Skip to content

Commit 7cc9e12

Browse files
committed
Animate query graph layout transitions
Compute the final layout once and interpolate node size and layout from a shared clock, avoiding intermediate relayout jumps. Animate subtree additions and removals from or toward their parent while fading nodes, edges, and labels. Delay entry animations until React Flow has measured the new nodes and preserve exit destinations across interrupted transitions. Disable pointer interaction for transient elements so they do not affect hover behavior.
1 parent 200a026 commit 7cc9e12

8 files changed

Lines changed: 395 additions & 56 deletions

File tree

query-graphs/src/ui/ColoredEdge.tsx

Lines changed: 4 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type {Edge, EdgeProps} from "@xyflow/react";
2-
import {BaseEdge, getBezierPath} from "@xyflow/react";
2+
import {BezierEdge} from "@xyflow/react";
33

44
// Must be a `type` instead of the usual `interface`.
55
// xyflow's `Node<NodeData>` requires NodeData to satisfy `Record<string, unknown>` and
@@ -18,9 +18,7 @@ export type ColoredGraphEdge = Edge<ColoredEdgeData, "colored">;
1818
// stroke is painted with a gradient of contiguous color bands (one per color,
1919
// running source->target); a single color is drawn as a solid stroke.
2020
export function ColoredEdge(props: EdgeProps<ColoredGraphEdge>) {
21-
const {id, sourceX, sourceY, targetX, targetY, markerEnd, label, labelStyle, style} = props;
22-
const [edgePath, labelX, labelY] = getBezierPath(props);
23-
21+
const {id, sourceX, sourceY, targetX, targetY, style} = props;
2422
const colors = props.data?.colors ?? [];
2523
const multi = colors.length > 1;
2624

@@ -46,28 +44,10 @@ export function ColoredEdge(props: EdgeProps<ColoredGraphEdge>) {
4644
])}
4745
</linearGradient>
4846
</defs>
49-
<BaseEdge
50-
path={edgePath}
51-
labelX={labelX}
52-
labelY={labelY}
53-
label={label}
54-
labelStyle={labelStyle}
55-
markerEnd={markerEnd}
56-
style={{...style, stroke: `url(#${gradientId})`}}
57-
/>
47+
<BezierEdge {...props} style={{...style, stroke: `url(#${gradientId})`}} />
5848
</>
5949
);
6050
}
6151

62-
return (
63-
<BaseEdge
64-
path={edgePath}
65-
labelX={labelX}
66-
labelY={labelY}
67-
label={label}
68-
labelStyle={labelStyle}
69-
markerEnd={markerEnd}
70-
style={{...style, stroke: colors[0] ?? style?.stroke}}
71-
/>
72-
);
52+
return <BezierEdge {...props} style={{...style, stroke: colors[0] ?? style?.stroke}} />;
7353
}

query-graphs/src/ui/QueryGraph.tsx

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {QueryNode} from "./QueryNode";
1111
import type {QueryGraphNode} from "./QueryNode";
1212
import {ColoredEdge} from "./ColoredEdge";
1313
import {createGraphRenderingStore, GraphRenderingStoreContext, useGraphRenderingStore} from "./store";
14+
import {useAnimatedGraphLayout} from "./useAnimatedGraphLayout";
1415
import "./QueryGraph.css";
1516

