180 lines
6.2 KiB
TypeScript
180 lines
6.2 KiB
TypeScript
import { useEffect, useState, useMemo, useCallback } from 'react';
|
|
import { useRackStore } from '../../store/useRackStore';
|
|
import { cn } from '../../lib/utils';
|
|
|
|
export function ConnectionLayer() {
|
|
const { racks, cablingFromPortId, setActiveConfigConnectionId } = 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.
|
|
|
|
// Use a MutationObserver to detect DOM changes (like modules being added/moved)
|
|
const observer = new MutationObserver(() => {
|
|
// Small debounce or check if it was our OWN SVG that changed
|
|
updateCoords();
|
|
});
|
|
|
|
const canvas = document.querySelector('.rack-planner-canvas');
|
|
if (canvas) {
|
|
// DO NOT observe the entire subtree with attributes if it includes the ConnectionLayer
|
|
// Instead, just watch for module layout changes
|
|
observer.observe(canvas, { childList: true, subtree: 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; edgeType?: 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,
|
|
edgeType: c.edgeType,
|
|
});
|
|
});
|
|
});
|
|
});
|
|
});
|
|
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;
|
|
|
|
let d = '';
|
|
if (conn.edgeType === 'straight') {
|
|
d = `M ${start.x} ${start.y} L ${end.x} ${end.y}`;
|
|
} else if (conn.edgeType === 'step') {
|
|
const midX = start.x + (end.x - start.x) / 2;
|
|
d = `M ${start.x} ${start.y} L ${midX} ${start.y} L ${midX} ${end.y} L ${end.x} ${end.y}`;
|
|
} else {
|
|
// default bezier
|
|
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);
|
|
d = `M ${start.x} ${start.y} C ${start.x + curvature} ${start.y}, ${end.x - curvature} ${end.y}, ${end.x} ${end.y}`;
|
|
}
|
|
|
|
return (
|
|
<g key={conn.id} className="connection-group">
|
|
<path
|
|
d={d}
|
|
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={d}
|
|
stroke="transparent"
|
|
strokeWidth="10"
|
|
fill="none"
|
|
className="pointer-events-auto cursor-pointer"
|
|
onClick={(e) => {
|
|
if (e.shiftKey) {
|
|
e.stopPropagation();
|
|
setActiveConfigConnectionId(conn.id);
|
|
}
|
|
}}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|