export class ConnectionManager { constructor(layer, api, deviceManager, rackManager) { this.layer = layer; this.api = api; this.deviceManager = deviceManager; this.rackManager = rackManager; this.connections = new Map(); this.connectionLayer = new Konva.Layer(); this.pendingConnection = null; this.tempLine = null; this.currentView = 'physical'; // Track current view this.selectedConnection = null; // Track selected connection for keyboard deletion this.contextMenuHandler = null; // Store the current context menu handler // Set up keyboard event listener for Delete key this.setupKeyboardListeners(); } setupKeyboardListeners() { document.addEventListener('keydown', (e) => { if (e.key === 'Delete' || e.key === 'Backspace') { if (this.selectedConnection) { e.preventDefault(); const conn = this.connections.get(this.selectedConnection); if (conn) { if (confirm('Delete this connection?')) { this.deleteConnection(this.selectedConnection, conn.shape, conn.handles); } } } } else if (e.key === 'Escape') { // Deselect connection on Escape this.deselectConnection(); } }); } selectConnection(connectionId, line, handles) { // Deselect previous connection this.deselectConnection(); // Select this connection this.selectedConnection = connectionId; line.stroke('#FF6B6B'); // Red highlight for selected line.strokeWidth(3); handles.forEach(h => h.opacity(1)); this.connectionLayer.batchDraw(); } deselectConnection() { if (this.selectedConnection) { const conn = this.connections.get(this.selectedConnection); if (conn) { conn.shape.stroke('#000000'); conn.shape.strokeWidth(1); conn.handles.forEach(h => h.opacity(0)); this.connectionLayer.batchDraw(); } this.selectedConnection = null; } } getConnectionLayer() { return this.connectionLayer; } setCurrentView(viewType) { this.currentView = viewType; // Reload all connections to use waypoints for the new view this.reloadConnectionsForView(); } async reloadConnectionsForView() { // Clear existing connections from layer this.connections.forEach(conn => { conn.shape.destroy(); conn.handles.forEach(h => h.destroy()); }); this.connections.clear(); // Reload connections with view-specific waypoints try { const connections = await this.api.getConnections(); connections.forEach(connData => { this.createConnectionLine(connData); }); this.connectionLayer.batchDraw(); } catch (err) { console.error('Failed to reload connections:', err); } } isConnectionMode() { return this.pendingConnection !== null; } async loadConnections() { try { const connections = await this.api.getConnections(); connections.forEach(connData => { this.createConnectionLine(connData); }); this.connectionLayer.batchDraw(); } catch (err) { console.error('Failed to load connections:', err); } } createConnectionLine(connData) { const sourceDevice = this.deviceManager.getDeviceShape(connData.source_device_id); const targetDevice = this.deviceManager.getDeviceShape(connData.target_device_id); if (!sourceDevice || !targetDevice) { console.error('Device shapes not found for connection:', connData); return; } const conn = this.drawConnection(sourceDevice, targetDevice, connData); this.connections.set(connData.id, conn); return conn; } // Alias for table-manager compatibility createConnectionShape(connData) { return this.createConnectionLine(connData); } drawConnection(sourceShape, targetShape, connData) { const deviceWidth = this.deviceManager.deviceWidth; const deviceHeight = this.deviceManager.deviceHeight; const sourcePos = this.getDeviceAbsolutePosition(sourceShape); const targetPos = this.getDeviceAbsolutePosition(targetShape); // Calculate edge points based on relative positions const { sourcePoint, targetPoint } = this.getEdgeConnectionPoints( sourcePos, targetPos, deviceWidth, deviceHeight ); // Load waypoints based on current view let points; let viewWaypoints = null; // Get view-specific waypoints if (this.currentView === 'physical' && connData.waypoints_physical) { viewWaypoints = typeof connData.waypoints_physical === 'string' ? JSON.parse(connData.waypoints_physical) : connData.waypoints_physical; } else if (this.currentView === 'logical' && connData.waypoints_logical) { viewWaypoints = typeof connData.waypoints_logical === 'string' ? JSON.parse(connData.waypoints_logical) : connData.waypoints_logical; } else if (connData.waypoints) { // Fallback to legacy waypoints column viewWaypoints = typeof connData.waypoints === 'string' ? JSON.parse(connData.waypoints) : connData.waypoints; } if (viewWaypoints && viewWaypoints.length > 0) { // Use saved waypoints for this view const hasEdgePoints = viewWaypoints[0].isEdge !== undefined; if (hasEdgePoints) { // Use saved edge points and middle waypoints const edgeStart = viewWaypoints.find((p, i) => p.isEdge && i === 0) || { x: sourcePoint.x, y: sourcePoint.y }; const edgeEnd = viewWaypoints.find((p, i) => p.isEdge && i === viewWaypoints.length - 1) || { x: targetPoint.x, y: targetPoint.y }; const middleWaypoints = viewWaypoints.filter(p => !p.isEdge); points = this.rebuildOrthogonalPath(edgeStart, middleWaypoints, edgeEnd); } else { // Old format: saved waypoints are only middle points points = this.rebuildOrthogonalPath(sourcePoint, viewWaypoints, targetPoint); } } else { // No waypoints for this view - create default path points = this.createSimpleOrthogonalPath(sourcePoint, targetPoint); } // Create the line const line = new Konva.Line({ points: points, stroke: '#000000', strokeWidth: 1, lineCap: 'round', lineJoin: 'round', id: `connection-${connData.id}`, opacity: 0.8, hitStrokeWidth: 10 }); // Add line to layer FIRST so it's drawn underneath handles this.connectionLayer.add(line); // Create draggable handles ONLY for waypoints + edge points // Don't create handles for auto-generated corner points const handles = []; // Get waypoints from connection data let savedWaypoints = []; if (this.currentView === 'physical' && connData.waypoints_physical) { const parsed = typeof connData.waypoints_physical === 'string' ? JSON.parse(connData.waypoints_physical) : connData.waypoints_physical; savedWaypoints = parsed.filter(p => !p.isEdge); } else if (this.currentView === 'logical' && connData.waypoints_logical) { const parsed = typeof connData.waypoints_logical === 'string' ? JSON.parse(connData.waypoints_logical) : connData.waypoints_logical; savedWaypoints = parsed.filter(p => !p.isEdge); } // Create handles: start point, user waypoints, end point const handlePoints = [ { x: points[0], y: points[1], isEdge: true }, ...savedWaypoints.map(wp => ({ x: wp.x, y: wp.y, isEdge: false })), { x: points[points.length - 2], y: points[points.length - 1], isEdge: true } ]; handlePoints.forEach((pt, i) => { const handle = new Konva.Circle({ x: pt.x, y: pt.y, radius: 4, fill: '#4A90E2', stroke: '#fff', strokeWidth: 1, draggable: true, opacity: 0, // Hidden by default name: `handle-${i}`, isEdge: pt.isEdge }); handles.push(handle); // Add handle AFTER line, so handles are on top this.connectionLayer.add(handle); }); // Click to select connection line.on('click', (e) => { e.cancelBubble = true; this.selectConnection(connData.id, line, handles); }); // Hover behavior: show handles and highlight line line.on('mouseenter', () => { if (this.selectedConnection !== connData.id) { line.stroke('#4A90E2'); // Blue highlight line.strokeWidth(2); handles.forEach(h => h.opacity(1)); this.connectionLayer.batchDraw(); } }); line.on('mouseleave', () => { // Only hide if no handle is being dragged and not selected const isDragging = handles.some(h => h.isDragging()); if (!isDragging && this.selectedConnection !== connData.id) { line.stroke('#000000'); line.strokeWidth(1); handles.forEach(h => h.opacity(0)); this.connectionLayer.batchDraw(); } }); // Handle event listeners handles.forEach((handle, idx) => { const isEdgeHandle = handle.getAttr('isEdge'); handle.on('mouseenter', () => { // Keep line highlighted and handles visible when hovering over handles line.stroke('#4A90E2'); line.strokeWidth(2); handles.forEach(h => h.opacity(1)); this.connectionLayer.batchDraw(); }); handle.on('mousedown', (e) => e.cancelBubble = true); // Double-click on waypoint handle to DELETE it handle.on('dblclick', (e) => { e.cancelBubble = true; if (!isEdgeHandle) { // Delete this waypoint this.deleteWaypoint(line, handles, idx, connData); } }); handle.on('dragmove', () => { this.updateLineFromHandles(line, handles); }); handle.on('dragend', () => { // Snap edge handles to nearest device edge if (isEdgeHandle) { const snappedPos = this.snapToNearestDeviceEdge(handle.x(), handle.y()); handle.position(snappedPos); this.updateLineFromHandles(line, handles); } this.saveWaypoints(connData.id, handles); // After drag, check if mouse is still over line or handles to keep handles visible const stage = this.connectionLayer.getStage(); const pointerPos = stage.getPointerPosition(); if (pointerPos) { const shape = this.connectionLayer.getIntersection(pointerPos); const isOverLineOrHandle = shape === line || handles.includes(shape); if (!isOverLineOrHandle) { // Mouse not over line or handles, hide handles and reset line style line.stroke('#000000'); line.strokeWidth(1); handles.forEach(h => h.opacity(0)); this.connectionLayer.batchDraw(); } } }); }); // Double-click on line to add waypoint line.on('dblclick', (e) => { const stage = this.connectionLayer.getStage(); const pointerPos = stage.getPointerPosition(); const scale = stage.scaleX(); const stagePos = stage.position(); // Convert to world coordinates const worldX = (pointerPos.x - stagePos.x) / scale; const worldY = (pointerPos.y - stagePos.y) / scale; // Create new waypoint at double-click position this.addWaypointAtPosition(line, handles, worldX, worldY, connData); }); // Right-click to delete line.on('contextmenu', (e) => { e.evt.preventDefault(); this.showConnectionContextMenu(e, connData, line, handles); }); // Update line when devices move const updateLine = () => { const newSourcePos = this.getDeviceAbsolutePosition(sourceShape); const newTargetPos = this.getDeviceAbsolutePosition(targetShape); const { sourcePoint: newSourcePoint, targetPoint: newTargetPoint } = this.getEdgeConnectionPoints(newSourcePos, newTargetPos, this.deviceManager.deviceWidth, this.deviceManager.deviceHeight); // Update first and last handle positions (start and end) handles[0].position(newSourcePoint); handles[handles.length - 1].position(newTargetPoint); // Get current middle waypoints const waypoints = handles.slice(1, -1).map(h => ({ x: h.x(), y: h.y() })); // Rebuild path with new endpoints and current waypoints const newPoints = this.rebuildOrthogonalPath(newSourcePoint, waypoints, newTargetPoint); line.points(newPoints); this.connectionLayer.batchDraw(); }; // Attach update listeners based on current view if (this.currentView === 'logical') { // In logical view, devices are on main layer - attach listeners to devices sourceShape.on('dragmove.connection', updateLine); targetShape.on('dragmove.connection', updateLine); } else { // In physical view, devices are in racks - attach listeners to racks const sourceRack = sourceShape.getParent().getParent(); const targetRack = targetShape.getParent().getParent(); if (sourceRack) sourceRack.on('dragmove.connection', updateLine); if (targetRack) targetRack.on('dragmove.connection', updateLine); } // Line already added earlier (before handles, for correct z-order) return { data: connData, shape: line, handles: handles, sourceShape, targetShape }; } addWaypointAtPosition(line, handles, worldX, worldY, connData) { const conn = this.connections.get(connData.id); if (!conn) return; // Get current points const startPoint = { x: handles[0].x(), y: handles[0].y() }; const endPoint = { x: handles[handles.length - 1].x(), y: handles[handles.length - 1].y() }; const waypoints = handles.slice(1, -1).map(h => ({ x: h.x(), y: h.y() })); // Add new waypoint waypoints.push({ x: worldX, y: worldY }); // Recreate handles this.recreateHandles(line, handles, startPoint, waypoints, endPoint, connData); // Show handles and highlight line (we just added a waypoint) line.stroke('#4A90E2'); line.strokeWidth(2); const updatedHandles = conn.handles; updatedHandles.forEach(h => h.opacity(1)); // Save this.saveWaypoints(connData.id, updatedHandles); this.connectionLayer.batchDraw(); } deleteWaypoint(line, handles, handleIndex, connData) { const conn = this.connections.get(connData.id); if (!conn) return; // Get current points const startPoint = { x: handles[0].x(), y: handles[0].y() }; const endPoint = { x: handles[handles.length - 1].x(), y: handles[handles.length - 1].y() }; const waypoints = handles.slice(1, -1).map(h => ({ x: h.x(), y: h.y() })); // Remove the waypoint at the specified index (adjust for edge handle at index 0) const waypointIndex = handleIndex - 1; if (waypointIndex >= 0 && waypointIndex < waypoints.length) { waypoints.splice(waypointIndex, 1); } // Recreate handles this.recreateHandles(line, handles, startPoint, waypoints, endPoint, connData); // Show handles and highlight line line.stroke('#4A90E2'); line.strokeWidth(2); const updatedHandles = conn.handles; updatedHandles.forEach(h => h.opacity(1)); // Save this.saveWaypoints(connData.id, updatedHandles); this.connectionLayer.batchDraw(); } recreateHandles(line, handles, startPoint, waypoints, endPoint, connData) { // Destroy old handles handles.forEach(h => h.destroy()); handles.length = 0; // Rebuild path const newPoints = this.rebuildOrthogonalPath(startPoint, waypoints, endPoint); line.points(newPoints); // Create new handles: start point, user waypoints, end point const handlePoints = [ { ...startPoint, isEdge: true }, ...waypoints.map(wp => ({ ...wp, isEdge: false })), { ...endPoint, isEdge: true } ]; handlePoints.forEach((pt, i) => { const isEdgeHandle = pt.isEdge; const handle = new Konva.Circle({ x: pt.x, y: pt.y, radius: 4, fill: '#4A90E2', stroke: '#fff', strokeWidth: 1, draggable: true, opacity: 0, // Hidden by default name: `handle-${i}`, isEdge: pt.isEdge }); // Attach event listeners handle.on('mouseenter', () => { // Keep line highlighted and handles visible when hovering over handles line.stroke('#4A90E2'); line.strokeWidth(2); handles.forEach(h => h.opacity(1)); this.connectionLayer.batchDraw(); }); handle.on('mousedown', (e) => e.cancelBubble = true); // Double-click on waypoint handle to DELETE it handle.on('dblclick', (e) => { e.cancelBubble = true; if (!isEdgeHandle) { // Delete this waypoint this.deleteWaypoint(line, handles, i, connData); } }); handle.on('dragmove', () => { this.updateLineFromHandles(line, handles); }); handle.on('dragend', () => { if (isEdgeHandle) { const snappedPos = this.snapToNearestDeviceEdge(handle.x(), handle.y()); handle.position(snappedPos); this.updateLineFromHandles(line, handles); } this.saveWaypoints(connData.id, handles); // After drag, check if mouse is still over line or handles to keep handles visible const stage = this.connectionLayer.getStage(); const pointerPos = stage.getPointerPosition(); if (pointerPos) { const shape = this.connectionLayer.getIntersection(pointerPos); const isOverLineOrHandle = shape === line || handles.includes(shape); if (!isOverLineOrHandle) { // Mouse not over line or handles, hide handles and reset line style line.stroke('#000000'); line.strokeWidth(1); handles.forEach(h => h.opacity(0)); this.connectionLayer.batchDraw(); } } }); handles.push(handle); this.connectionLayer.add(handle); }); // Update connection reference const conn = this.connections.get(connData.id); if (conn) conn.handles = handles; } async saveWaypoints(connectionId, handles) { try { // Only save user waypoints (middle handles), not edge points // Edge points are recalculated based on device positions const waypoints = handles.slice(1, -1).map(h => ({ x: h.x(), y: h.y() })); // Save to view-specific column await this.api.updateConnectionWaypoints(connectionId, waypoints, this.currentView); } catch (err) { console.error('Failed to save waypoints:', err); } } createSimpleOrthogonalPath(start, end) { // Create a proper orthogonal path with right angles only const dx = Math.abs(end.x - start.x); const dy = Math.abs(end.y - start.y); // If points are already aligned (same x or y), draw straight line if (dx === 0 || dy === 0) { return [start.x, start.y, end.x, end.y]; } // Create L-shaped path based on which direction is dominant if (dx > dy) { // Horizontal first, then vertical return [ start.x, start.y, end.x, start.y, end.x, end.y ]; } else { // Vertical first, then horizontal return [ start.x, start.y, start.x, end.y, end.x, end.y ]; } } snapToNearestDeviceEdge(x, y) { const deviceWidth = this.deviceManager.deviceWidth; const deviceHeight = this.deviceManager.deviceHeight; let nearestEdge = null; let minDistance = Infinity; // Get all devices from deviceManager this.deviceManager.devices.forEach((device, deviceId) => { const deviceShape = device.shape; if (!deviceShape) return; const devicePos = this.getDeviceAbsolutePosition(deviceShape); // Calculate all 4 edges const edges = [ { type: 'left', x: devicePos.x, y: Math.max(devicePos.y, Math.min(devicePos.y + deviceHeight, y)), line: { x1: devicePos.x, y1: devicePos.y, x2: devicePos.x, y2: devicePos.y + deviceHeight } }, { type: 'right', x: devicePos.x + deviceWidth, y: Math.max(devicePos.y, Math.min(devicePos.y + deviceHeight, y)), line: { x1: devicePos.x + deviceWidth, y1: devicePos.y, x2: devicePos.x + deviceWidth, y2: devicePos.y + deviceHeight } }, { type: 'top', x: Math.max(devicePos.x, Math.min(devicePos.x + deviceWidth, x)), y: devicePos.y, line: { x1: devicePos.x, y1: devicePos.y, x2: devicePos.x + deviceWidth, y2: devicePos.y } }, { type: 'bottom', x: Math.max(devicePos.x, Math.min(devicePos.x + deviceWidth, x)), y: devicePos.y + deviceHeight, line: { x1: devicePos.x, y1: devicePos.y + deviceHeight, x2: devicePos.x + deviceWidth, y2: devicePos.y + deviceHeight } } ]; // Find nearest edge point edges.forEach(edge => { const dist = Math.sqrt(Math.pow(edge.x - x, 2) + Math.pow(edge.y - y, 2)); if (dist < minDistance) { minDistance = dist; nearestEdge = { x: edge.x, y: edge.y }; } }); }); // Return snapped position or original if no devices found return nearestEdge || { x, y }; } getEdgeConnectionPoints(sourcePos, targetPos, deviceWidth, deviceHeight) { // Calculate center points const sourceCenterX = sourcePos.x + deviceWidth / 2; const sourceCenterY = sourcePos.y + deviceHeight / 2; const targetCenterX = targetPos.x + deviceWidth / 2; const targetCenterY = targetPos.y + deviceHeight / 2; const dx = targetCenterX - sourceCenterX; const dy = targetCenterY - sourceCenterY; let sourcePoint, targetPoint; // Determine which edges to connect based on relative positions if (Math.abs(dx) > Math.abs(dy)) { // Primarily horizontal - use left/right edges if (dx > 0) { // Source right edge, target left edge sourcePoint = { x: sourcePos.x + deviceWidth, y: sourceCenterY }; targetPoint = { x: targetPos.x, y: targetCenterY }; } else { // Source left edge, target right edge sourcePoint = { x: sourcePos.x, y: sourceCenterY }; targetPoint = { x: targetPos.x + deviceWidth, y: targetCenterY }; } } else { // Primarily vertical - use top/bottom edges if (dy > 0) { // Source bottom edge, target top edge sourcePoint = { x: sourceCenterX, y: sourcePos.y + deviceHeight }; targetPoint = { x: targetCenterX, y: targetPos.y }; } else { // Source top edge, target bottom edge sourcePoint = { x: sourceCenterX, y: sourcePos.y }; targetPoint = { x: targetCenterX, y: targetPos.y + deviceHeight }; } } return { sourcePoint, targetPoint }; } updateLineFromHandles(line, handles) { if (handles.length < 2) return; const startPoint = { x: handles[0].x(), y: handles[0].y() }; const endPoint = { x: handles[handles.length - 1].x(), y: handles[handles.length - 1].y() }; const waypoints = handles.slice(1, -1).map(h => ({ x: h.x(), y: h.y() })); const newPoints = this.rebuildOrthogonalPath(startPoint, waypoints, endPoint); line.points(newPoints); this.connectionLayer.batchDraw(); } rebuildOrthogonalPath(start, waypoints, end) { // Simple orthogonal path: just connect all points with right angles const points = [start.x, start.y]; let prev = start; const allPoints = [...waypoints, end]; allPoints.forEach(curr => { const dx = Math.abs(curr.x - prev.x); const dy = Math.abs(curr.y - prev.y); // Add corner point if needed if (dx > 0 && dy > 0) { // Choose direction based on larger distance if (dx > dy) { points.push(curr.x, prev.y); // Horizontal first } else { points.push(prev.x, curr.y); // Vertical first } } points.push(curr.x, curr.y); prev = curr; }); return points; } getDeviceAbsolutePosition(deviceShape) { if (this.currentView === 'logical') { // In logical view, devices are on main layer with absolute positioning return { x: deviceShape.x(), y: deviceShape.y() }; } else { // In physical view, devices are in rack containers with relative positioning const parent = deviceShape.getParent(); if (!parent || parent === this.layer) { // Device is on main layer (shouldn't happen in physical view, but handle it) return { x: deviceShape.x(), y: deviceShape.y() }; } const rack = parent.getParent(); return { x: rack.x() + deviceShape.x(), y: rack.y() + deviceShape.y() }; } } updateAllConnections() { // Update all connection paths this.connections.forEach(conn => { if (!conn.sourceShape || !conn.targetShape) return; const deviceWidth = this.deviceManager.deviceWidth; const deviceHeight = this.deviceManager.deviceHeight; const sourcePos = this.getDeviceAbsolutePosition(conn.sourceShape); const targetPos = this.getDeviceAbsolutePosition(conn.targetShape); const { sourcePoint, targetPoint } = this.getEdgeConnectionPoints( sourcePos, targetPos, deviceWidth, deviceHeight ); // Update first and last handle positions (start and end) conn.handles[0].position(sourcePoint); conn.handles[conn.handles.length - 1].position(targetPoint); // Get middle waypoint positions from handles const waypoints = conn.handles.slice(1, -1).map(h => ({ x: h.x(), y: h.y() })); // Rebuild path const points = this.rebuildOrthogonalPath(sourcePoint, waypoints, targetPoint); conn.shape.points(points); }); this.connectionLayer.batchDraw(); } startConnection(deviceId, deviceShape) { // Cancel any existing connection mode if (this.tempLine) { this.tempLine.destroy(); this.tempLine = null; } const deviceData = this.deviceManager.getDeviceData(deviceId); if (!deviceData) return; const deviceWidth = this.deviceManager.deviceWidth; const deviceHeight = this.deviceManager.deviceHeight; const deviceAbsPos = this.getDeviceAbsolutePosition(deviceShape); // Start from device center (will be adjusted to edge when connection completes) const startPoint = { x: deviceAbsPos.x + deviceWidth / 2, y: deviceAbsPos.y + deviceHeight / 2 }; // Create temporary line this.tempLine = new Konva.Line({ points: [startPoint.x, startPoint.y, startPoint.x, startPoint.y], stroke: '#000000', strokeWidth: 1, dash: [10, 5], listening: false }); this.connectionLayer.add(this.tempLine); this.tempLine.moveToTop(); this.pendingConnection = { sourceDeviceId: deviceId, sourceDeviceShape: deviceShape, sourceDeviceData: deviceData, startPoint: startPoint, deviceAbsPos: deviceAbsPos }; // Update temp line on mouse move const stage = this.connectionLayer.getStage(); stage.on('mousemove.connection', () => { if (this.tempLine && this.pendingConnection) { const pos = stage.getPointerPosition(); const scale = stage.scaleX(); const stagePos = stage.position(); const worldX = (pos.x - stagePos.x) / scale; const worldY = (pos.y - stagePos.y) / scale; // Calculate which edge to use based on cursor position const dx = worldX - (this.pendingConnection.deviceAbsPos.x + deviceWidth / 2); const dy = worldY - (this.pendingConnection.deviceAbsPos.y + deviceHeight / 2); let edgePoint; if (Math.abs(dx) > Math.abs(dy)) { // Use left or right edge if (dx > 0) { edgePoint = { x: this.pendingConnection.deviceAbsPos.x + deviceWidth, y: this.pendingConnection.deviceAbsPos.y + deviceHeight / 2 }; } else { edgePoint = { x: this.pendingConnection.deviceAbsPos.x, y: this.pendingConnection.deviceAbsPos.y + deviceHeight / 2 }; } } else { // Use top or bottom edge if (dy > 0) { edgePoint = { x: this.pendingConnection.deviceAbsPos.x + deviceWidth / 2, y: this.pendingConnection.deviceAbsPos.y + deviceHeight }; } else { edgePoint = { x: this.pendingConnection.deviceAbsPos.x + deviceWidth / 2, y: this.pendingConnection.deviceAbsPos.y }; } } this.tempLine.points([edgePoint.x, edgePoint.y, worldX, worldY]); this.connectionLayer.batchDraw(); } }); } async completeConnection(targetDeviceId, targetDeviceShape) { if (!this.pendingConnection) return; const sourceDeviceId = this.pendingConnection.sourceDeviceId; if (sourceDeviceId === targetDeviceId) { this.cancelConnection(); return; } try { const sourceUsedPorts = await this.api.getUsedPorts(sourceDeviceId); const targetUsedPorts = await this.api.getUsedPorts(targetDeviceId); const sourcePort = this.getNextAvailablePort(this.pendingConnection.sourceDeviceData, sourceUsedPorts); const targetData = this.deviceManager.getDeviceData(targetDeviceId); const targetPort = this.getNextAvailablePort(targetData, targetUsedPorts); if (sourcePort === null || targetPort === null) { this.cancelConnection(); return; } const connData = await this.api.createConnection( sourceDeviceId, sourcePort, targetDeviceId, targetPort ); const connections = await this.api.getConnections(); const newConn = connections.find(c => c.id === connData.id); if (newConn) { this.createConnectionLine(newConn); this.connectionLayer.batchDraw(); } } catch (err) { console.error('Failed to create connection:', err); } this.cancelConnection(); } getNextAvailablePort(deviceData, usedPorts) { const portsCount = deviceData.ports_count || 24; for (let port = 1; port <= portsCount; port++) { if (!usedPorts.includes(port)) { return port; } } return null; } cancelConnection() { if (this.tempLine) { this.tempLine.destroy(); this.tempLine = null; } const stage = this.connectionLayer.getStage(); if (stage) { stage.off('mousemove.connection'); } this.pendingConnection = null; this.connectionLayer.batchDraw(); } async deleteConnection(connId, line, handles, suppressEvent = false) { try { await this.api.deleteConnection(connId); // Handle case where line and handles might not be provided (called from table) if (line) { line.destroy(); } if (handles) { handles.forEach(h => h.destroy()); } // If line/handles not provided, find and destroy them const conn = this.connections.get(connId); if (conn) { if (!line && conn.shape) { conn.shape.destroy(); } if (!handles && conn.handles) { conn.handles.forEach(h => h.destroy()); } } this.connections.delete(connId); // Clear selection if this was the selected connection if (this.selectedConnection === connId) { this.selectedConnection = null; } this.connectionLayer.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 connection:', err); } } showConnectionContextMenu(e, connData, line, handles) { const contextMenu = document.getElementById('contextMenu'); const contextMenuList = document.getElementById('contextMenuList'); const sourceDevice = this.deviceManager.getDeviceData(connData.source_device_id); const targetDevice = this.deviceManager.getDeviceData(connData.target_device_id); contextMenuList.innerHTML = `
  • ${sourceDevice.name}:${connData.source_port} ↔ ${targetDevice.name}:${connData.target_port}
  • Delete Connection
  • `; 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 = (evt) => { const action = evt.target.dataset.action; if (action === 'delete') { if (confirm('Delete this connection?')) { this.deleteConnection(connData.id, line, handles); } } contextMenu.classList.add('hidden'); contextMenuList.removeEventListener('click', handleAction); this.contextMenuHandler = null; }; // Store and add the new handler this.contextMenuHandler = handleAction; contextMenuList.addEventListener('click', handleAction); } }