diff --git a/cable_map/frontend/src/App.tsx b/cable_map/frontend/src/App.tsx index f564392..073830c 100644 --- a/cable_map/frontend/src/App.tsx +++ b/cable_map/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { startTransition, useCallback, useMemo, useRef, useState } from 'react'; +import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type ReactFlowInstance } from '@xyflow/react'; import Alert from '@mui/material/Alert'; import Box from '@mui/material/Box'; @@ -20,6 +20,9 @@ import AppToolbar from './components/AppToolbar'; import FlowCanvas from './components/FlowCanvas'; import NodeDrawer from './components/NodeDrawer'; import PdfExportProgress from './components/PdfExportProgress'; +import { type OpenNodeOptions } from './components/NodeActionsContext'; +import { NODE_FOCUS_ZOOM } from './lib/viewport'; +import { trace } from './trace'; // Composition root: wires the data/view-model hooks into the toolbar, canvas and drawer. The // hooks form a fixed pipeline — graph → label filter → edge-link mode → flow model → search — so @@ -32,9 +35,17 @@ export default function App({ mode, onToggleMode }: { mode: Mode; onToggleMode: const [flow, setFlow] = useState(null); const flowHostRef = useRef(null); + const viewportActionSeqRef = useRef(0); + const getViewportActionSeq = useCallback(() => viewportActionSeqRef.current, []); + const nextViewportActionSeq = useCallback(() => { + viewportActionSeqRef.current += 1; + return viewportActionSeqRef.current; + }, []); const viewport = useViewportController(flow, largeTopology, flowHostRef, namespace); + const { centerNode } = viewport; const graph = useGraphModel(topo, largeTopology); + const { selectNode } = graph; const filter = useLabelFilter(graph.adjacency, graph.switchIds, namespace, { nodes: graph.nodes, ready, fitNodes: viewport.fitNodes, largeTopology }); const links = useEdgeLinkMode(graph.edges, graph.adjacency, graph.switchIds, filter.shown); @@ -46,13 +57,17 @@ export default function App({ mode, onToggleMode }: { mode: Mode; onToggleMode: }); const search = useSearch({ displayNodes: flowModel.displayNodes, adjacency: graph.adjacency, shown: filter.shown, - ready, largeTopology, flow, viewport, + ready, largeTopology, flow, viewport, getViewportActionSeq, }); const [open, setOpen] = useState(null); const [drawerExpanded, setDrawerExpanded] = useState(false); + const [drawerHeight, setDrawerHeight] = useState(0); const [drawerResetSizeSignal, setDrawerResetSizeSignal] = useState(0); + const [pendingFocus, setPendingFocus] = useState<{ name: string; pin: boolean; seq: number } | null>(null); const nodeByName = useMemo(() => new Map(topo?.nodes.map((n) => [n.name, n]) ?? []), [topo]); + const graphNodeById = useMemo(() => new Map(graph.nodes.map((n) => [n.id, n])), [graph.nodes]); + const renderedNodeIds = useMemo(() => new Set(flowModel.flowNodes.map((n) => n.id)), [flowModel.flowNodes]); const openNode = open ? nodeByName.get(open) : undefined; const { exportNodeIds, exportScope } = useMemo(() => { const allNodeIds = topo?.nodes.map((n) => n.name) ?? []; @@ -71,7 +86,17 @@ export default function App({ mode, onToggleMode }: { mode: Mode; onToggleMode: ? { exportNodeIds: selectedFilteredNodeIds, exportScope: 'selected' as const } : { exportNodeIds: filteredNodeIds, exportScope: 'filtered' as const }; }, [topo, filter.shown, graph.selected]); - const selectDrawerNode = useCallback((name: string | null, expand = false) => { + useEffect(() => { + if (!pendingFocus || !renderedNodeIds.has(pendingFocus.name)) return; + if (viewportActionSeqRef.current !== pendingFocus.seq) return; + setPendingFocus((current) => current?.seq === pendingFocus.seq ? null : current); + if (pendingFocus.pin) trace.pin(`node:${pendingFocus.name}`, true); + selectNode(pendingFocus.name, { protect: true }); + }, [pendingFocus, renderedNodeIds, selectNode]); + + const selectDrawerNode = useCallback((name: string | null, options: OpenNodeOptions = {}) => { + const { expand = false, pan = false, pin = false, select = false } = options; + const actionSeq = nextViewportActionSeq(); startTransition(() => { setOpen((current) => current === name ? current : name); if (expand) { @@ -80,7 +105,23 @@ export default function App({ mode, onToggleMode }: { mode: Mode; onToggleMode: } else if (!name) setDrawerExpanded(false); }); - }, []); + if (!name) setPendingFocus(null); + if (name && pan) { + const target = graphNodeById.get(name); + if (target) { + void centerNode(target, { + duration: 350, + minZoom: NODE_FOCUS_ZOOM, + bottomInset: drawerExpanded ? drawerHeight : 0, + }).then((ok) => { + if (ok && viewportActionSeqRef.current === actionSeq) setPendingFocus({ name, pin, seq: actionSeq }); + }); + return; + } + } + if (name && pin) trace.pin(`node:${name}`, true); + if (select) selectNode(name); + }, [centerNode, drawerExpanded, drawerHeight, graphNodeById, nextViewportActionSeq, selectNode]); const pdf = usePdfExport({ topo, namespace }); const pdfLoading = pdf.pdfState.status === 'loading'; @@ -142,6 +183,7 @@ export default function App({ mode, onToggleMode }: { mode: Mode; onToggleMode: resetSizeSignal={drawerResetSizeSignal} onOpenNode={selectDrawerNode} mode={mode} topo={topo} pdfLoading={pdfLoading} onExportPdf={pdf.openPdf} + onHeightChange={setDrawerHeight} /> ); diff --git a/cable_map/frontend/src/SwitchNode.tsx b/cable_map/frontend/src/SwitchNode.tsx index 0d94363..4f2cd72 100644 --- a/cable_map/frontend/src/SwitchNode.tsx +++ b/cable_map/frontend/src/SwitchNode.tsx @@ -229,6 +229,7 @@ function renderCage(cage: string, infos: PortInfo[] | undefined, bo: Breakout | function CompactSwitchNode({ id, data, selected }: NodeProps) { const d = data as SwitchData; + const isSelected = typeof d.selected === 'boolean' ? d.selected : selected; const icon = RoleIcons[d.role]; const mode = useTrace(modeFor((s) => s.nodes.has(id))); const lldp = useLldp(); @@ -269,7 +270,7 @@ function CompactSwitchNode({ id, data, selected }: NodeProps) { background: 'var(--panel)', borderRadius: 6, padding: '6px 7px', - border: `1.5px solid ${selected ? 'var(--eda-blue-400)' : mode === 'on' ? 'var(--eda-gray-600)' : 'var(--line)'}`, + border: `1.5px solid ${isSelected ? 'var(--eda-blue-400)' : mode === 'on' ? 'var(--eda-gray-600)' : 'var(--line)'}`, opacity: mode === 'dim' ? 0.32 : 1, transition: 'opacity 120ms, border-color 120ms', boxShadow: mode === 'on' ? '0 0 0 1px var(--eda-blue-400)' : undefined, @@ -302,8 +303,9 @@ function CompactSwitchNode({ id, data, selected }: NodeProps) { function DetailedSwitchNode({ id, data, selected }: NodeProps) { const d = data as SwitchData; + const isSelected = typeof d.selected === 'boolean' ? d.selected : selected; const openNode = useOpenNode(); - const openThisNode = useCallback((expand?: boolean) => openNode(id, expand), [id, openNode]); + const openThisNode = useCallback((expand?: boolean) => openNode(id, { expand }), [id, openNode]); const rows = Math.max(1, d.rows); const cols = Math.ceil(d.portCount / rows); const icon = RoleIcons[d.role]; @@ -358,7 +360,7 @@ function DetailedSwitchNode({ id, data, selected }: NodeProps) { onMouseLeave={() => trace.hover(null)} style={{ background: 'var(--panel)', borderRadius: 8, padding: 8, - border: `1.5px solid ${selected ? 'var(--eda-blue-400)' : mode === 'on' ? 'var(--eda-gray-600)' : 'var(--line)'}`, + border: `1.5px solid ${isSelected ? 'var(--eda-blue-400)' : mode === 'on' ? 'var(--eda-gray-600)' : 'var(--line)'}`, opacity: mode === 'dim' ? 0.32 : 1, transition: 'opacity 140ms, border-color 140ms', contentVisibility: d.showEdgeStubs ? 'visible' : 'auto', containIntrinsicSize: panel ? `${panel.W + 16}px ${panel.H + 44}px` : '220px 96px', @@ -381,14 +383,14 @@ function DetailedSwitchNode({ id, data, selected }: NodeProps) { const cw = Math.max(3, p.w * panel.W), ch = Math.max(3, p.h * panel.H); const box = { left: p.x * panel.W - cw / 2, top: p.y * panel.H - ch / 2, width: cw, height: ch }; const hot = hotPorts.has(cage); - return renderCage(cage, byPort.get(cage), d.breakouts?.[cage], hot, selected, dimInactivePorts && !hot, lldp, !!d.showEdgeStubs, box, openThisNode); + return renderCage(cage, byPort.get(cage), d.breakouts?.[cage], hot, isSelected, dimInactivePorts && !hot, lldp, !!d.showEdgeStubs, box, openThisNode); })} ) : (
{cages.map((cage) => { const hot = hotPorts.has(cage); - return renderCage(cage, byPort.get(cage), d.breakouts?.[cage], hot, selected, dimInactivePorts && !hot, lldp, !!d.showEdgeStubs, undefined, openThisNode); + return renderCage(cage, byPort.get(cage), d.breakouts?.[cage], hot, isSelected, dimInactivePorts && !hot, lldp, !!d.showEdgeStubs, undefined, openThisNode); })}
)} diff --git a/cable_map/frontend/src/components/FlowCanvas.tsx b/cable_map/frontend/src/components/FlowCanvas.tsx index 8f3b79a..c8d2197 100644 --- a/cable_map/frontend/src/components/FlowCanvas.tsx +++ b/cable_map/frontend/src/components/FlowCanvas.tsx @@ -19,7 +19,7 @@ import { useLldp } from '../lldp'; import { edgeLinkModeIcon, edgeLinkModeTitle, type EdgeLinkMode } from '../lib/edgeLinks'; import { LARGE_DEFAULT_VIEWPORT, LARGE_MAX_ZOOM, LARGE_MIN_ZOOM } from '../lib/viewport'; import { type Mode } from '../theme'; -import { NodeActionsProvider } from './NodeActionsContext'; +import { NodeActionsProvider, type OpenNodeHandler } from './NodeActionsContext'; const nodeTypes: NodeTypes = { switch: SwitchNode, endpoint: EndpointNode }; const edgeTypes: EdgeTypes = { cable: CableEdge }; @@ -40,7 +40,7 @@ interface FlowCanvasProps { onNodesChange: OnNodesChange; onInit: (flow: ReactFlowInstance) => void; onSelectionChange: (sel: { nodes: RFNode[] }) => void; - onOpenNode: (id: string | null, expand?: boolean) => void; + onOpenNode: OpenNodeHandler; onClearSelection: () => void; onViewportChange: (v: Viewport) => void; onWebglUnavailable: () => void; @@ -77,13 +77,13 @@ function FlowCanvas({ // single click only toggles the node's trace highlight; opening the drawer is reserved // for double-click so lightweight map inspection stays cheap. trace.pin(k); - onOpenNode(n.id); + onOpenNode(n.id, { select: true }); }} onNodeDoubleClick={(_, n) => { if (!switchIds.has(n.id)) return; const k = `node:${n.id}`; if (trace.getPinned() !== k) trace.pin(k); - onOpenNode(n.id, true); + onOpenNode(n.id, { expand: true, select: true }); }} onEdgeClick={(_, e) => trace.pin(`cable:${e.id}`)} onEdgeMouseEnter={(_, e) => trace.hover(`cable:${e.id}`)} diff --git a/cable_map/frontend/src/components/NodeActionsContext.tsx b/cable_map/frontend/src/components/NodeActionsContext.tsx index d06b377..1562d01 100644 --- a/cable_map/frontend/src/components/NodeActionsContext.tsx +++ b/cable_map/frontend/src/components/NodeActionsContext.tsx @@ -1,6 +1,13 @@ import { createContext, useContext } from 'react'; -export type OpenNodeHandler = (id: string | null, expand?: boolean) => void; +export type OpenNodeOptions = { + expand?: boolean; + pan?: boolean; + pin?: boolean; + select?: boolean; +}; + +export type OpenNodeHandler = (id: string | null, options?: OpenNodeOptions) => void; const noopOpenNode: OpenNodeHandler = () => {}; diff --git a/cable_map/frontend/src/components/NodeDrawer.tsx b/cable_map/frontend/src/components/NodeDrawer.tsx index 8c2d163..e1105fa 100644 --- a/cable_map/frontend/src/components/NodeDrawer.tsx +++ b/cable_map/frontend/src/components/NodeDrawer.tsx @@ -1,4 +1,4 @@ -import { memo, useEffect, useState } from 'react'; +import { memo, useCallback, useEffect, useState } from 'react'; import Box from '@mui/material/Box'; import Typography from '@mui/material/Typography'; import Chip from '@mui/material/Chip'; @@ -21,17 +21,19 @@ import NodeView from '../NodeView'; import NodeDetails from '../NodeDetails'; import { trace } from '../trace'; import { type Mode } from '../theme'; +import { type OpenNodeHandler } from './NodeActionsContext'; interface NodeDrawerProps { node: Node | undefined; expanded: boolean; onExpandedChange: (expanded: boolean) => void; resetSizeSignal: number; - onOpenNode: (name: string | null, expand?: boolean) => void; + onOpenNode: OpenNodeHandler; mode: Mode; topo: Topology | null; pdfLoading: boolean; onExportPdf: (ids: string[]) => void; + onHeightChange: (height: number) => void; } // A persistent bottom sheet: always present as a slim, draggable handle that pulls up to @@ -40,11 +42,12 @@ interface NodeDrawerProps { // re-render the open sheet + its Monaco panel; it still re-renders when `node` changes. const defaultDrawerHeight = () => Math.round(window.innerHeight * 0.6); -function NodeDrawer({ node, expanded, onExpandedChange, resetSizeSignal, onOpenNode, mode, topo, pdfLoading, onExportPdf }: NodeDrawerProps) { +function NodeDrawer({ node, expanded, onExpandedChange, resetSizeSignal, onOpenNode, mode, topo, pdfLoading, onExportPdf, onHeightChange }: NodeDrawerProps) { const [drawerTab, setDrawerTab] = useState(0); // 0 = Ports, 1 = Details const [drawerH, setDrawerH] = useState(defaultDrawerHeight); const [dragging, setDragging] = useState(false); // suppresses the slide transition mid-drag const nodeName = node?.name; + const openLinkedNode = useCallback((name: string) => onOpenNode(name, { pan: true, pin: true, select: true }), [onOpenNode]); useEffect(() => { setDrawerTab(0); @@ -54,6 +57,10 @@ function NodeDrawer({ node, expanded, onExpandedChange, resetSizeSignal, onOpenN setDrawerH(defaultDrawerHeight()); }, [resetSizeSignal]); + useEffect(() => { + onHeightChange(drawerH); + }, [drawerH, onHeightChange]); + // drag any handle: live-resize while dragging (collapsing if pulled near the bottom), // or — when released without moving — toggle expanded/collapsed (so a plain click opens // the sheet). Listeners live on window so the drag survives the collapsed→expanded swap. @@ -163,7 +170,7 @@ function NodeDrawer({ node, expanded, onExpandedChange, resetSizeSignal, onOpenN {drawerTab === 0 ? ( ) : ( diff --git a/cable_map/frontend/src/graph/types.ts b/cable_map/frontend/src/graph/types.ts index 36c1d55..26381dc 100644 --- a/cable_map/frontend/src/graph/types.ts +++ b/cable_map/frontend/src/graph/types.ts @@ -27,6 +27,7 @@ export type SwitchData = { aspect?: number; // cage-box w/h, to size the layout panel breakouts?: Record; // cage id -> breakout split (channels + speed) compact?: boolean; // large-topology mode: draw a cheap aggregate switch node + selected?: boolean; // programmatic focus/selection mirrored outside React Flow's transient selection store showEdgeStubs?: boolean; // draw short local downlink stubs instead of remote endpoint chips activeEdgeStubPorts?: string[]; // "node:port" ids whose stubs are expanded to full cables }; diff --git a/cable_map/frontend/src/hooks/useGraphModel.ts b/cable_map/frontend/src/hooks/useGraphModel.ts index db37a29..50c2188 100644 --- a/cable_map/frontend/src/hooks/useGraphModel.ts +++ b/cable_map/frontend/src/hooks/useGraphModel.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useNodesState, type Node as RFNode } from '@xyflow/react'; import type { Topology } from '../domain/topology/contract'; import { emptyAdjacency, type Adjacency } from '../graph/adjacency'; @@ -11,12 +11,20 @@ function sameSelection(a: string[], b: string[]): boolean { return true; } +type SelectNodeOptions = { + protect?: boolean; +}; + +const SELECTION_PROTECTION_MS = 2500; + // Builds the React Flow graph from a topology and owns the node state. The `setAdjacency` call // inside the memo pushes adjacency into the trace store — every highlight depends on it. A live // stream tick rebuilds node data but keeps each node's current position so a dragged node never // snaps back; new nodes adopt their computed layout position. export function useGraphModel(topo: Topology | null, largeTopology: boolean) { const [selected, setSelected] = useState([]); + const protectedSelectionRef = useRef(null); + const protectionTimerRef = useRef(null); const { nodes: builtNodes, edges, switchIds, adjacency } = useMemo(() => { const empty: Adjacency = emptyAdjacency(); @@ -29,16 +37,68 @@ export function useGraphModel(topo: Topology | null, largeTopology: boolean) { const [nodes, setNodes, onNodesChange] = useNodesState([]); useEffect(() => { setNodes((prev) => { - const pos = new Map(prev.map((n) => [n.id, n.position])); - return builtNodes.map((n) => { const p = pos.get(n.id); return p ? { ...n, position: p } : n; }); + const prevById = new Map(prev.map((n) => [n.id, n])); + return builtNodes.map((n) => { + const previous = prevById.get(n.id); + return previous ? { ...n, position: previous.position } : n; + }); }); }, [builtNodes, setNodes]); + const releaseProtectedSelection = useCallback(() => { + protectedSelectionRef.current = null; + if (protectionTimerRef.current != null) { + window.clearTimeout(protectionTimerRef.current); + protectionTimerRef.current = null; + } + }, []); + + const protectSelection = useCallback((id: string) => { + if (protectionTimerRef.current != null) window.clearTimeout(protectionTimerRef.current); + protectedSelectionRef.current = id; + protectionTimerRef.current = window.setTimeout(() => { + if (protectedSelectionRef.current === id) protectedSelectionRef.current = null; + protectionTimerRef.current = null; + }, SELECTION_PROTECTION_MS); + }, []); + + useEffect(() => () => { + if (protectionTimerRef.current != null) window.clearTimeout(protectionTimerRef.current); + }, []); + const onSelectionChange = useCallback(({ nodes: sel }: { nodes: RFNode[] }) => { const next = sel.filter((n) => n.type === 'switch').map((n) => n.id); - setSelected((prev) => sameSelection(prev, next) ? prev : next); + setSelected((prev) => { + const protectedId = protectedSelectionRef.current; + if (protectedId && prev.includes(protectedId)) { + if (!next.includes(protectedId)) return prev; + return sameSelection(prev, [protectedId]) ? prev : [protectedId]; + } + return sameSelection(prev, next) ? prev : next; + }); }, []); - const clearSelection = useCallback(() => setSelected((prev) => prev.length ? [] : prev), []); + const selectNode = useCallback((id: string | null, options?: SelectNodeOptions) => { + const next = id && switchIds.has(id) ? id : null; + if (next && options?.protect) protectSelection(next); + else releaseProtectedSelection(); + setSelected(next ? [next] : []); + }, [protectSelection, releaseProtectedSelection, switchIds]); + const clearSelection = useCallback(() => { + releaseProtectedSelection(); + setSelected((prev) => prev.length ? [] : prev); + }, [releaseProtectedSelection]); + + const selectedSet = useMemo(() => new Set(selected), [selected]); + const selectedNodes = useMemo( + () => nodes.map((n) => { + if (n.type !== 'switch') return n.selected ? { ...n, selected: false } : n; + const shouldSelect = n.type === 'switch' && selectedSet.has(n.id); + const dataSelectionMatches = (n.data as { selected?: unknown }).selected === shouldSelect; + const data = dataSelectionMatches ? n.data : { ...n.data, selected: shouldSelect }; + return n.selected === shouldSelect && dataSelectionMatches ? n : { ...n, selected: shouldSelect, data }; + }), + [nodes, selectedSet], + ); // Ctrl/Cmd+A selects all switches (ignored while typing in an input) useEffect(() => { @@ -47,11 +107,12 @@ export function useGraphModel(topo: Topology | null, largeTopology: boolean) { const el = document.activeElement; if (el && /^(input|textarea)$/i.test(el.tagName)) return; e.preventDefault(); - setNodes((nds) => nds.map((n) => ({ ...n, selected: n.type === 'switch' }))); + releaseProtectedSelection(); + setSelected([...switchIds]); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); - }, [setNodes]); + }, [releaseProtectedSelection, switchIds]); - return { nodes, onNodesChange, edges, switchIds, adjacency, selected, onSelectionChange, clearSelection }; + return { nodes: selectedNodes, onNodesChange, edges, switchIds, adjacency, selected, onSelectionChange, selectNode, clearSelection }; } diff --git a/cable_map/frontend/src/hooks/useSearch.ts b/cable_map/frontend/src/hooks/useSearch.ts index 67eb9af..981a8b6 100644 --- a/cable_map/frontend/src/hooks/useSearch.ts +++ b/cable_map/frontend/src/hooks/useSearch.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { type Node as RFNode, type ReactFlowInstance, type Viewport } from '@xyflow/react'; import type { Adjacency } from '../graph/adjacency'; import { trace } from '../trace'; @@ -18,16 +18,20 @@ type SearchArgs = { largeTopology: boolean; flow: ReactFlowInstance | null; viewport: ViewportController; + getViewportActionSeq: () => number; }; // Owns the search box. Derives the highlight set + the fit target from the query, pushes the // highlight into the trace store, and centres/fits the viewport on the match (a port match zooms // to SEARCH_PORT_ZOOM). Runs downstream of the flow model since it reads `displayNodes`. -export function useSearch({ displayNodes, adjacency, shown, ready, largeTopology, flow, viewport }: SearchArgs) { +export function useSearch({ displayNodes, adjacency, shown, ready, largeTopology, flow, viewport, getViewportActionSeq }: SearchArgs) { const { fitNodes, setLargeViewport } = viewport; const [query, setQuery] = useState(''); const searchMatcher = useMemo(() => buildSearchMatcher(query), [query]); const displayNodeIds = useMemo(() => new Set(displayNodes.map((n) => n.id)), [displayNodes]); + const displayNodeById = useMemo(() => new Map(displayNodes.map((node) => [node.id, node])), [displayNodes]); + const displayNodeByIdRef = useRef(displayNodeById); + displayNodeByIdRef.current = displayNodeById; const searchTargetIds = useMemo(() => { if (!searchMatcher || searchMatcher.error) return []; @@ -49,11 +53,13 @@ export function useSearch({ displayNodes, adjacency, shown, ready, largeTopology } return [...targets]; }, [searchMatcher, adjacency, shown, displayNodeIds]); - const searchTargetNodes = useMemo( - () => displayNodes.filter((node) => searchTargetIds.includes(node.id)), - [displayNodes, searchTargetIds], - ); + const searchTargetKey = useMemo(() => searchTargetIds.join('\n'), [searchTargetIds]); const searchPortTarget = useMemo(() => portSearchTarget(searchMatcher, displayNodes), [searchMatcher, displayNodes]); + const searchPortTargetRef = useRef(searchPortTarget); + searchPortTargetRef.current = searchPortTarget; + const searchPortTargetKey = searchPortTarget + ? `${searchPortTarget.nodeId}:${searchPortTarget.port}:${searchPortTarget.x}:${searchPortTarget.y}` + : ''; const highlightedSearch = useMemo( () => searchHighlight(searchMatcher, displayNodes, adjacency, shown, searchPortTarget), [searchMatcher, displayNodes, adjacency, shown, searchPortTarget], @@ -67,9 +73,12 @@ export function useSearch({ displayNodes, adjacency, shown, ready, largeTopology }, [ready, searchMatcher, highlightedSearch]); useEffect(() => { if (!flow || !ready || !searchMatcher || searchMatcher.error) return; + const viewportActionSeq = getViewportActionSeq(); const handle = window.setTimeout(() => { - if (searchPortTarget) { - void flow.setCenter(searchPortTarget.x, searchPortTarget.y, { + if (getViewportActionSeq() !== viewportActionSeq) return; + const portTarget = searchPortTargetRef.current; + if (portTarget) { + void flow.setCenter(portTarget.x, portTarget.y, { zoom: SEARCH_PORT_ZOOM, duration: 350, }).then((ok) => { @@ -77,6 +86,9 @@ export function useSearch({ displayNodes, adjacency, shown, ready, largeTopology }); return; } + const searchTargetNodes = searchTargetKey + ? searchTargetKey.split('\n').map((id) => displayNodeByIdRef.current.get(id)).filter((node): node is RFNode => !!node) + : []; if (searchTargetNodes.length === 0) return; fitNodes(searchTargetNodes, { padding: searchTargetNodes.length === 1 ? 0.45 : 0.25, @@ -85,7 +97,7 @@ export function useSearch({ displayNodes, adjacency, shown, ready, largeTopology }); }, 160); return () => window.clearTimeout(handle); - }, [flow, ready, searchMatcher, searchPortTarget, searchTargetNodes, fitNodes, setLargeViewport, largeTopology]); + }, [flow, ready, searchMatcher, searchPortTargetKey, searchTargetKey, fitNodes, setLargeViewport, largeTopology, getViewportActionSeq]); return { query, setQuery, searchError: searchMatcher?.error }; } diff --git a/cable_map/frontend/src/hooks/useViewportController.ts b/cable_map/frontend/src/hooks/useViewportController.ts index c889cd3..11c9d40 100644 --- a/cable_map/frontend/src/hooks/useViewportController.ts +++ b/cable_map/frontend/src/hooks/useViewportController.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useState, type RefObject } from 'react'; import { getViewportForBounds, type Node as RFNode, type ReactFlowInstance, type Viewport } from '@xyflow/react'; import { - LARGE_DEFAULT_VIEWPORT, LARGE_MAX_ZOOM, LARGE_MIN_ZOOM, nodesBounds, type FitNodesOptions, + LARGE_DEFAULT_VIEWPORT, LARGE_MAX_ZOOM, LARGE_MIN_ZOOM, nodeCenter, nodesBounds, type CenterNodeOptions, type FitNodesOptions, } from '../lib/viewport'; // Single owner of the large-topology viewport. `fitNodes` animates to a node set and writes the @@ -43,5 +43,32 @@ export function useViewportController( }); }, [flow, largeTopology, flowHostRef]); - return { fitNodes, largeViewport, setLargeViewport }; + const centerNode = useCallback((node: RFNode, options: CenterNodeOptions): Promise => { + if (!flow) return Promise.resolve(false); + const center = nodeCenter(node); + const currentZoom = flow.getViewport().zoom; + const zoom = options.zoom ?? Math.max(currentZoom, options.minZoom ?? currentZoom); + if (options.bottomInset) { + const rect = flowHostRef.current?.getBoundingClientRect(); + if (rect?.width && rect.height) { + const bottomInset = Math.max(0, Math.min(options.bottomInset, rect.height - 1)); + const visibleHeight = rect.height - bottomInset; + const viewport: Viewport = { + x: rect.width / 2 - center.x * zoom, + y: visibleHeight / 2 - center.y * zoom, + zoom, + }; + return flow.setViewport(viewport, { duration: options.duration }).then((ok) => { + if (ok && largeTopology) setLargeViewport(flow.getViewport()); + return ok; + }); + } + } + return flow.setCenter(center.x, center.y, { zoom, duration: options.duration }).then((ok) => { + if (ok && largeTopology) setLargeViewport(flow.getViewport()); + return ok; + }); + }, [flow, flowHostRef, largeTopology]); + + return { fitNodes, centerNode, largeViewport, setLargeViewport }; } diff --git a/cable_map/frontend/src/lib/viewport.ts b/cable_map/frontend/src/lib/viewport.ts index 8bdb0e6..b8a8add 100644 --- a/cable_map/frontend/src/lib/viewport.ts +++ b/cable_map/frontend/src/lib/viewport.ts @@ -5,9 +5,11 @@ export const LARGE_MIN_ZOOM = 0.02; export const LARGE_MAX_ZOOM = 2; export const LARGE_VIEWPORT_PAD = 900; export const LARGE_WEBGL_NODE_ZOOM = 0.2; +export const NODE_FOCUS_ZOOM = 1.4; export type FlowBounds = { x: number; y: number; width: number; height: number }; export type FitNodesOptions = { padding: number; duration: number; maxZoom?: number }; +export type CenterNodeOptions = { duration: number; zoom?: number; minZoom?: number; bottomInset?: number }; export function nodeIntersectsViewport(node: RFNode, viewport: Viewport, width: number, height: number): boolean { const nodeWidth = node.width ?? node.measured?.width ?? 160; @@ -28,6 +30,11 @@ function nodeBounds(node: RFNode): FlowBounds { return { x: node.position.x, y: node.position.y, width, height }; } +export function nodeCenter(node: RFNode): { x: number; y: number } { + const bounds = nodeBounds(node); + return { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }; +} + export function nodesBounds(nodes: RFNode[]): FlowBounds | null { if (nodes.length === 0) return null; diff --git a/cable_map/frontend/src/stores/trace.ts b/cable_map/frontend/src/stores/trace.ts index f195974..c800f58 100644 --- a/cable_map/frontend/src/stores/trace.ts +++ b/cable_map/frontend/src/stores/trace.ts @@ -107,7 +107,7 @@ export const trace = { if (!pinnedKey) refresh(); } }, - pin(key: string) { pinnedKey = pinnedKey === key ? null : key; refresh(); }, + pin(key: string, force = false) { pinnedKey = !force && pinnedKey === key ? null : key; refresh(); }, // combined search box + label-chip filter: a node must match the free-text term AND carry every label filter(term: string, labels: string[]) { fTerm = term; fLabels = labels;