From 2426863c674ab4b95821e9da0c30550268f703ec Mon Sep 17 00:00:00 2001 From: Ib Green <7025232+ibgreen@users.noreply.github.com> Date: Tue, 4 Nov 2025 12:54:31 -0500 Subject: [PATCH 1/2] Add synced mini-map and collapsed chain breadcrumbs --- examples/graph-layers/graph-viewer/app.tsx | 155 ++++++++- .../graph-layers/graph-viewer/mini-map.tsx | 313 ++++++++++++++++++ 2 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 examples/graph-layers/graph-viewer/mini-map.tsx diff --git a/examples/graph-layers/graph-viewer/app.tsx b/examples/graph-layers/graph-viewer/app.tsx index 60bfebab..0cf453f8 100644 --- a/examples/graph-layers/graph-viewer/app.tsx +++ b/examples/graph-layers/graph-viewer/app.tsx @@ -28,6 +28,7 @@ import {extent} from 'd3-array'; import {ControlPanel, ExampleDefinition, LayoutType} from './control-panel'; import {DEFAULT_EXAMPLE, EXAMPLES} from './examples'; +import {MiniMap} from './mini-map'; const INITIAL_VIEW_STATE = { /** the target origin of the view */ @@ -39,6 +40,9 @@ const INITIAL_VIEW_STATE = { // the default cursor in the view const DEFAULT_CURSOR = 'default'; const DEFAULT_LAYOUT = DEFAULT_EXAMPLE?.layouts[0] ?? 'd3-force-layout'; +const MINI_MAP_VIEWPORT_WIDTH = 200; +const MINI_MAP_VIEWPORT_HEIGHT = 160; +const MINI_MAP_PADDING = 12; type LayoutFactory = (options?: Record) => GraphLayout; @@ -122,6 +126,7 @@ export function App(props) { const engine = useMemo(() => (graph && layout ? new GraphEngine({graph, layout}) : null), [graph, layout]); const isFirstMount = useRef(true); const dagLayout = layout instanceof D3DagLayout ? (layout as D3DagLayout) : null; + const [engineRevision, bumpEngineRevision] = useReducer((count: number) => count + 1, 0); useLayoutEffect(() => { if (!engine) { @@ -185,6 +190,23 @@ export function App(props) { } }, [isDagLayout, selectedExample]); + useEffect(() => { + if (!engine) { + return () => undefined; + } + + const handleEngineUpdate = () => bumpEngineRevision(); + engine.addEventListener('onLayoutStart', handleEngineUpdate); + engine.addEventListener('onLayoutChange', handleEngineUpdate); + engine.addEventListener('onLayoutDone', handleEngineUpdate); + + return () => { + engine.removeEventListener('onLayoutStart', handleEngineUpdate); + engine.removeEventListener('onLayoutChange', handleEngineUpdate); + engine.removeEventListener('onLayoutDone', handleEngineUpdate); + }; + }, [engine, bumpEngineRevision]); + useEffect(() => { if (!dagLayout) { return; @@ -243,6 +265,57 @@ export function App(props) { }; }, [engine, dagLayout, isDagLayout]); + const nodes = engine ? engine.getNodes() : []; + const hoveredNode = nodes.find((node) => node.getState() === 'hover') ?? null; + + const hoveredChainInfo = useMemo(() => { + if (!engine || !hoveredNode) { + return null; + } + + const chainId = hoveredNode.getPropertyValue('collapsedChainId'); + const collapsedNodeIds = hoveredNode.getPropertyValue('collapsedNodeIds'); + const representativeId = hoveredNode.getPropertyValue('collapsedChainRepresentativeId'); + + if ( + chainId === null || + chainId === undefined || + !Array.isArray(collapsedNodeIds) || + collapsedNodeIds.length <= 1 || + representativeId !== hoveredNode.getId() + ) { + return null; + } + + const collapsedNodeIdSet = new Set(collapsedNodeIds as (string | number)[]); + const graph = engine.props.graph; + const parents = new Set(); + + for (const edge of graph.getEdges()) { + if (!edge.isDirected()) { + continue; + } + const targetId = edge.getTargetNodeId(); + if (!collapsedNodeIdSet.has(targetId)) { + continue; + } + const sourceId = edge.getSourceNodeId(); + if (collapsedNodeIdSet.has(sourceId)) { + continue; + } + parents.add(sourceId); + } + + const parentList = Array.from(parents).map((id) => String(id)); + parentList.sort((a, b) => a.localeCompare(b, undefined, {numeric: true, sensitivity: 'base'})); + + return { + chainId: String(chainId), + breadcrumb: parentList.length ? parentList.join(' › ') : 'No parent nodes', + collapsedLength: collapsedNodeIdSet.size + }; + }, [engine, hoveredNode, engineRevision]); + const handleToggleCollapseEnabled = useCallback(() => { setCollapseEnabled((value) => !value); }, []); @@ -336,6 +409,30 @@ export function App(props) { setSelectedLayout(layoutType); }, []); + const handleNodeInteraction = useCallback(() => { + bumpEngineRevision(); + }, [bumpEngineRevision]); + + const graphLayerNodeEvents = useMemo( + () => ({ + onHover: handleNodeInteraction, + onMouseLeave: handleNodeInteraction, + onClick: handleNodeInteraction, + onDrag: handleNodeInteraction + }), + [handleNodeInteraction] + ); + + const handleMiniMapRecenter = useCallback( + (target: [number, number]) => { + setViewState((prev) => ({ + ...prev, + target: [target[0], target[1]] + })); + }, + [setViewState] + ); + return (
getToolTip(info.object)} /> + {engine ? ( +
+
+ +
+ {hoveredChainInfo ? ( +
+
+ Chain {hoveredChainInfo.chainId} + + • {hoveredChainInfo.collapsedLength} nodes + +
+
Parents: {hoveredChainInfo.breadcrumb}
+
+ ) : ( +
+ Hover a collapsed chain to see parent breadcrumbs. +
+ )} +
+ ) : null}