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 = 1510; // Fits 42 devices (42 * 35px + margins) this.rackSpacing = 80; this.gridSize = 600; // Default: rack width + spacing this.gridVertical = 1610; // Default: rack height + spacing (1510 + 100) this.racksLocked = true; // Start with racks locked this.nextX = 0; // Start at grid origin this.nextY = 0; // Start at grid origin // 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 = 1610; // 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 (clickable) 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' }); // Make name clickable nameLabel.on('click', () => { window.dispatchEvent(new CustomEvent('rename-rack', { detail: { rackId: rackData.id, rackData, rackShape: group } })); }); nameLabel.on('mouseenter', () => { document.body.style.cursor = 'pointer'; nameLabel.fill('#4A90E2'); this.layer.batchDraw(); }); nameLabel.on('mouseleave', () => { document.body.style.cursor = 'default'; nameLabel.fill('#333'); this.layer.batchDraw(); }); // 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) { try { await this.api.deleteRack(rackId); group.destroy(); this.racks.delete(rackId); this.layer.batchDraw(); // Notify table to sync 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 = ''; if (this.deviceManager && this.deviceManager.deviceTypes) { this.deviceManager.deviceTypes.forEach(type => { deviceTypesHTML += `
  • ${type.name}
  • `; }); } // Build unlock/management options let managementHTML = `
  • ${lockText}
  • `; // Show delete and spacing controls only when unlocked if (!this.racksLocked) { const horizontalSpacing = this.gridSize - this.rackWidth; const verticalSpacing = this.gridVertical - this.rackHeight; managementHTML += `
  • Delete Rack
  • Horizontal spacing: ${horizontalSpacing}px
  • Vertical spacing: ${verticalSpacing}px
  • `; } contextMenuList.innerHTML = ` ${deviceTypesHTML}
  • ${managementHTML} `; contextMenu.style.left = `${e.evt.pageX}px`; contextMenu.style.top = `${e.evt.pageY}px`; contextMenu.classList.remove('hidden'); 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); }; 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; } }