import React, { forwardRef, useCallback, useMemo } from 'react'; import { RotateCcw, LayoutGrid, ZoomIn, ZoomOut } from '../../icons'; import NetBox from './NetBox'; import NodeCard from './NodeCard'; import type { Net, MazeNode, Edge } from './types'; import type { Selection } from './Inspector'; import type { ResizeHandle } from './useMazeInteraction'; interface Props { nets: Net[]; nodes: MazeNode[]; edges: Edge[]; deployed: boolean; selection: Selection; setSelection: (s: Selection) => void; pan: { x: number; y: number }; zoom: number; dropTargetId: string | null; dragging: boolean; edgeDraw: { fromX: number; fromY: number; toX: number; toY: number; hoverTarget: string | null } | null; onCanvasMouseDown: (e: React.MouseEvent) => void; onNodeMouseDown: (id: string) => (e: React.MouseEvent) => void; onNetMouseDown: (id: string) => (e: React.MouseEvent) => void; onNetResizeMouseDown: (id: string, handle: ResizeHandle) => (e: React.MouseEvent) => void; onPortMouseDown: (id: string) => (e: React.MouseEvent) => void; onNodeContextMenu?: (id: string) => (e: React.MouseEvent) => void; onNetContextMenu?: (id: string) => (e: React.MouseEvent) => void; onEdgeContextMenu?: (id: string) => (e: React.MouseEvent) => void; onCanvasContextMenu?: (e: React.MouseEvent) => void; onResetView?: () => void; onAutoLayout?: () => void; onZoomIn?: () => void; onZoomOut?: () => void; sseConnected?: boolean; lastEventAt?: Date | null; onSelectService?: (nodeId: string, slug: string) => void; panLayerRef?: React.RefObject; gridPatternRef?: React.RefObject; } const fmtTime = (d: Date) => `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`; const NODE_W = 140; const NODE_HEAD_H = 22; const Canvas = forwardRef(function Canvas( { nets, nodes, edges, deployed, selection, setSelection, pan, zoom, dropTargetId, dragging, edgeDraw, onCanvasMouseDown, onNodeMouseDown, onNetMouseDown, onNetResizeMouseDown, onPortMouseDown, onNodeContextMenu, onNetContextMenu, onEdgeContextMenu, onCanvasContextMenu, onResetView, onAutoLayout, onZoomIn, onZoomOut, sseConnected, lastEventAt, onSelectService, panLayerRef, gridPatternRef }, ref, ) { const netById = useMemo(() => new Map(nets.map((n) => [n.id, n])), [nets]); // Pre-indexed node lookup so edge rendering is O(E) instead of // O(E·N) from the prior `nodes.find(...)` inside the edge loop. const nodeById = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]); const absPos = (node: MazeNode) => { const net = netById.get(node.netId); return { x: (net?.x ?? 0) + node.x, y: (net?.y ?? 0) + node.y }; }; // Stable per-kind selection callbacks so React.memo on children // (NetBox/NodeCard) can actually short-circuit re-renders instead // of seeing a fresh closure on every Canvas render. const selectNet = useCallback((id: string) => setSelection({ type: 'net', id }), [setSelection]); const selectNode = useCallback((id: string) => setSelection({ type: 'node', id }), [setSelection]); // Flowing-dash edge animation is the single most expensive thing // on the canvas — each animated invalidates its bounding // box every frame, and inter-LAN paths are long so the invalidated // rects overlap most of the viewport. Past ~60 edges the compositor // spends every frame repainting. Drop the animation class above // the threshold; edges stay fully visible, just static. const ANIMATE_EDGE_LIMIT = 60; const animateEdges = edges.length <= ANIMATE_EDGE_LIMIT; const activeNetIds = useMemo(() => { const nodeNet = new Map(nodes.map((n) => [n.id, n.netId])); const ids = new Set(); for (const e of edges) { const a = nodeNet.get(e.from); const b = nodeNet.get(e.to); if (a) ids.add(a); if (b) ids.add(b); } return ids; }, [nodes, edges]); const selNetId = selection?.type === 'net' ? selection.id : null; const selNodeId = selection?.type === 'node' ? selection.id : selection?.type === 'service' ? selection.nodeId : null; const selEdgeId = selection?.type === 'edge' ? selection.id : null; const selServiceNodeId = selection?.type === 'service' ? selection.nodeId : null; const selServiceSlug = selection?.type === 'service' ? selection.id : null; return (
{ if (e.target === e.currentTarget) setSelection(null); onCanvasMouseDown(e); }} onContextMenu={(e) => { if (e.target === e.currentTarget && onCanvasContextMenu) onCanvasContextMenu(e); }} style={{ cursor: dragging ? 'grabbing' : 'grab' }} >
{edges.map((e) => { const from = nodeById.get(e.from); const to = nodeById.get(e.to); if (!from || !to) return null; const a = absPos(from); const b = absPos(to); const x1 = a.x + NODE_W, y1 = a.y + NODE_HEAD_H; const x2 = b.x, y2 = b.y + NODE_HEAD_H; const cx = (x1 + x2) / 2; const d = `M${x1},${y1} C${cx},${y1} ${cx},${y2} ${x2},${y2}`; const klass = e.traffic === 'hot' ? 'hot' : e.traffic === 'active' ? 'active' : ''; const marker = e.traffic === 'hot' ? 'arrow-alert' : e.traffic === 'active' ? 'arrow-violet' : 'arrow-matrix'; const isSel = e.id === selEdgeId; return ( { ev.stopPropagation(); setSelection({ type: 'edge', id: e.id }); }} onContextMenu={onEdgeContextMenu?.(e.id)}> {e.label && ( {e.label} )} ); })} {edgeDraw && (() => { const cx = (edgeDraw.fromX + edgeDraw.toX) / 2; const d = `M${edgeDraw.fromX},${edgeDraw.fromY} C${cx},${edgeDraw.fromY} ${cx},${edgeDraw.toY} ${edgeDraw.toX},${edgeDraw.toY}`; return ; })()}
{nets.map((net) => { const inactive = net.kind !== 'internet' && !activeNetIds.has(net.id); return ( ); })} {nodes.map((n) => { const p = absPos(n); return ( ); })}
{(onResetView || onAutoLayout || onZoomIn || onZoomOut) && (
{onResetView && ( )} {onAutoLayout && ( )} {onZoomOut && ( )} {onZoomIn && ( )}
)}
GRAPH {sseConnected ? 'LIVE' : 'IDLE'} PAN: {Math.round(pan.x)},{Math.round(pan.y)} ZOOM: {Math.round(zoom * 100)}% AS-OF {lastEventAt ? fmtTime(lastEventAt) : '--:--:--'} {!animateEdges && ( MOTION: OFF )}
ACTIVE ATTACK
OBSERVED FLOW
CONFIGURED
INACTIVE NET
); }); export default Canvas;