Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 46 additions & 4 deletions cable_map/frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand All @@ -32,9 +35,17 @@ export default function App({ mode, onToggleMode }: { mode: Mode; onToggleMode:

const [flow, setFlow] = useState<ReactFlowInstance | null>(null);
const flowHostRef = useRef<HTMLDivElement | null>(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);
Expand All @@ -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<string | null>(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) ?? [];
Expand All @@ -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) {
Expand All @@ -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';

Expand Down Expand Up @@ -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}
/>
</Box>
);
Expand Down
12 changes: 7 additions & 5 deletions cable_map/frontend/src/SwitchNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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',
Expand All @@ -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);
})}
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${cols}, ${CELL}px)`, gridTemplateRows: `repeat(${rows}, ${CELL}px)`, gridAutoFlow: 'column', gap: CELL_GAP }}>
{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);
})}
</div>
)}
Expand Down
8 changes: 4 additions & 4 deletions cable_map/frontend/src/components/FlowCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -40,7 +40,7 @@ interface FlowCanvasProps {
onNodesChange: OnNodesChange<RFNode>;
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;
Expand Down Expand Up @@ -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}`)}
Expand Down
9 changes: 8 additions & 1 deletion cable_map/frontend/src/components/NodeActionsContext.tsx
Original file line number Diff line number Diff line change
@@ -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 = () => {};

Expand Down
15 changes: 11 additions & 4 deletions cable_map/frontend/src/components/NodeDrawer.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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.
Expand Down Expand Up @@ -163,7 +170,7 @@ function NodeDrawer({ node, expanded, onExpandedChange, resetSizeSignal, onOpenN
{drawerTab === 0 ? (
<Box sx={{ flex: 1, minWidth: 0, minHeight: 0, px: 3, py: 2, display: 'flex', flexDirection: 'column' }}>
<NodeView node={node} rows={node.table}
showHeader={false} onOpenNode={onOpenNode} fillHeight
showHeader={false} onOpenNode={openLinkedNode} fillHeight
panelMaxHeight={Math.round(drawerH * 0.4)} />
</Box>
) : (
Expand Down
1 change: 1 addition & 0 deletions cable_map/frontend/src/graph/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export type SwitchData = {
aspect?: number; // cage-box w/h, to size the layout panel
breakouts?: Record<string, Breakout>; // 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
};
Expand Down
Loading
Loading