Files
datacenter-designer/archive/old_public/js/rack-manager.js
Stefano Manfredi 3431a121a9 First commit
2025-10-27 11:57:38 +00:00

489 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = '<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');
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;
}
}