diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 214df98e..743a1940 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,7 +46,7 @@ importers: dependencies: '@xyflow/react': specifier: ^12.11.3 - version: 12.11.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 12.11.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) classcat: specifier: ^5.0.4 version: 5.0.5 @@ -56,9 +56,6 @@ importers: d3-hierarchy: specifier: ^3.1.2 version: 3.1.2 - immer: - specifier: ^11.1.18 - version: 11.1.18 react: specifier: ^19.2.8 version: 19.2.8 @@ -67,7 +64,7 @@ importers: version: 19.2.8(react@19.2.8) zustand: specifier: ^5.0.0 - version: 5.0.15(@types/react@19.2.18)(immer@11.1.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) + version: 5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)) devDependencies: '@types/d3-hierarchy': specifier: ^3.1.2 @@ -2574,9 +2571,6 @@ packages: resolution: {integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==} engines: {node: '>= 4'} - immer@11.1.18: - resolution: {integrity: sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ==} - import-local@3.2.0: resolution: {integrity: sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==} engines: {node: '>=8'} @@ -5641,13 +5635,13 @@ snapshots: '@xtuc/long@4.2.2': {} - '@xyflow/react@12.11.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(immer@11.1.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@xyflow/react@12.11.3(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@xyflow/system': 0.0.80 classcat: 5.0.5 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) - zustand: 4.5.7(@types/react@19.2.18)(immer@11.1.18)(react@19.2.8) + zustand: 4.5.7(@types/react@19.2.18)(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 '@types/react-dom': 19.2.4(@types/react@19.2.18) @@ -6845,8 +6839,6 @@ snapshots: ignore@7.0.6: {} - immer@11.1.18: {} - import-local@3.2.0: dependencies: pkg-dir: 4.2.0 @@ -8511,17 +8503,15 @@ snapshots: zod@4.4.3: {} - zustand@4.5.7(@types/react@19.2.18)(immer@11.1.18)(react@19.2.8): + zustand@4.5.7(@types/react@19.2.18)(react@19.2.8): dependencies: use-sync-external-store: 1.6.0(react@19.2.8) optionalDependencies: '@types/react': 19.2.18 - immer: 11.1.18 react: 19.2.8 - zustand@5.0.15(@types/react@19.2.18)(immer@11.1.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)): + zustand@5.0.15(@types/react@19.2.18)(react@19.2.8)(use-sync-external-store@1.6.0(react@19.2.8)): optionalDependencies: '@types/react': 19.2.18 - immer: 11.1.18 react: 19.2.8 use-sync-external-store: 1.6.0(react@19.2.8) diff --git a/query-graphs/README.md b/query-graphs/README.md index 9c5c34da..aa2b8815 100644 --- a/query-graphs/README.md +++ b/query-graphs/README.md @@ -48,10 +48,10 @@ The library intentionally exposes low-level loaders (`json`, `xml`) as generic f ## The Renderer `QueryGraph` (`src/ui/QueryGraph.tsx`) is the top-level component rendering a `TreeDescription`. -It assigns a stable id to every node, seeds the interaction store from each node's `expandedByDefault` flag, and observes on-screen node sizes with a `ResizeObserver`. +It assigns a stable id to every node, creates a graph-local rendering store seeded from each node's `expandedByDefault` flag, and retains the node dimensions measured by react-flow. `tree-layout.ts` positions the tree with [`d3-flextree`](https://github.com/Klortho/d3-flextree) on top of `d3-hierarchy`, then translates the result into react-flow nodes and edges. -Layout is driven by the **measured** DOM size of each node, so it runs in two passes: the first render uses a placeholder size and, once the `ResizeObserver` reports real dimensions, the tree re-lays-out with the correct sizes. +Layout is driven by the **measured** DOM size of each node, so it runs in two passes: react-flow measures new nodes after their first render, then the tree re-lays-out with the correct sizes. Those measurements are retained in the controlled node objects so react-flow does not re-initialize them on every layout. Edge thickness is scaled from `edgeWidth`, and `crosslinks` are added as extra edges. `QueryNode` (`src/ui/QueryNode.tsx`) draws a single node. @@ -83,12 +83,12 @@ These are the touches that make a plan readable at a glance: ### Interaction State -`store.ts` is a [Zustand](https://github.com/pmndrs/zustand) store (with the `immer` and `devtools` middleware) holding all mutable view state, so React components stay purely declarative. +Each `QueryGraph` owns a [Zustand](https://github.com/pmndrs/zustand) store holding its mutable rendering state, so multiple graphs do not interfere with one another. It tracks three things, and the distinction between the first two is the key subtlety: * `expandedNodes` — which nodes have their **property detail panel** open. * `expandedSubtrees` — which nodes reveal their **`collapsedChildren`** in the graph. -* `nodeDimensions` — the measured head/body size of each node, fed back into layout. +* `nodeDimensions` — react-flow's measurements, retained across controlled-node layout updates. ## Tech Debt diff --git a/query-graphs/package.json b/query-graphs/package.json index 228bc42a..f3c54a25 100644 --- a/query-graphs/package.json +++ b/query-graphs/package.json @@ -47,7 +47,6 @@ "classcat": "^5.0.4", "d3-flextree": "^2.1.2", "d3-hierarchy": "^3.1.2", - "immer": "^11.1.18", "react": "^19.2.8", "react-dom": "^19.2.8", "zustand": "^5.0.0" diff --git a/query-graphs/src/ui/QueryGraph.tsx b/query-graphs/src/ui/QueryGraph.tsx index c5a50f31..de74d5cf 100644 --- a/query-graphs/src/ui/QueryGraph.tsx +++ b/query-graphs/src/ui/QueryGraph.tsx @@ -1,3 +1,4 @@ +import type {NodeChange} from "@xyflow/react"; import {ReactFlow, MiniMap, Controls, ReactFlowProvider} from "@xyflow/react"; import "@xyflow/react/dist/base.css"; @@ -5,11 +6,11 @@ import {layoutTree} from "./tree-layout"; import type {TreeDescription, TreeNode} from "../tree-description"; import {allChildren, visitTreeNodes} from "../tree-description"; import type {ReactNode} from "react"; -import {useMemo, useEffect, useRef} from "react"; +import {useCallback, useMemo} from "react"; import {QueryNode} from "./QueryNode"; import type {QueryGraphNode} from "./QueryNode"; import {ColoredEdge} from "./ColoredEdge"; -import {useGraphRenderingStore} from "./store"; +import {createGraphRenderingStore, GraphRenderingStoreContext, useGraphRenderingStore} from "./store"; import "./QueryGraph.css"; interface QueryGraphProps { @@ -17,6 +18,10 @@ interface QueryGraphProps { children: ReactNode | ReactNode[]; } +interface QueryGraphInternalProps extends QueryGraphProps { + nodeIdMapping: Map; +} + function minimapNodeColor(n: QueryGraphNode): string { if (n.data.nodeColor) return n.data.nodeColor; if (n.data.iconColor) return n.data.iconColor; @@ -31,59 +36,27 @@ const edgeTypes = { colored: ColoredEdge, }; -function QueryGraphInternal({treeDescription, children}: QueryGraphProps) { - // Assign ids to all nodes - const nodeIdMapping = useMemo(() => { - let nextId = 0; - const nodeIds = new Map(); - visitTreeNodes( - treeDescription.root, - (d) => { - nodeIds.set(d, "" + nextId++); - }, - allChildren, - ); - return nodeIds; - }, [treeDescription]); - - // Initialize our state using the correct "expandedByDefault" state - const initGraphStore = useGraphRenderingStore((s) => s.init); - useMemo(() => { - const expandedSubtrees = {}; - visitTreeNodes( - treeDescription.root, - (n) => { - if (n.expandedByDefault) { - expandedSubtrees[nodeIdMapping.get(n)!] = true; - } - }, - allChildren, - ); - initGraphStore(expandedSubtrees); - }, [treeDescription, initGraphStore, nodeIdMapping]); - - // Create a ResizeObserver to keep track of the sizes of the nodes - const resizeObserverRef = useRef(undefined); +function QueryGraphInternal({treeDescription, children, nodeIdMapping}: QueryGraphInternalProps) { + // Keep React Flow's measurements in the controlled node objects. Dropping them when + // recomputing the layout makes React Flow repeatedly hide and re-initialize the nodes. + const nodeDimensions = useGraphRenderingStore((s) => s.nodeDimensions); const updateNodeDimensions = useGraphRenderingStore((s) => s.updateNodeDimensions); - const resizeObserver = useMemo(() => { - resizeObserverRef.current?.disconnect(); - const observer = new ResizeObserver(updateNodeDimensions); - resizeObserverRef.current = observer; - return observer; - }, [updateNodeDimensions]); - useEffect(() => { - return () => { - resizeObserverRef.current?.disconnect(); - }; - }, []); + const onNodesChange = useCallback( + (changes: NodeChange[]) => { + const updates = changes.flatMap((change) => { + if (change.type !== "dimensions" || change.dimensions === undefined) return []; + return [[change.id, change.dimensions] as const]; + }); + updateNodeDimensions(updates); + }, + [updateNodeDimensions], + ); - // Layout the tree, using the actual measured sizes of the DOM nodes - const nodeDimensions = useGraphRenderingStore((s) => s.nodeDimensions); - const expandedNodes = useGraphRenderingStore((s) => s.expandedNodes); + // Layout the tree using the dimensions measured by React Flow itself. const expandedSubtrees = useGraphRenderingStore((s) => s.expandedSubtrees); const layout = useMemo( - () => layoutTree(treeDescription, nodeIdMapping, nodeDimensions, expandedNodes, expandedSubtrees, resizeObserver), - [treeDescription, nodeIdMapping, nodeDimensions, expandedNodes, expandedSubtrees, resizeObserver], + () => layoutTree(treeDescription, nodeIdMapping, nodeDimensions, expandedSubtrees), + [treeDescription, nodeIdMapping, nodeDimensions, expandedSubtrees], ); return ( @@ -93,6 +66,7 @@ function QueryGraphInternal({treeDescription, children}: QueryGraphProps) { nodeOrigin={[0.5, 0]} nodeTypes={nodeTypes} edgeTypes={edgeTypes} + onNodesChange={onNodesChange} fitView minZoom={0.2} maxZoom={1.5} @@ -109,10 +83,33 @@ function QueryGraphInternal({treeDescription, children}: QueryGraphProps) { ); } +function createGraphState(treeDescription: TreeDescription) { + let nextId = 0; + const nodeIdMapping = new Map(); + const expandedSubtrees: Record = {}; + visitTreeNodes( + treeDescription.root, + (node) => { + const id = "" + nextId++; + nodeIdMapping.set(node, id); + if (node.expandedByDefault) expandedSubtrees[id] = true; + }, + allChildren, + ); + return { + nodeIdMapping, + graphStore: createGraphRenderingStore(expandedSubtrees), + }; +} + export function QueryGraph(props: QueryGraphProps) { + const {nodeIdMapping, graphStore} = useMemo(() => createGraphState(props.treeDescription), [props.treeDescription]); + return ( - + + + ); } diff --git a/query-graphs/src/ui/QueryNode.css b/query-graphs/src/ui/QueryNode.css index 75413326..51fdf8e0 100644 --- a/query-graphs/src/ui/QueryNode.css +++ b/query-graphs/src/ui/QueryNode.css @@ -46,9 +46,6 @@ } .qg-graph-node-head { - /* We don't want the node to automatically stretch/shrink with its container - * because that would trigger the `ResizeObserver` to often and would cause too - * many layout recomputations.*/ width: max-content; margin: auto; text-align: center; diff --git a/query-graphs/src/ui/QueryNode.tsx b/query-graphs/src/ui/QueryNode.tsx index 4849da8d..247f1a2c 100644 --- a/query-graphs/src/ui/QueryNode.tsx +++ b/query-graphs/src/ui/QueryNode.tsx @@ -1,5 +1,5 @@ -import type {ReactElement, MouseEvent, RefObject} from "react"; -import {memo, useCallback, useRef, useEffect} from "react"; +import type {ReactElement, MouseEvent} from "react"; +import {memo, useCallback} from "react"; import type {Node, NodeProps} from "@xyflow/react"; import {Handle, Position} from "@xyflow/react"; import cc from "classcat"; @@ -7,27 +7,10 @@ import type {TreeNode} from "../tree-description"; import {NodeIcon} from "./NodeIcon"; import "./QueryNode.css"; import {useGraphRenderingStore} from "./store"; -import {assert} from "../assert"; -type NodeData = TreeNode & {resizeObserver: ResizeObserver}; - -export type QueryGraphNode = Node; - -function useResizeObservedRef(resizeObserver: ResizeObserver): RefObject { - const ref = useRef(null); - useEffect(() => { - assert(ref.current !== null); - const currNode = ref.current; - resizeObserver.observe(currNode); - return () => resizeObserver.unobserve(currNode); - }, [resizeObserver]); - return ref; -} +export type QueryGraphNode = Node; function QueryNode({data, id}: NodeProps) { - const bodyRef = useResizeObservedRef(data.resizeObserver); - const headRef = useResizeObservedRef(data.resizeObserver); - const expanded = useGraphRenderingStore((s) => s.expandedNodes[id]); const toggleNode = useGraphRenderingStore((s) => s.toggleExpandedNode); const subtreeExpanded = useGraphRenderingStore((s) => s.expandedSubtrees[id]); @@ -93,7 +76,7 @@ function QueryNode({data, id}: NodeProps) { <>
-
+
{colorBar(data.barsAbove, "above")}
@@ -101,9 +84,7 @@ function QueryNode({data, id}: NodeProps) {
-
- {children} -
+
{children}
{colorBar(data.barsBelow, "below")}
diff --git a/query-graphs/src/ui/store.ts b/query-graphs/src/ui/store.ts index 2286da29..70ae4c29 100644 --- a/query-graphs/src/ui/store.ts +++ b/query-graphs/src/ui/store.ts @@ -1,69 +1,63 @@ -import {create} from "zustand"; -import {immer} from "zustand/middleware/immer"; +import type {Dimensions} from "@xyflow/react"; +import {createContext, useContext} from "react"; +import {useStore} from "zustand"; import {devtools} from "zustand/middleware"; +import {createStore} from "zustand/vanilla"; +import type {StoreApi} from "zustand/vanilla"; +import {assertNotNull} from "../assert"; -export interface NodeDimensions { - headWidth?: number; - headHeight?: number; - bodyWidth?: number; - bodyHeight?: number; -} - -interface GraphRenderingState { - init: (expandedSubtrees: Record) => void; +export interface GraphRenderingState { // `expandedNodes` tracks which nodes show their property detail panel (toggled by a plain click). expandedNodes: Record; toggleExpandedNode: (nodeId: string) => void; // `expandedSubtrees` tracks which nodes reveal their `collapsedChildren` (toggled by shift-click or the +/- handle). expandedSubtrees: Record; toggleExpandedSubtree: (nodeId: string) => void; - // Measured on-screen head/body sizes, reported by a ResizeObserver and fed back into layout. - nodeDimensions: Record; - updateNodeDimensions: (entries: ResizeObserverEntry[]) => unknown; + nodeDimensions: Map; + updateNodeDimensions: (updates: readonly (readonly [string, Dimensions])[]) => void; } -export const useGraphRenderingStore = create()( - devtools( - immer((set, get) => ({ +export type GraphRenderingStore = StoreApi; + +export function createGraphRenderingStore(expandedSubtrees: Record): GraphRenderingStore { + return createStore()( + devtools((set) => ({ expandedNodes: {}, - expandedSubtrees: {}, - nodeDimensions: {}, - init: (expandedSubtrees) => { - set((state) => { - state.expandedNodes = {}; - state.expandedSubtrees = expandedSubtrees; - state.nodeDimensions = {}; - }); - }, + expandedSubtrees, toggleExpandedNode: (nodeId) => - set((state) => { - state.expandedNodes[nodeId] = !get().expandedNodes[nodeId]; - }), + set((state) => ({ + expandedNodes: { + ...state.expandedNodes, + [nodeId]: !state.expandedNodes[nodeId], + }, + })), toggleExpandedSubtree: (nodeId) => + set((state) => ({ + expandedSubtrees: { + ...state.expandedSubtrees, + [nodeId]: !state.expandedSubtrees[nodeId], + }, + })), + nodeDimensions: new Map(), + updateNodeDimensions: (updates) => set((state) => { - state.expandedSubtrees[nodeId] = !get().expandedSubtrees[nodeId]; - }), - updateNodeDimensions: (entries: ResizeObserverEntry[]) => - set((state) => { - for (const e of entries) { - // Figure out which node was changed - const target = e.target as HTMLElement; - const id = target.closest(".react-flow__node")?.getAttribute("data-id"); - if (id === null || id === undefined) continue; - // Create an entry for this node, if we don't have it, yet - if (!state.nodeDimensions[id]) { - state.nodeDimensions[id] = {}; - } - // Update head/body dimensions - if (target.classList.contains("qg-graph-node-head")) { - state.nodeDimensions[id].headWidth = target.offsetWidth; - state.nodeDimensions[id].headHeight = target.offsetHeight; - } else if (target.classList.contains("qg-graph-node-body")) { - state.nodeDimensions[id].bodyWidth = target.offsetWidth; - state.nodeDimensions[id].bodyHeight = target.offsetHeight; - } + let nodeDimensions: Map | undefined; + for (const [nodeId, dimensions] of updates) { + const previous = state.nodeDimensions.get(nodeId); + if (previous?.width === dimensions.width && previous.height === dimensions.height) continue; + nodeDimensions ??= new Map(state.nodeDimensions); + nodeDimensions.set(nodeId, dimensions); } + return nodeDimensions === undefined ? state : {nodeDimensions}; }), })), - ), -); + ); +} + +export const GraphRenderingStoreContext = createContext(null); + +export function useGraphRenderingStore(selector: (state: GraphRenderingState) => T): T { + const store = useContext(GraphRenderingStoreContext); + assertNotNull(store); + return useStore(store, selector); +} diff --git a/query-graphs/src/ui/tree-layout.ts b/query-graphs/src/ui/tree-layout.ts index ecff5de2..41151636 100644 --- a/query-graphs/src/ui/tree-layout.ts +++ b/query-graphs/src/ui/tree-layout.ts @@ -1,10 +1,9 @@ import * as d3flextree from "d3-flextree"; import * as d3hierarchy from "d3-hierarchy"; -import type {NodeDimensions} from "./store"; import type * as treeDescription from "../tree-description"; import type {TreeNode, TreeDescription} from "../tree-description"; -import type {Edge} from "@xyflow/react"; +import type {Dimensions, Edge} from "@xyflow/react"; import type {QueryGraphNode} from "./QueryNode"; import type {ColoredGraphEdge} from "./ColoredEdge"; import {assertNotNull} from "../assert"; @@ -25,10 +24,8 @@ interface TreeLayout { export function layoutTree( treeData: TreeDescription, nodeIds: Map, - nodeDimensions: Record, - expandedNodes: Record, + nodeDimensions: Map, expandedSubtrees: Record, - resizeObserver: ResizeObserver, ): TreeLayout { const root = d3hierarchy.hierarchy(treeData.root, (d) => { if (expandedSubtrees[nodeIds.get(d)!] && d.collapsedChildren) { @@ -38,28 +35,19 @@ export function layoutTree( }); // Layout the tree - const heighOffset = 60; + const heightOffset = 60; const treelayout = d3flextree .flextree() .nodeSize((d) => { const id = nodeIds.get(d.data); assertNotNull(id); - const dim = nodeDimensions[id]; - if ( - dim == undefined || - dim.headWidth === undefined || - dim.headHeight === undefined || - dim.bodyWidth === undefined || - dim.bodyHeight === undefined - ) { - // Layout is a two-pass process: node sizes are only known after they are rendered and - // measured by the ResizeObserver. On the first pass we lay out with this placeholder size, - // then re-render once the measured dimensions arrive in `nodeDimensions`. + const dim = nodeDimensions.get(id); + if (dim === undefined) { + // React Flow measures new nodes after their first render. It keeps them hidden until then, + // so this placeholder only determines where that measurement render happens. return [50, 50]; } - if (expandedNodes[id]) { - return [Math.max(dim.headWidth, dim.bodyWidth) + 20, dim.headHeight + dim.bodyHeight + heighOffset]; - } else return [dim.headWidth + 20, dim.headHeight + heighOffset]; + return [dim.width + 20, dim.height + heightOffset]; }) .spacing((a, b) => (a.parent === b.parent ? 10 : 40)); const layout = treelayout(root); @@ -74,7 +62,8 @@ export function layoutTree( id, position: {x: n.x, y: n.y}, type: "querynode", - data: {...n.data, resizeObserver}, + data: n.data, + measured: nodeDimensions.get(id), }; }); const edges: QueryGraphEdge[] = d3edges.map((e) => {