feat(rack-planner): implement port-to-port connections (patch cables) with dynamic SVG visualization layer
This commit is contained in:
158
client/src/components/rack/ConnectionLayer.tsx
Normal file
158
client/src/components/rack/ConnectionLayer.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { useRackStore } from '../../store/useRackStore';
|
||||
|
||||
export function ConnectionLayer() {
|
||||
const { racks, cablingFromPortId } = useRackStore();
|
||||
const [coords, setCoords] = useState<Record<string, { x: number; y: number }>>({});
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Update port coordinates
|
||||
const updateCoords = useCallback(() => {
|
||||
const newCoords: Record<string, { x: number; y: number }> = {};
|
||||
const dots = document.querySelectorAll('[data-port-id]');
|
||||
|
||||
// Find the closest scrollable parent that defines our coordinate system
|
||||
// RackPlanner has overflow-auto on the canvas wrapper
|
||||
const canvas = document.querySelector('.rack-planner-canvas');
|
||||
if (!canvas) return;
|
||||
|
||||
const canvasRect = canvas.getBoundingClientRect();
|
||||
|
||||
dots.forEach((dot) => {
|
||||
const portId = (dot as HTMLElement).dataset.portId;
|
||||
if (!portId) return;
|
||||
const rect = dot.getBoundingClientRect();
|
||||
|
||||
// Coordinate is relative to the canvas origin, including its scroll position
|
||||
newCoords[portId] = {
|
||||
x: rect.left + rect.width / 2 - canvasRect.left + canvas.scrollLeft,
|
||||
y: rect.top + rect.height / 2 - canvasRect.top + canvas.scrollTop,
|
||||
};
|
||||
});
|
||||
setCoords(newCoords);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updateCoords();
|
||||
// Re-calculate on window resize or when racks change (modules move)
|
||||
window.addEventListener('resize', updateCoords);
|
||||
|
||||
// Also re-calculate if the user scrolls (though ideally lines are pinned to the canvas)
|
||||
// Actually, if SVG is INSIDE the scrollable container, we don't need scroll adjustment.
|
||||
|
||||
// We'll use a MutationObserver to detect DOM changes (like modules being added/moved)
|
||||
const observer = new MutationObserver(updateCoords);
|
||||
const canvas = document.querySelector('.rack-planner-canvas');
|
||||
if (canvas) {
|
||||
observer.observe(canvas, { childList: true, subtree: true, attributes: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateCoords);
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [racks, updateCoords]);
|
||||
|
||||
// Track mouse for "draft" connection (only while actively cabling)
|
||||
useEffect(() => {
|
||||
if (!cablingFromPortId) return;
|
||||
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
const canvas = document.querySelector('.rack-planner-canvas');
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
setMousePos({
|
||||
x: e.clientX - rect.left + canvas.scrollLeft,
|
||||
y: e.clientY - rect.top + canvas.scrollTop,
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
return () => window.removeEventListener('mousemove', onMouseMove);
|
||||
}, [cablingFromPortId]);
|
||||
|
||||
const connections = useMemo(() => {
|
||||
const conns: { id: string; from: string; to: string; color?: string; fromRackId: string; toRackId: string }[] = [];
|
||||
racks.forEach((rack) => {
|
||||
rack.modules.forEach((mod) => {
|
||||
mod.ports.forEach((port) => {
|
||||
port.sourceConnections?.forEach((c) => {
|
||||
conns.push({
|
||||
id: c.id,
|
||||
from: c.fromPortId,
|
||||
to: c.toPortId,
|
||||
color: c.color,
|
||||
fromRackId: rack.id,
|
||||
toRackId: '' // We don't easily know the destination rack without searching
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
return conns;
|
||||
}, [racks]);
|
||||
|
||||
// Decide if we should show draft line
|
||||
const draftStart = cablingFromPortId ? coords[cablingFromPortId] : null;
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="absolute top-0 left-0 pointer-events-none z-20 overflow-visible"
|
||||
style={{ width: '1px', height: '1px' }} // SVG origin is top-left of canvas
|
||||
>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="5" refY="5" markerWidth="4" markerHeight="4" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 5 L 0 10 z" fill="currentColor" />
|
||||
</marker>
|
||||
</defs>
|
||||
|
||||
{/* Existing connections */}
|
||||
{connections.map((conn) => {
|
||||
const start = coords[conn.from];
|
||||
const end = coords[conn.to];
|
||||
if (!start || !end) return null;
|
||||
|
||||
// Calculate a slight curve. If ports are close, use a tighter curve.
|
||||
const dx = Math.abs(end.x - start.x);
|
||||
const dy = Math.abs(end.y - start.y);
|
||||
const distance = Math.sqrt(dx*dx + dy*dy);
|
||||
const curvature = Math.min(100, distance / 3);
|
||||
|
||||
return (
|
||||
<g key={conn.id} className="connection-group">
|
||||
<path
|
||||
d={`M ${start.x} ${start.y} C ${start.x + curvature} ${start.y}, ${end.x - curvature} ${end.y}, ${end.x} ${end.y}`}
|
||||
stroke={conn.color || '#3b82f6'}
|
||||
strokeWidth="2.5"
|
||||
fill="none"
|
||||
opacity="0.8"
|
||||
className="drop-shadow-sm transition-opacity hover:opacity-100"
|
||||
/>
|
||||
{/* Thicker transparent helper for easier identification if we ever add hover interactions */}
|
||||
<path
|
||||
d={`M ${start.x} ${start.y} C ${start.x + curvature} ${start.y}, ${end.x - curvature} ${end.y}, ${end.x} ${end.y}`}
|
||||
stroke="transparent"
|
||||
strokeWidth="10"
|
||||
fill="none"
|
||||
className="pointer-events-auto cursor-help"
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Draft connection line (dashed) */}
|
||||
{draftStart && (
|
||||
<line
|
||||
x1={draftStart.x}
|
||||
y1={draftStart.y}
|
||||
x2={mousePos.x}
|
||||
y2={mousePos.y}
|
||||
stroke="#3b82f6"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="5 3"
|
||||
opacity="0.6"
|
||||
/>
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -109,6 +109,35 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
|
||||
setPortModalOpen(true);
|
||||
}
|
||||
|
||||
const { cablingFromPortId, setCablingFromPortId, createConnection } = useRackStore();
|
||||
|
||||
async function handlePortClick(e: React.MouseEvent, portId: string) {
|
||||
e.stopPropagation();
|
||||
|
||||
// If shift key is pressed, open config modal as before
|
||||
if (e.shiftKey) {
|
||||
openPort(portId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle cabling mode
|
||||
if (!cablingFromPortId) {
|
||||
setCablingFromPortId(portId);
|
||||
} else if (cablingFromPortId === portId) {
|
||||
setCablingFromPortId(null);
|
||||
} else {
|
||||
// Connect!
|
||||
try {
|
||||
await createConnection(cablingFromPortId, portId);
|
||||
setCablingFromPortId(null);
|
||||
toast.success('Patch cable connected');
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'Connection failed');
|
||||
setCablingFromPortId(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -140,18 +169,28 @@ export function ModuleBlock({ module }: ModuleBlockProps) {
|
||||
<div key={rowIdx} className="flex gap-[3px]">
|
||||
{row.map((port) => {
|
||||
const hasVlan = port.vlans.length > 0;
|
||||
const vlanColor = hasVlan
|
||||
? port.mode === 'ACCESS'
|
||||
? port.vlans[0]?.vlan?.color || '#10b981'
|
||||
: '#8b5cf6'
|
||||
: '#475569';
|
||||
const isCablingSource = cablingFromPortId === port.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={port.id}
|
||||
data-port-id={port.id}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => { e.stopPropagation(); openPort(port.id); }}
|
||||
onClick={(e) => handlePortClick(e, port.id)}
|
||||
aria-label={`Port ${port.portNumber}`}
|
||||
title={`Port ${port.portNumber}${port.label ? ` · ${port.label}` : ''}`}
|
||||
title={`Port ${port.portNumber}${port.label ? ` · ${port.label}` : ''}${
|
||||
hasVlan ? ` (VLAN ${port.vlans.map((v) => v.vlan.vlanId).join(',')})` : ''
|
||||
}\nShift+Click for settings`}
|
||||
style={{ backgroundColor: vlanColor, borderColor: 'rgba(0,0,0,0.2)' }}
|
||||
className={cn(
|
||||
'w-2.5 h-2.5 rounded-sm border transition-colors shrink-0',
|
||||
hasVlan
|
||||
? 'bg-green-400 border-green-500 hover:bg-green-300'
|
||||
: 'bg-slate-600 border-slate-500 hover:bg-slate-400'
|
||||
'w-2.5 h-2.5 rounded-sm border transition-all shrink-0 hover:scale-110 hover:brightness-125',
|
||||
isCablingSource &&
|
||||
'ring-2 ring-blue-400 ring-offset-1 ring-offset-slate-900 animate-pulse'
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { apiClient } from '../../api/client';
|
||||
import { RackToolbar } from './RackToolbar';
|
||||
import { RackColumn } from './RackColumn';
|
||||
import { DevicePalette } from './DevicePalette';
|
||||
import { ConnectionLayer } from './ConnectionLayer';
|
||||
import { AddModuleModal } from '../modals/AddModuleModal';
|
||||
import { RackSkeleton } from '../ui/Skeleton';
|
||||
import type { ModuleType } from '../../types';
|
||||
@@ -237,7 +238,7 @@ export function RackPlanner() {
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<DevicePalette />
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="flex-1 overflow-auto relative rack-planner-canvas">
|
||||
{loading ? (
|
||||
<RackSkeleton />
|
||||
) : racks.length === 0 ? (
|
||||
@@ -270,6 +271,7 @@ export function RackPlanner() {
|
||||
hoverSlot={hoverSlot}
|
||||
/>
|
||||
))}
|
||||
<ConnectionLayer />
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user