Skip to content

Commit 533a4f6

Browse files
committed
Encapsulate graph animation state in layout hook
1 parent 1ed586d commit 533a4f6

5 files changed

Lines changed: 260 additions & 198 deletions

File tree

query-graphs/src/ui/QueryGraph.tsx

Lines changed: 25 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,15 @@
1-
import type {NodeChange} from "@xyflow/react";
21
import {ReactFlow, MiniMap, Controls, ReactFlowProvider} from "@xyflow/react";
32
import "@xyflow/react/dist/base.css";
43

5-
import {layoutTree} from "./tree-layout";
64
import type {TreeDescription, TreeNode} from "../tree-description";
75
import {allChildren, visitTreeNodes} from "../tree-description";
86
import type {ReactNode} from "react";
9-
import {useCallback, useMemo} from "react";
7+
import {useMemo} from "react";
108
import {QueryNode} from "./QueryNode";
119
import type {QueryGraphNode} from "./QueryNode";
1210
import {ColoredEdge} from "./ColoredEdge";
1311
import {createGraphRenderingStore, GraphRenderingStoreContext, useGraphRenderingStore} from "./store";
14-
import {useAnimatedGraphLayout} from "./useAnimatedGraphLayout";
12+
import {GraphAnimationContext, useAnimatedGraphLayout} from "./useAnimatedGraphLayout";
1513
import "./QueryGraph.css";
1614

