488 lines
15 KiB
JavaScript
488 lines
15 KiB
JavaScript
export class RackManager {
|
||
constructor(layer, api, deviceManager) {
|
||
this.layer = layer;
|
||
this.api = api;
|
||
this.deviceManager = deviceManager;
|
||
this.racks = new Map();
|
||
this.rackPrefix = 'RACK';
|
||
this.rackWidth = 520; // Fits 500px wide devices with margins
|
||
this.rackHeight = 1485; // Fits 42 devices (42 * 30px + 41 * 5px spacing + 20px margins)
|
||
this.rackSpacing = 80;
|
||
this.gridSize = 600; // Default: rack width + spacing
|
||
this.gridVertical = 1585; // Default: rack height + spacing (1485 + 100)
|
||
this.racksLocked = true; // Start with racks locked
|
||
this.nextX = 0; // Start at grid origin
|
||
this.nextY = 0; // Start at grid origin
|
||
this.contextMenuHandler = null; // Store the current context menu handler
|
||
// Note: loadSpacing() will be called after project ID is set
|
||
}
|
||
|
||
loadSpacing() {
|
||
const projectId = this.api.currentProjectId;
|
||
const savedGridSize = localStorage.getItem(`gridSize_${projectId}`);
|
||
const savedGridVertical = localStorage.getItem(`gridVertical_${projectId}`);
|
||
|
||
if (savedGridSize) {
|
||
this.gridSize = parseInt(savedGridSize);
|
||
this.rackSpacing = this.gridSize - this.rackWidth;
|
||
} else {
|
||
this.gridSize = 600; // Default: rack width + spacing
|
||
}
|
||
|
||
if (savedGridVertical) {
|
||
this.gridVertical = parseInt(savedGridVertical);
|
||
} else {
|
||
this.gridVertical = 1585; // Default: rack height + spacing (fits 42 devices)
|
||
}
|
||
}
|
||
|
||
saveSpacing() {
|
||
const projectId = this.api.currentProjectId;
|
||
localStorage.setItem(`gridSize_${projectId}`, this.gridSize.toString());
|
||
localStorage.setItem(`gridVertical_${projectId}`, this.gridVertical.toString());
|
||
}
|
||
|
||
async toggleRacksLock() {
|
||
this.racksLocked = !this.racksLocked;
|
||
this.racks.forEach(rack => {
|
||
rack.shape.draggable(!this.racksLocked);
|
||
});
|
||
|
||
// Update device draggability
|
||
if (this.deviceManager) {
|
||
this.deviceManager.updateDevicesDraggability(!this.racksLocked);
|
||
}
|
||
|
||
// If locking, compact the grid (remove empty columns from the left)
|
||
if (this.racksLocked) {
|
||
await this.compactGrid();
|
||
}
|
||
|
||
return this.racksLocked;
|
||
}
|
||
|
||
async compactGrid() {
|
||
if (this.racks.size === 0) return;
|
||
|
||
// Get all rack positions and calculate their grid coordinates
|
||
const rackPositions = [];
|
||
this.racks.forEach((rack, id) => {
|
||
const gridX = Math.round(rack.data.x / this.gridSize);
|
||
const gridY = Math.round(rack.data.y / this.gridVertical);
|
||
rackPositions.push({ id, rack, gridX, gridY });
|
||
});
|
||
|
||
// Find the minimum grid X (leftmost column that has racks)
|
||
const minGridX = Math.min(...rackPositions.map(r => r.gridX));
|
||
|
||
// If minGridX is 0, grid is already compact
|
||
if (minGridX === 0) return;
|
||
|
||
// Shift all racks left by minGridX columns
|
||
const updatePromises = [];
|
||
|
||
for (const rackPos of rackPositions) {
|
||
const newGridX = rackPos.gridX - minGridX;
|
||
const newX = newGridX * this.gridSize;
|
||
const newY = rackPos.gridY * this.gridVertical;
|
||
|
||
// Update visual position
|
||
rackPos.rack.shape.position({ x: newX, y: newY });
|
||
|
||
// Update data
|
||
rackPos.rack.data.x = newX;
|
||
rackPos.rack.data.y = newY;
|
||
|
||
// Queue database update
|
||
updatePromises.push(this.api.updateRackPosition(rackPos.id, newX, newY));
|
||
}
|
||
|
||
// Redraw once
|
||
this.layer.batchDraw();
|
||
|
||
// Wait for all updates
|
||
await Promise.all(updatePromises);
|
||
}
|
||
|
||
snapToGrid(value, gridSize) {
|
||
return Math.round(value / gridSize) * gridSize;
|
||
}
|
||
|
||
async loadRacks() {
|
||
try {
|
||
const racks = await this.api.getRacks();
|
||
racks.forEach(rackData => {
|
||
this.createRackShape(rackData);
|
||
});
|
||
this.layer.batchDraw();
|
||
} catch (err) {
|
||
console.error('Failed to load racks:', err);
|
||
}
|
||
}
|
||
|
||
createRackShape(rackData) {
|
||
const group = new Konva.Group({
|
||
x: rackData.x,
|
||
y: rackData.y,
|
||
draggable: !this.racksLocked, // Locked by default
|
||
id: `rack-${rackData.id}`
|
||
});
|
||
|
||
// Rack background
|
||
const rect = new Konva.Rect({
|
||
width: rackData.width || this.rackWidth,
|
||
height: rackData.height || this.rackHeight,
|
||
fill: '#ffffff',
|
||
stroke: '#333',
|
||
strokeWidth: 2,
|
||
shadowColor: 'black',
|
||
shadowBlur: 5,
|
||
shadowOpacity: 0.1,
|
||
shadowOffset: { x: 2, y: 2 }
|
||
});
|
||
|
||
// Rack name label
|
||
const nameLabel = new Konva.Text({
|
||
x: 0,
|
||
y: -30,
|
||
width: rackData.width || this.rackWidth,
|
||
text: rackData.name,
|
||
fontSize: 16,
|
||
fontStyle: 'bold',
|
||
fill: '#333',
|
||
align: 'center',
|
||
name: 'rack-name'
|
||
});
|
||
|
||
// Double-click to rename (consistent with device behavior)
|
||
nameLabel.on('dblclick', () => {
|
||
window.dispatchEvent(new CustomEvent('rename-rack', {
|
||
detail: { rackId: rackData.id, rackData, rackShape: group }
|
||
}));
|
||
});
|
||
|
||
// Container for devices
|
||
const devicesLayer = new Konva.Group({
|
||
name: 'devices-container'
|
||
});
|
||
|
||
group.add(rect);
|
||
group.add(nameLabel);
|
||
group.add(devicesLayer);
|
||
|
||
// Grid snapping during drag
|
||
group.on('dragmove', () => {
|
||
const x = this.snapToGrid(group.x(), this.gridSize);
|
||
const y = this.snapToGrid(group.y(), this.gridVertical);
|
||
group.position({ x, y });
|
||
});
|
||
|
||
// Drag end - update position in DB with smart positioning
|
||
group.on('dragend', async () => {
|
||
try {
|
||
const newX = this.snapToGrid(group.x(), this.gridSize);
|
||
const newY = this.snapToGrid(group.y(), this.gridVertical);
|
||
|
||
// Check if position is occupied by another rack
|
||
await this.handleRackPlacement(rackData.id, newX, newY);
|
||
} catch (err) {
|
||
console.error('Failed to update rack position:', err);
|
||
}
|
||
});
|
||
|
||
// Right-click context menu
|
||
group.on('contextmenu', (e) => {
|
||
e.evt.preventDefault();
|
||
this.showContextMenu(e, rackData, group);
|
||
});
|
||
|
||
this.layer.add(group);
|
||
this.racks.set(rackData.id, { data: rackData, shape: group });
|
||
|
||
return group;
|
||
}
|
||
|
||
async addRack() {
|
||
try {
|
||
const nextName = await this.api.getNextRackName(this.rackPrefix);
|
||
|
||
const rackData = await this.api.createRack(nextName, this.nextX, this.nextY);
|
||
|
||
this.createRackShape(rackData);
|
||
this.layer.batchDraw();
|
||
|
||
// Update next position (using grid sizes)
|
||
this.nextX += this.gridSize;
|
||
if (this.nextX > 1200) {
|
||
this.nextX = 0;
|
||
this.nextY += this.gridVertical;
|
||
}
|
||
|
||
// Notify table to sync
|
||
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
|
||
|
||
return rackData;
|
||
} catch (err) {
|
||
console.error('Failed to add rack:', err);
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
async deleteRack(rackId, group, suppressEvent = false) {
|
||
try {
|
||
await this.api.deleteRack(rackId);
|
||
group.destroy();
|
||
this.racks.delete(rackId);
|
||
this.layer.batchDraw();
|
||
|
||
// Notify table to sync (unless suppressed for bulk operations)
|
||
if (!suppressEvent) {
|
||
window.dispatchEvent(new CustomEvent('canvas-data-changed'));
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to delete rack:', err);
|
||
}
|
||
}
|
||
|
||
showContextMenu(e, rackData, group) {
|
||
const contextMenu = document.getElementById('contextMenu');
|
||
const contextMenuList = document.getElementById('contextMenuList');
|
||
|
||
const lockText = this.racksLocked ? 'Unlock All Racks' : 'Lock All Racks';
|
||
|
||
// Build device types list with header
|
||
let deviceTypesHTML = '<li class="menu-header">Add device:</li>';
|
||
if (this.deviceManager && this.deviceManager.deviceTypes) {
|
||
this.deviceManager.deviceTypes.forEach(type => {
|
||
deviceTypesHTML += `<li data-action="add-device" data-device-type-id="${type.id}" data-device-type-name="${type.name}">${type.name}</li>`;
|
||
});
|
||
}
|
||
|
||
// Build unlock/management options
|
||
let managementHTML = `<li data-action="toggle-lock">${lockText}</li>`;
|
||
|
||
// Show delete and spacing controls only when unlocked
|
||
if (!this.racksLocked) {
|
||
const horizontalSpacing = this.gridSize - this.rackWidth;
|
||
const verticalSpacing = this.gridVertical - this.rackHeight;
|
||
|
||
managementHTML += `
|
||
<li data-action="delete">Delete Rack</li>
|
||
<li class="divider"></li>
|
||
<li class="spacing-control">
|
||
<span class="spacing-label">Horizontal spacing: ${horizontalSpacing}px</span>
|
||
<div class="spacing-buttons">
|
||
<button class="spacing-btn" data-action="h-spacing-decrease">−</button>
|
||
<button class="spacing-btn" data-action="h-spacing-increase">+</button>
|
||
</div>
|
||
</li>
|
||
<li class="spacing-control">
|
||
<span class="spacing-label">Vertical spacing: ${verticalSpacing}px</span>
|
||
<div class="spacing-buttons">
|
||
<button class="spacing-btn" data-action="v-spacing-decrease">−</button>
|
||
<button class="spacing-btn" data-action="v-spacing-increase">+</button>
|
||
</div>
|
||
</li>
|
||
`;
|
||
}
|
||
|
||
contextMenuList.innerHTML = `
|
||
${deviceTypesHTML}
|
||
<li class="divider"></li>
|
||
${managementHTML}
|
||
`;
|
||
|
||
contextMenu.style.left = `${e.evt.pageX}px`;
|
||
contextMenu.style.top = `${e.evt.pageY}px`;
|
||
contextMenu.classList.remove('hidden');
|
||
|
||
// Remove previous event listener if exists
|
||
if (this.contextMenuHandler) {
|
||
contextMenuList.removeEventListener('click', this.contextMenuHandler);
|
||
}
|
||
|
||
const handleAction = async (evt) => {
|
||
const action = evt.target.dataset.action;
|
||
|
||
// For spacing buttons, prevent default and stop propagation
|
||
if (action && action.includes('spacing')) {
|
||
evt.preventDefault();
|
||
evt.stopPropagation();
|
||
}
|
||
|
||
if (action === 'add-device') {
|
||
const deviceTypeId = parseInt(evt.target.dataset.deviceTypeId);
|
||
const deviceTypeName = evt.target.dataset.deviceTypeName;
|
||
const deviceName = prompt(`Enter name for ${deviceTypeName}:`, deviceTypeName);
|
||
|
||
if (deviceName) {
|
||
try {
|
||
// Check if name will be auto-numbered
|
||
const uniqueName = this.deviceManager.generateUniqueName(deviceName);
|
||
if (uniqueName !== deviceName) {
|
||
const proceed = confirm(`Device name "${deviceName}" is already in use.\n\nDevice will be named "${uniqueName}" instead.\n\nContinue?`);
|
||
if (!proceed) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
const position = this.deviceManager.getNextDevicePosition(rackData.id);
|
||
await this.deviceManager.addDevice(deviceTypeId, rackData.id, position, deviceName);
|
||
} catch (err) {
|
||
alert('Failed to add device: ' + err.message);
|
||
}
|
||
}
|
||
} else if (action === 'delete') {
|
||
if (confirm(`Delete rack ${rackData.name}?`)) {
|
||
this.deleteRack(rackData.id, group);
|
||
}
|
||
} else if (action === 'toggle-lock') {
|
||
const isLocked = await this.toggleRacksLock();
|
||
const statusText = isLocked ? 'Racks locked (grid compacted)' : 'Racks unlocked';
|
||
// Close and reopen menu to refresh the lock state
|
||
contextMenu.classList.add('hidden');
|
||
setTimeout(() => {
|
||
this.showContextMenu(e, rackData, group);
|
||
}, 10);
|
||
return; // Don't close menu handler
|
||
} else if (action === 'h-spacing-increase') {
|
||
await this.adjustSpacing('horizontal', 10);
|
||
return; // Don't close menu
|
||
} else if (action === 'h-spacing-decrease') {
|
||
await this.adjustSpacing('horizontal', -10);
|
||
return; // Don't close menu
|
||
} else if (action === 'v-spacing-increase') {
|
||
await this.adjustSpacing('vertical', 50);
|
||
return; // Don't close menu
|
||
} else if (action === 'v-spacing-decrease') {
|
||
await this.adjustSpacing('vertical', -50);
|
||
return; // Don't close menu
|
||
}
|
||
|
||
contextMenu.classList.add('hidden');
|
||
contextMenuList.removeEventListener('click', handleAction);
|
||
this.contextMenuHandler = null;
|
||
};
|
||
|
||
// Store and add the new handler
|
||
this.contextMenuHandler = handleAction;
|
||
contextMenuList.addEventListener('click', handleAction);
|
||
}
|
||
|
||
getRackShape(rackId) {
|
||
const rack = this.racks.get(rackId);
|
||
return rack ? rack.shape : null;
|
||
}
|
||
|
||
getRackData(rackId) {
|
||
const rack = this.racks.get(rackId);
|
||
return rack ? rack.data : null;
|
||
}
|
||
|
||
async handleRackPlacement(movedRackId, newX, newY) {
|
||
// Get all racks in the same row (same Y coordinate)
|
||
const racksInRow = [];
|
||
this.racks.forEach((rack, id) => {
|
||
if (id !== movedRackId && rack.data.y === newY) {
|
||
racksInRow.push({ id, rack, x: rack.data.x });
|
||
}
|
||
});
|
||
|
||
// Sort by X position
|
||
racksInRow.sort((a, b) => a.x - b.x);
|
||
|
||
// Check if new position is occupied
|
||
const occupiedRack = racksInRow.find(r => r.x === newX);
|
||
|
||
if (occupiedRack) {
|
||
// Position is occupied - shift all racks at and to the right of this position
|
||
const racksToShift = racksInRow.filter(r => r.x >= newX);
|
||
|
||
// Shift each rack one grid position to the right
|
||
for (const rackInfo of racksToShift) {
|
||
const newRackX = rackInfo.x + this.gridSize;
|
||
|
||
// Update visual position
|
||
rackInfo.rack.shape.position({ x: newRackX, y: newY });
|
||
|
||
// Update data
|
||
rackInfo.rack.data.x = newRackX;
|
||
rackInfo.rack.data.y = newY;
|
||
|
||
// Update in database
|
||
await this.api.updateRackPosition(rackInfo.id, newRackX, newY);
|
||
}
|
||
}
|
||
|
||
// Update the moved rack
|
||
const movedRack = this.racks.get(movedRackId);
|
||
if (movedRack) {
|
||
movedRack.shape.position({ x: newX, y: newY });
|
||
movedRack.data.x = newX;
|
||
movedRack.data.y = newY;
|
||
await this.api.updateRackPosition(movedRackId, newX, newY);
|
||
}
|
||
|
||
// Redraw
|
||
this.layer.batchDraw();
|
||
}
|
||
|
||
async adjustSpacing(direction, delta) {
|
||
// Calculate grid coordinates for all racks BEFORE changing spacing
|
||
const rackGridPositions = new Map();
|
||
|
||
this.racks.forEach((rack, id) => {
|
||
const gridX = Math.round(rack.data.x / this.gridSize);
|
||
const gridY = Math.round(rack.data.y / this.gridVertical);
|
||
rackGridPositions.set(id, { gridX, gridY });
|
||
});
|
||
|
||
// Adjust spacing (this updates the grid references)
|
||
if (direction === 'horizontal') {
|
||
const newSpacing = (this.gridSize - this.rackWidth) + delta;
|
||
if (newSpacing < 10) return; // Minimum spacing
|
||
this.gridSize = this.rackWidth + newSpacing;
|
||
this.rackSpacing = newSpacing; // Update the spacing value
|
||
} else {
|
||
const newSpacing = (this.gridVertical - this.rackHeight) + delta;
|
||
if (newSpacing < 10) return; // Minimum spacing
|
||
this.gridVertical = this.rackHeight + newSpacing;
|
||
}
|
||
|
||
// Batch all position updates
|
||
const updatePromises = [];
|
||
|
||
// Recalculate all rack positions at once
|
||
for (const [id, gridPos] of rackGridPositions) {
|
||
const rack = this.racks.get(id);
|
||
if (!rack) continue;
|
||
|
||
const newX = gridPos.gridX * this.gridSize;
|
||
const newY = gridPos.gridY * this.gridVertical;
|
||
|
||
// Update visual position
|
||
rack.shape.position({ x: newX, y: newY });
|
||
|
||
// Update data
|
||
rack.data.x = newX;
|
||
rack.data.y = newY;
|
||
|
||
// Queue database update (don't await yet)
|
||
updatePromises.push(this.api.updateRackPosition(id, newX, newY));
|
||
}
|
||
|
||
// Redraw once for all changes
|
||
this.layer.batchDraw();
|
||
|
||
// Wait for all database updates to complete
|
||
await Promise.all(updatePromises);
|
||
|
||
// Save spacing to localStorage
|
||
this.saveSpacing();
|
||
|
||
// Update status
|
||
const horizontalSpacing = this.gridSize - this.rackWidth;
|
||
const verticalSpacing = this.gridVertical - this.rackHeight;
|
||
}
|
||
}
|