1007 lines
33 KiB
JavaScript
1007 lines
33 KiB
JavaScript
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 = `
|
|
<li class="menu-header">Connection</li>
|
|
<li style="cursor: default; padding: 8px 20px; font-size: 12px; color: #666;">
|
|
${sourceDevice.name}:${connData.source_port} ↔ ${targetDevice.name}:${connData.target_port}
|
|
</li>
|
|
<li class="divider"></li>
|
|
<li data-action="delete">Delete Connection</li>
|
|
`;
|
|
|
|
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);
|
|
}
|
|
}
|