From 1e0849b528d4f950f688d05b9f060b60ea793047 Mon Sep 17 00:00:00 2001 From: Lum1104 Date: Sun, 3 May 2026 16:38:53 +0800 Subject: [PATCH] feat(dashboard): auto-expand on zoom, focus, and tour --- .../dashboard/src/components/GraphView.tsx | 78 ++++++++++++++++++- 1 file changed, 74 insertions(+), 4 deletions(-) diff --git a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx index bdab2cc..4f3b735 100644 --- a/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx +++ b/understand-anything-plugin/packages/dashboard/src/components/GraphView.tsx @@ -981,7 +981,14 @@ function useLayerDetailGraph() { }); }, [expandedEdges, topo.portalEdges, selectedNodeId]); - return { nodes, edges }; + // Expose container topology so the parent component can wire auto-expand + // triggers (focus, tour, zoom) without having to re-derive containers. + const containerIds = useMemo( + () => topo.containers.map((c) => c.id), + [topo.containers], + ); + + return { nodes, edges, nodeToContainer: topo.nodeToContainer, containerIds }; } // ── Main inner component (must be inside ReactFlowProvider) ──────────── @@ -995,18 +1002,26 @@ function GraphViewInner() { const focusNodeId = useDashboardStore((s) => s.focusNodeId); const setFocusNode = useDashboardStore((s) => s.setFocusNode); const setReactFlowInstance = useDashboardStore((s) => s.setReactFlowInstance); + const tourHighlightedNodeIds = useDashboardStore((s) => s.tourHighlightedNodeIds); + const expandContainer = useDashboardStore((s) => s.expandContainer); const { preset } = useTheme(); const overviewGraph = useOverviewGraph(); const detailGraph = useLayerDetailGraph(); - const { nodes: initialNodes, edges: initialEdges } = - navigationLevel === "overview" ? overviewGraph : detailGraph; + const { + nodes: initialNodes, + edges: initialEdges, + nodeToContainer, + containerIds, + } = navigationLevel === "overview" + ? { ...overviewGraph, nodeToContainer: undefined, containerIds: undefined } + : detailGraph; const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); - const { fitView } = useReactFlow(); + const { fitView, getViewport } = useReactFlow(); useEffect(() => { setNodes(initialNodes); @@ -1024,6 +1039,60 @@ function GraphViewInner() { return () => clearTimeout(timer); }, [navigationLevel, activeLayerId, fitView]); + // ── Auto-expand triggers (Task 13) ───────────────────────────────────── + // Only meaningful in layer-detail; in overview mode there are no + // containers so all three effects no-op. + + // Focus: when focusNodeId resolves to a node inside a container, expand it. + // Reading expandContainer is stable (Zustand setter); intentionally omitting + // expandedContainers from deps so focus changes are the only trigger. + useEffect(() => { + if (!focusNodeId || !nodeToContainer) return; + const cid = nodeToContainer.get(focusNodeId); + // Self-maps mean ungrouped nodes have cid === focusNodeId — skip those. + if (cid && cid !== focusNodeId) expandContainer(cid); + }, [focusNodeId, nodeToContainer, expandContainer]); + + // Tour: expand containers for every tour-highlighted node so the tour + // can fitView onto real nodes rather than collapsed atoms. + useEffect(() => { + if (tourHighlightedNodeIds.length === 0 || !nodeToContainer) return; + for (const nid of tourHighlightedNodeIds) { + const cid = nodeToContainer.get(nid); + if (cid && cid !== nid) expandContainer(cid); + } + }, [tourHighlightedNodeIds, nodeToContainer, expandContainer]); + + // Zoom: debounced auto-expand when the user has zoomed in past 1.0. + // Hysteresis: zoom < 0.6 = no auto-expand AND no auto-collapse (v1, the + // user collapses manually). The handler reads expandedContainers via + // getState() inside the timeout to avoid re-creating on every expand. + const zoomTimeoutRef = useRef(null); + const onMove = useCallback(() => { + if (!containerIds || containerIds.length === 0) return; + if (zoomTimeoutRef.current !== null) { + window.clearTimeout(zoomTimeoutRef.current); + } + zoomTimeoutRef.current = window.setTimeout(() => { + const vp = getViewport(); + if (vp.zoom <= 1.0) return; + const expanded = useDashboardStore.getState().expandedContainers; + for (const cid of containerIds) { + if (!expanded.has(cid)) expandContainer(cid); + } + }, 200); + }, [containerIds, getViewport, expandContainer]); + + // Clear any pending zoom timer on unmount or when handler identity changes. + useEffect(() => { + return () => { + if (zoomTimeoutRef.current !== null) { + window.clearTimeout(zoomTimeoutRef.current); + zoomTimeoutRef.current = null; + } + }; + }, [onMove]); + const onNodeClick = useCallback( (_: React.MouseEvent, node: { id: string }) => { if (navigationLevel === "overview") { @@ -1071,6 +1140,7 @@ function GraphViewInner() { onEdgesChange={onEdgesChange} onNodeClick={onNodeClick} onPaneClick={onPaneClick} + onMove={navigationLevel === "layer-detail" ? onMove : undefined} onInit={setReactFlowInstance} nodeTypes={nodeTypes} nodesDraggable={false}