1617
interface QueryGraphProps {
@@ -53,16 +54,23 @@ function QueryGraphInternal({treeDescription, children, nodeIdMapping}: QueryGra
5354
);
5455

5556
// Layout the tree using the dimensions measured by React Flow itself.
57+
const layoutDimensions = useGraphRenderingStore((s) => s.layoutDimensions);
5658
const expandedSubtrees = useGraphRenderingStore((s) => s.expandedSubtrees);
59+
const layoutAnimation = useGraphRenderingStore((s) => s.layoutAnimation);
5760
const layout = useMemo(
58-
() => layoutTree(treeDescription, nodeIdMapping, nodeDimensions, expandedSubtrees),
59-
[treeDescription, nodeIdMapping, nodeDimensions, expandedSubtrees],
61+
() => layoutTree(treeDescription, nodeIdMapping, layoutDimensions, nodeDimensions, expandedSubtrees),
62+
[treeDescription, nodeIdMapping, layoutDimensions, nodeDimensions, expandedSubtrees],
63+
);
64+
const animatedLayout = useAnimatedGraphLayout(
65+
layout,
66+
layoutAnimation,
67+
layout.nodes.every((node) => layoutDimensions.has(node.id)),
6068
);
6169

6270
return (
6371
<ReactFlow
64-
nodes={layout.nodes}
65-
edges={layout.edges}
72+
nodes={animatedLayout.nodes}
73+
edges={animatedLayout.edges}
6674
nodeOrigin={[0.5, 0]}
6775
nodeTypes={nodeTypes}
6876
edgeTypes={edgeTypes}
@@ -84,13 +92,13 @@ function QueryGraphInternal({treeDescription, children, nodeIdMapping}: QueryGra
8492
}
8593

8694
function createGraphState(treeDescription: TreeDescription) {
87-
let nextId = 0;
95+
let nextNodeId = 0;
8896
const nodeIdMapping = new Map<TreeNode, string>();
8997
const expandedSubtrees: Record<string, boolean> = {};
9098
visitTreeNodes(
9199
treeDescription.root,
92100
(node) => {
93-
const id = "" + nextId++;
101+
const id = "" + nextNodeId++;
94102
nodeIdMapping.set(node, id);
95103
if (node.expandedByDefault) expandedSubtrees[id] = true;
96104
},

query-graphs/src/ui/QueryNode.css

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,10 +94,9 @@
9494
max-width: 0;
9595
max-height: 0;
9696
overflow: hidden;
97-
transition-property: max-width, max-height;
98-
transition-duration: .2s;
9997

100-
.qg-graph-node.qg-expanded & {
98+
.qg-graph-node.qg-expanded &,
99+
.qg-graph-node.qg-animating & {
101100
max-width: 30em;
102101
max-height: 20em;
103102
}

query-graphs/src/ui/QueryNode.tsx

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,112 @@
11
import type {ReactElement, MouseEvent} from "react";
2-
import {memo, useCallback} from "react";
2+
import {memo, useCallback, useLayoutEffect, 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";
1011

1112
export type QueryGraphNode = Node<TreeNode, "querynode">;
1213

1314
function QueryNode({data, id}: NodeProps<QueryGraphNode>) {
1415
const expanded = useGraphRenderingStore((s) => s.expandedNodes[id]);
1516
const toggleNode = useGraphRenderingStore((s) => s.toggleExpandedNode);
17+
const finishNodeAnimation = useGraphRenderingStore((s) => s.finishNodeAnimation);
18+
const sizeAnimation = useGraphRenderingStore((s) => s.nodeSizeAnimations.get(id));
1619
const subtreeExpanded = useGraphRenderingStore((s) => s.expandedSubtrees[id]);
1720
const toggleSubtree = useGraphRenderingStore((s) => s.toggleExpandedSubtree);
1821

1922
const hasProperties = data.properties?.size;
2023
const hasSubtree = data.collapsedChildren && data.collapsedChildren.length > 0;
24+
const graphNodeRef = useRef<HTMLDivElement>(null);
25+
const bodyWrapperRef = useRef<HTMLDivElement>(null);
26+
27+
const measureTargetDimensions = useCallback((targetExpanded: boolean) => {
28+
const graphNode = graphNodeRef.current;
29+
const flowNode = graphNode?.closest<HTMLElement>(".react-flow__node");
30+
if (graphNode === null || flowNode === null || flowNode === undefined) {
31+
return {node: {width: 50, height: 50}, body: {width: 0, height: 0}};
32+
}
33+
34+
const clone = flowNode.cloneNode(true) as HTMLElement;
35+
const clonedGraphNode = clone.querySelector<HTMLElement>(".qg-graph-node");
36+
const clonedBodyWrapper = clone.querySelector<HTMLElement>(".qg-graph-node-body-wrapper");
37+
clone.style.position = "fixed";
38+
clone.style.transform = "none";
39+
clone.style.visibility = "hidden";
40+
clone.style.pointerEvents = "none";
41+
clonedGraphNode?.classList.toggle("qg-expanded", targetExpanded);
42+
clonedGraphNode?.classList.remove("qg-animating");
43+
clonedBodyWrapper?.style.removeProperty("width");
44+
clonedBodyWrapper?.style.removeProperty("height");
45+
flowNode.parentElement?.append(clone);
46+
const measurements = {
47+
node: {width: clone.offsetWidth, height: clone.offsetHeight},
48+
body: {width: clonedBodyWrapper?.offsetWidth ?? 0, height: clonedBodyWrapper?.offsetHeight ?? 0},
49+
};
50+
clone.remove();
51+
return measurements;
52+
}, []);
53+
54+
useLayoutEffect(() => {
55+
const element = bodyWrapperRef.current;
56+
if (element === null) return;
57+
if (sizeAnimation === undefined) {
58+
element.style.removeProperty("width");
59+
element.style.removeProperty("height");
60+
return;
61+
}
62+
63+
let animationFrame: number | undefined;
64+
const step = (now: number) => {
65+
const progress = graphAnimationProgress(sizeAnimation.startedAt, now);
66+
const width = sizeAnimation.from.width + (sizeAnimation.to.width - sizeAnimation.from.width) * progress;
67+
const height = sizeAnimation.from.height + (sizeAnimation.to.height - sizeAnimation.from.height) * progress;
68+
element.style.width = `${width}px`;
69+
element.style.height = `${height}px`;
70+
if (progress < 1) animationFrame = requestAnimationFrame(step);
71+
else finishNodeAnimation(id);
72+
};
73+
step(sizeAnimation.startedAt);
74+
return () => {
75+
if (animationFrame !== undefined) cancelAnimationFrame(animationFrame);
76+
};
77+
}, [finishNodeAnimation, id, sizeAnimation]);
2178

2279
const onClick = useCallback(
2380
(e: MouseEvent) => {
2481
if (e.shiftKey) {
25-
if (hasSubtree) toggleSubtree(id);
82+
if (hasSubtree) toggleSubtree(id, animationStartTime());
2683
} else {
27-
if (hasProperties) toggleNode(id);
84+
if (hasProperties) {
85+
const target = measureTargetDimensions(!expanded);
86+
const bodyWrapper = bodyWrapperRef.current;
87+
const startedAt = animationStartTime();
88+
const sizeAnimation =
89+
startedAt === undefined || bodyWrapper === null
90+
? undefined
91+
: {
92+
from: {width: bodyWrapper.offsetWidth, height: bodyWrapper.offsetHeight},
93+
to: target.body,
94+
startedAt,
95+
};
96+
toggleNode(id, target.node, sizeAnimation);
97+
}
2898
}
2999
e.stopPropagation();
30100
},
31-
[toggleNode, toggleSubtree, hasProperties, hasSubtree, id],
101+
[toggleNode, toggleSubtree, hasProperties, hasSubtree, expanded, id, measureTargetDimensions],
32102
);
33103
const onSubtreeHandleClick = useCallback(
34104
(e: MouseEvent) => {
35-
if (hasSubtree) toggleSubtree(id);
105+
if (hasSubtree) toggleSubtree(id, animationStartTime());
36106
e.stopPropagation();
37107
},
38108
[toggleSubtree, hasSubtree, id],
39109
);
40-
41110
const children = [] as ReactElement[];
42111
for (const [key, value] of (data.properties || []).entries()) {
43112
children.push(
@@ -51,6 +120,7 @@ function QueryNode({data, id}: NodeProps<QueryGraphNode>) {
51120
"qg-graph-node",
52121
{
53122
"qg-expanded": expanded,
123+
"qg-animating": sizeAnimation !== undefined,
54124
"qg-collapsed": hasProperties && !expanded,
55125
"qg-no-props": !hasProperties,
56126
},
@@ -75,15 +145,15 @@ function QueryNode({data, id}: NodeProps<QueryGraphNode>) {
75145
return (
76146
<>
77147
<Handle type="target" position={Position.Top} />
78-
<div className={nodeClassName} onClick={onClick}>
148+
<div ref={graphNodeRef} className={nodeClassName} onClick={onClick}>
79149
<div className="qg-graph-node-head">
80150
{colorBar(data.barsAbove, "above")}
81151
<NodeIcon icon={data.icon} iconColor={data.iconColor} />
82152
<div className="qg-graph-node-label" style={{background: data.nodeColor}}>
83153
{data.name}
84154
</div>
85155
</div>
86-
<div className="qg-graph-node-body-wrapper nowheel">
156+
<div ref={bodyWrapperRef} className="qg-graph-node-body-wrapper nowheel">
87157
<div className="qg-graph-node-body">{children}</div>
88158
</div>
89159
{colorBar(data.barsBelow, "below")}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export const graphAnimationDuration = 200;
2+
3+
export function animationStartTime(): number | undefined {
4+
return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? undefined : performance.now();
5+
}
6+
7+
export function graphAnimationProgress(startTime: number, now: number): number {
8+
const elapsed = Math.min(1, Math.max(0, (now - startTime) / graphAnimationDuration));
9+
return (1 - Math.cos(Math.PI * elapsed)) / 2;
10+
}

query-graphs/src/ui/store.ts

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,32 @@ import {createStore} from "zustand/vanilla";
66
import type {StoreApi} from "zustand/vanilla";
77
import {assertNotNull} from "../assert";
88

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+
anchorNodeId?: string;
19+
}
20+
921
export interface GraphRenderingState {
1022
// `expandedNodes` tracks which nodes show their property detail panel (toggled by a plain click).
1123
expandedNodes: Record<string, boolean>;
12-
toggleExpandedNode: (nodeId: string) => void;
24+
toggleExpandedNode: (nodeId: string, targetDimensions: Dimensions, sizeAnimation: NodeSizeAnimation | undefined) => void;
25+
finishNodeAnimation: (nodeId: string) => void;
1326
// `expandedSubtrees` tracks which nodes reveal their `collapsedChildren` (toggled by shift-click or the +/- handle).
1427
expandedSubtrees: Record<string, boolean>;
15-
toggleExpandedSubtree: (nodeId: string) => void;
28+
toggleExpandedSubtree: (nodeId: string, startedAt: number | undefined) => void;
29+
// React Flow's latest measurements are kept on its controlled node objects.
1630
nodeDimensions: Map<string, Dimensions>;
31+
// Layout dimensions jump directly to an animation's endpoint, avoiding intermediate relayouts.
32+
layoutDimensions: Map<string, Dimensions>;
33+
nodeSizeAnimations: ReadonlyMap<string, NodeSizeAnimation>;
34+
layoutAnimation: LayoutAnimation | undefined;
1735
updateNodeDimensions: (updates: readonly (readonly [string, Dimensions])[]) => void;
1836
}
1937

@@ -24,31 +42,64 @@ export function createGraphRenderingStore(expandedSubtrees: Record<string, boole
2442
devtools((set) => ({
2543
expandedNodes: {},
2644
expandedSubtrees,
27-
toggleExpandedNode: (nodeId) =>
28-
set((state) => ({
29-
expandedNodes: {
30-
...state.expandedNodes,
31-
[nodeId]: !state.expandedNodes[nodeId],
32-
},
33-
})),
34-
toggleExpandedSubtree: (nodeId) =>
45+
toggleExpandedNode: (nodeId, targetDimensions, sizeAnimation) =>
46+
set((state) => {
47+
const nodeSizeAnimations = new Map(state.nodeSizeAnimations);
48+
if (sizeAnimation === undefined) nodeSizeAnimations.delete(nodeId);
49+
else nodeSizeAnimations.set(nodeId, sizeAnimation);
50+
return {
51+
expandedNodes: {
52+
...state.expandedNodes,
53+
[nodeId]: !state.expandedNodes[nodeId],
54+
},
55+
layoutDimensions: new Map(state.layoutDimensions).set(nodeId, targetDimensions),
56+
nodeSizeAnimations,
57+
layoutAnimation:
58+
sizeAnimation === undefined ? undefined : {kind: "resize", startedAt: sizeAnimation.startedAt},
59+
};
60+
}),
61+
finishNodeAnimation: (nodeId) =>
62+
set((state) => {
63+
if (!state.nodeSizeAnimations.has(nodeId)) return state;
64+
const nodeSizeAnimations = new Map(state.nodeSizeAnimations);
65+
nodeSizeAnimations.delete(nodeId);
66+
return {nodeSizeAnimations};
67+
}),
68+
toggleExpandedSubtree: (nodeId, startedAt) =>
3569
set((state) => ({
3670
expandedSubtrees: {
3771
...state.expandedSubtrees,
3872
[nodeId]: !state.expandedSubtrees[nodeId],
3973
},
74+
layoutAnimation: startedAt === undefined ? undefined : {kind: "subtree", startedAt, anchorNodeId: nodeId},
4075
})),
4176
nodeDimensions: new Map(),
77+
layoutDimensions: new Map(),
78+
nodeSizeAnimations: new Map(),
79+
layoutAnimation: undefined,
4280
updateNodeDimensions: (updates) =>
4381
set((state) => {
4482
let nodeDimensions: Map<string, Dimensions> | undefined;
83+
let layoutDimensions: Map<string, Dimensions> | undefined;
4584
for (const [nodeId, dimensions] of updates) {
4685
const previous = state.nodeDimensions.get(nodeId);
47-
if (previous?.width === dimensions.width && previous.height === dimensions.height) continue;
48-
nodeDimensions ??= new Map(state.nodeDimensions);
49-
nodeDimensions.set(nodeId, dimensions);
86+
if (previous?.width !== dimensions.width || previous.height !== dimensions.height) {
87+
nodeDimensions ??= new Map(state.nodeDimensions);
88+
nodeDimensions.set(nodeId, dimensions);
89+
}
90+
if (!state.nodeSizeAnimations.has(nodeId)) {
91+
const previousLayout = state.layoutDimensions.get(nodeId);
92+
if (previousLayout?.width !== dimensions.width || previousLayout.height !== dimensions.height) {
93+
layoutDimensions ??= new Map(state.layoutDimensions);
94+
layoutDimensions.set(nodeId, dimensions);
95+
}
96+
}
5097
}
51-
return nodeDimensions === undefined ? state : {nodeDimensions};
98+
if (nodeDimensions === undefined && layoutDimensions === undefined) return state;
99+
return {
100+
...(nodeDimensions === undefined ? {} : {nodeDimensions}),
101+
...(layoutDimensions === undefined ? {} : {layoutDimensions}),
102+
};
52103
}),
53104
})),
54105
);

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ interface TreeLayout {
2424
export function layoutTree(
2525
treeData: TreeDescription,
2626
nodeIds: Map<TreeNode, string>,
27+
layoutDimensions: Map<string, Dimensions>,
2728
nodeDimensions: Map<string, Dimensions>,
2829
expandedSubtrees: Record<string, boolean>,
2930
): TreeLayout {
@@ -41,7 +42,7 @@ export function layoutTree(
4142
.nodeSize((d) => {
4243
const id = nodeIds.get(d.data);
4344
assertNotNull(id);
44-
const dim = nodeDimensions.get(id);
45+
const dim = layoutDimensions.get(id);
4546
if (dim === undefined) {
4647
// React Flow measures new nodes after their first render. It keeps them hidden until then,
4748
// so this placeholder only determines where that measurement render happens.

0 commit comments

Comments
 (0)