1715
interface QueryGraphProps {
@@ -38,54 +36,32 @@ const edgeTypes = {
3836
};
3937

4038
function QueryGraphInternal({treeDescription, children, nodeIdMapping}: QueryGraphInternalProps) {
41-
// Keep React Flow's measurements in the controlled node objects. Dropping them when
42-
// recomputing the layout makes React Flow repeatedly hide and re-initialize the nodes.
43-
const nodeDimensions = useGraphRenderingStore((s) => s.nodeDimensions);
44-
const updateNodeMeasurements = useGraphRenderingStore((s) => s.updateNodeMeasurements);
45-
const onNodesChange = useCallback(
46-
(changes: NodeChange<QueryGraphNode>[]) => {
47-
const updates = changes.flatMap((change) => {
48-
if (change.type !== "dimensions" || change.dimensions === undefined) return [];
49-
return [[change.id, change.dimensions] as const];
50-
});
51-
updateNodeMeasurements(updates);
52-
},
53-
[updateNodeMeasurements],
54-
);
55-
5639
const expandedSubtrees = useGraphRenderingStore((s) => s.expandedSubtrees);
57-
const layoutAnimation = useGraphRenderingStore((s) => s.layoutAnimation);
58-
const layout = useMemo(
59-
() => layoutTree(treeDescription, nodeIdMapping, nodeDimensions, expandedSubtrees),
60-
[treeDescription, nodeIdMapping, nodeDimensions, expandedSubtrees],
61-
);
62-
const animatedLayout = useAnimatedGraphLayout(
63-
layout,
64-
layoutAnimation,
65-
layout.nodes.every((node) => nodeDimensions.has(node.id)),
66-
);
40+
const animatedLayout = useAnimatedGraphLayout(treeDescription, nodeIdMapping, expandedSubtrees);
6741

6842
return (
69-
<ReactFlow
70-
nodes={animatedLayout.nodes}
71-
edges={animatedLayout.edges}
72-
nodeOrigin={[0.5, 0]}
73-
nodeTypes={nodeTypes}
74-
edgeTypes={edgeTypes}
75-
onNodesChange={onNodesChange}
76-
fitView
77-
minZoom={0.2}
78-
maxZoom={1.5}
79-
elementsSelectable={true}
80-
nodesDraggable={false}
81-
nodesConnectable={false}
82-
nodesFocusable={false}
83-
className={"query-graph"}
84-
>
85-
{...Array.isArray(children) ? children : [children]}
86-
<MiniMap zoomable={true} pannable={true} nodeColor={minimapNodeColor} />
87-
<Controls showInteractive={false} />
88-
</ReactFlow>
43+
<GraphAnimationContext.Provider value={animatedLayout.animationController}>
44+
<ReactFlow
45+
nodes={animatedLayout.nodes}
46+
edges={animatedLayout.edges}
47+
nodeOrigin={[0.5, 0]}
48+
nodeTypes={nodeTypes}
49+
edgeTypes={edgeTypes}
50+
onNodesChange={animatedLayout.onNodesChange}
51+
fitView
52+
minZoom={0.2}
53+
maxZoom={1.5}
54+
elementsSelectable={true}
55+
nodesDraggable={false}
56+
nodesConnectable={false}
57+
nodesFocusable={false}
58+
className={"query-graph"}
59+
>
60+
{...Array.isArray(children) ? children : [children]}
61+
<MiniMap zoomable={true} pannable={true} nodeColor={minimapNodeColor} />
62+
<Controls showInteractive={false} />
63+
</ReactFlow>
64+
</GraphAnimationContext.Provider>
8965
);
9066
}
9167

query-graphs/src/ui/QueryNode.tsx

Lines changed: 21 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,22 @@
11
import type {ReactElement, MouseEvent} from "react";
2-
import {memo, useCallback, useLayoutEffect, useRef} from "react";
2+
import {memo, useCallback, useRef} from "react";
33
import type {Node, NodeProps} from "@xyflow/react";
44
import {Handle, Position} from "@xyflow/react";
55
import cc from "classcat";
66
import type {TreeNode} from "../tree-description";
77
import {NodeIcon} from "./NodeIcon";
88
import "./QueryNode.css";
99
import {useGraphRenderingStore} from "./store";
10-
import {animationStartTime, graphAnimationProgress} from "./animation-timing";
10+
import {useGraphAnimationController} from "./useAnimatedGraphLayout";
1111

1212
export type QueryGraphNode = Node<TreeNode, "querynode">;
1313

1414
function QueryNode({data, id}: NodeProps<QueryGraphNode>) {
1515
const expanded = useGraphRenderingStore((s) => s.expandedNodes[id]);
1616
const toggleNode = useGraphRenderingStore((s) => s.toggleExpandedNode);
17-
const finishNodeAnimation = useGraphRenderingStore((s) => s.finishNodeAnimation);
18-
const sizeAnimation = useGraphRenderingStore((s) => s.nodeSizeAnimations.get(id));
1917
const subtreeExpanded = useGraphRenderingStore((s) => s.expandedSubtrees[id]);
2018
const toggleSubtree = useGraphRenderingStore((s) => s.toggleExpandedSubtree);
19+
const animationController = useGraphAnimationController();
2120

2221
const hasProperties = data.properties?.size;
2322
const hasSubtree = data.collapsedChildren && data.collapsedChildren.length > 0;
@@ -52,65 +51,40 @@ function QueryNode({data, id}: NodeProps<QueryGraphNode>) {
5251
return measurements;
5352
}, []);
5453

55-
useLayoutEffect(() => {
56-
const element = bodyWrapperRef.current;
57-
if (element === null) return;
58-
if (sizeAnimation === undefined) {
59-
element.style.removeProperty("width");
60-
element.style.removeProperty("height");
61-
element.style.removeProperty("max-width");
62-
element.style.removeProperty("max-height");
63-
return;
64-
}
65-
66-
element.style.maxWidth = "none";
67-
element.style.maxHeight = "none";
68-
let animationFrame: number | undefined;
69-
const step = (now: number) => {
70-
const progress = graphAnimationProgress(sizeAnimation.startedAt, now);
71-
const width = sizeAnimation.from.width + (sizeAnimation.to.width - sizeAnimation.from.width) * progress;
72-
const height = sizeAnimation.from.height + (sizeAnimation.to.height - sizeAnimation.from.height) * progress;
73-
element.style.width = `${width}px`;
74-
element.style.height = `${height}px`;
75-
if (progress < 1) animationFrame = requestAnimationFrame(step);
76-
else finishNodeAnimation(id);
77-
};
78-
step(sizeAnimation.startedAt);
79-
return () => {
80-
if (animationFrame !== undefined) cancelAnimationFrame(animationFrame);
81-
};
82-
}, [finishNodeAnimation, id, sizeAnimation]);
83-
8454
const onClick = useCallback(
8555
(e: MouseEvent) => {
8656
if (e.shiftKey) {
87-
if (hasSubtree) toggleSubtree(id, animationStartTime());
57+
if (hasSubtree) animationController.animateSubtreeChange(id, () => toggleSubtree(id));
8858
} else {
8959
if (hasProperties) {
9060
const target = measureTargetDimensions(!expanded);
9161
const bodyWrapper = bodyWrapperRef.current;
92-
const startedAt = animationStartTime();
93-
const sizeAnimation =
94-
startedAt === undefined || bodyWrapper === null
95-
? undefined
96-
: {
97-
from: {width: bodyWrapper.offsetWidth, height: bodyWrapper.offsetHeight},
98-
to: target.body,
99-
startedAt,
100-
};
101-
toggleNode(id, target.node, sizeAnimation);
62+
if (bodyWrapper === null) {
63+
toggleNode(id);
64+
} else {
65+
animationController.animateNodeResize(
66+
{
67+
nodeId: id,
68+
targetDimensions: target.node,
69+
bodyElement: bodyWrapper,
70+
bodyFrom: {width: bodyWrapper.offsetWidth, height: bodyWrapper.offsetHeight},
71+
bodyTo: target.body,
72+
},
73+
() => toggleNode(id),
74+
);
75+
}
10276
}
10377
}
10478
e.stopPropagation();
10579
},
106-
[toggleNode, toggleSubtree, hasProperties, hasSubtree, expanded, id, measureTargetDimensions],
80+
[animationController, toggleNode, toggleSubtree, hasProperties, hasSubtree, expanded, id, measureTargetDimensions],
10781
);
10882
const onSubtreeHandleClick = useCallback(
10983
(e: MouseEvent) => {
110-
if (hasSubtree) toggleSubtree(id, animationStartTime());
84+
if (hasSubtree) animationController.animateSubtreeChange(id, () => toggleSubtree(id));
11185
e.stopPropagation();
11286
},
113-
[toggleSubtree, hasSubtree, id],
87+
[animationController, toggleSubtree, hasSubtree, id],
11488
);
11589
const children = [] as ReactElement[];
11690
for (const [key, value] of (data.properties || []).entries()) {

query-graphs/src/ui/store.ts

Lines changed: 10 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,17 @@
1-
import type {Dimensions} from "@xyflow/react";
21
import {createContext, useContext} from "react";
32
import {useStore} from "zustand";
43
import {devtools} from "zustand/middleware";
54
import {createStore} from "zustand/vanilla";
65
import type {StoreApi} from "zustand/vanilla";
76
import {assertNotNull} from "../assert";
87

9-
export interface NodeSizeAnimation {
10-
from: Dimensions;
11-
to: Dimensions;
12-
startedAt: number;
13-
}
14-
15-
export interface LayoutAnimation {
16-
kind: "resize" | "subtree";
17-
startedAt: number;
18-
// Entering and exiting nodes animate from or toward this node's position.
19-
anchorNodeId?: string;
20-
}
21-
22-
export interface GraphNodeDimensions {
23-
// React Flow's latest measurement, preserved on its controlled node object.
24-
measured: Dimensions;
25-
// The endpoint used for layout, frozen while the measured size animates toward it.
26-
target: Dimensions;
27-
}
28-
298
export interface GraphRenderingState {
309
// `expandedNodes` tracks which nodes show their property detail panel (toggled by a plain click).
3110
expandedNodes: Record<string, boolean>;
32-
toggleExpandedNode: (nodeId: string, targetDimensions: Dimensions, sizeAnimation: NodeSizeAnimation | undefined) => void;
33-
finishNodeAnimation: (nodeId: string) => void;
11+
toggleExpandedNode: (nodeId: string) => void;
3412
// `expandedSubtrees` tracks which nodes reveal their `collapsedChildren` (toggled by shift-click or the +/- handle).
3513
expandedSubtrees: Record<string, boolean>;
36-
toggleExpandedSubtree: (nodeId: string, startedAt: number | undefined) => void;
37-
nodeDimensions: ReadonlyMap<string, GraphNodeDimensions>;
38-
nodeSizeAnimations: ReadonlyMap<string, NodeSizeAnimation>;
39-
layoutAnimation: LayoutAnimation | undefined;
40-
updateNodeMeasurements: (updates: readonly (readonly [string, Dimensions])[]) => void;
14+
toggleExpandedSubtree: (nodeId: string) => void;
4115
}
4216

4317
export type GraphRenderingStore = StoreApi<GraphRenderingState>;
@@ -47,64 +21,20 @@ export function createGraphRenderingStore(expandedSubtrees: Record<string, boole
4721
devtools((set) => ({
4822
expandedNodes: {},
4923
expandedSubtrees,
50-
toggleExpandedNode: (nodeId, targetDimensions, sizeAnimation) =>
51-
set((state) => {
52-
const nodeSizeAnimations = new Map(state.nodeSizeAnimations);
53-
if (sizeAnimation === undefined) nodeSizeAnimations.delete(nodeId);
54-
else nodeSizeAnimations.set(nodeId, sizeAnimation);
55-
const nodeDimensions = new Map(state.nodeDimensions);
56-
const previousDimensions = nodeDimensions.get(nodeId);
57-
nodeDimensions.set(nodeId, {
58-
measured: previousDimensions?.measured ?? targetDimensions,
59-
target: targetDimensions,
60-
});
61-
return {
62-
expandedNodes: {
63-
...state.expandedNodes,
64-
[nodeId]: !state.expandedNodes[nodeId],
65-
},
66-
nodeDimensions,
67-
nodeSizeAnimations,
68-
layoutAnimation:
69-
sizeAnimation === undefined ? undefined : {kind: "resize", startedAt: sizeAnimation.startedAt},
70-
};
71-
}),
72-
finishNodeAnimation: (nodeId) =>
73-
set((state) => {
74-
if (!state.nodeSizeAnimations.has(nodeId)) return state;
75-
const nodeSizeAnimations = new Map(state.nodeSizeAnimations);
76-
nodeSizeAnimations.delete(nodeId);
77-
return {nodeSizeAnimations};
78-
}),
79-
toggleExpandedSubtree: (nodeId, startedAt) =>
24+
toggleExpandedNode: (nodeId) =>
25+
set((state) => ({
26+
expandedNodes: {
27+
...state.expandedNodes,
28+
[nodeId]: !state.expandedNodes[nodeId],
29+
},
30+
})),
31+
toggleExpandedSubtree: (nodeId) =>
8032
set((state) => ({
8133
expandedSubtrees: {
8234
...state.expandedSubtrees,
8335
[nodeId]: !state.expandedSubtrees[nodeId],
8436
},
85-
layoutAnimation: startedAt === undefined ? undefined : {kind: "subtree", startedAt, anchorNodeId: nodeId},
8637
})),
87-
nodeDimensions: new Map(),
88-
nodeSizeAnimations: new Map(),
89-
layoutAnimation: undefined,
90-
updateNodeMeasurements: (updates) =>
91-
set((state) => {
92-
let nodeDimensions: Map<string, GraphNodeDimensions> | undefined;
93-
for (const [nodeId, measured] of updates) {
94-
const previous = state.nodeDimensions.get(nodeId);
95-
const target = state.nodeSizeAnimations.has(nodeId) ? (previous?.target ?? measured) : measured;
96-
if (
97-
previous?.measured.width === measured.width &&
98-
previous.measured.height === measured.height &&
99-
previous.target.width === target.width &&
100-
previous.target.height === target.height
101-
)
102-
continue;
103-
nodeDimensions ??= new Map(state.nodeDimensions);
104-
nodeDimensions.set(nodeId, {measured, target});
105-
}
106-
return nodeDimensions === undefined ? state : {nodeDimensions};
107-
}),
10838
})),
10939
);
11040
}

query-graphs/src/ui/tree-layout.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,9 @@ import * as d3hierarchy from "d3-hierarchy";
33

44
import type * as treeDescription from "../tree-description";
55
import type {TreeNode, TreeDescription} from "../tree-description";
6-
import type {Edge} from "@xyflow/react";
6+
import type {Dimensions, Edge} from "@xyflow/react";
77
import type {QueryGraphNode} from "./QueryNode";
88
import type {ColoredGraphEdge} from "./ColoredEdge";
9-
import type {GraphNodeDimensions} from "./store";
109
import {assertNotNull} from "../assert";
1110
import type {CSSProperties} from "react";
1211

@@ -18,6 +17,11 @@ interface TreeLayout {
1817
edges: QueryGraphEdge[];
1918
}
2019

20+
export interface GraphNodeDimensions {
21+
measured: Dimensions;
22+
target: Dimensions;
23+
}
24+
2125
//
2226
// Layout a tree
2327
//

0 commit comments

Comments
 (0)