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 = '