Skip to content

Commit 1ed586d

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 1ed586d

8 files changed

Lines changed: 407 additions & 63 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 & 8 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 {
@@ -40,29 +41,34 @@ function QueryGraphInternal({treeDescription, children, nodeIdMapping}: QueryGra
4041
// Keep React Flow's measurements in the controlled node objects. Dropping them when
4142
// recomputing the layout makes React Flow repeatedly hide and re-initialize the nodes.
4243
const nodeDimensions = useGraphRenderingStore((s) => s.nodeDimensions);
43-
const updateNodeDimensions = useGraphRenderingStore((s) => s.updateNodeDimensions);
44+
const updateNodeMeasurements = useGraphRenderingStore((s) => s.updateNodeMeasurements);
4445
const onNodesChange = useCallback(
4546
(changes: NodeChange<QueryGraphNode>[]) => {
4647
const updates = changes.flatMap((change) => {
4748
if (change.type !== "dimensions" || change.dimensions === undefined) return [];
4849
return [[change.id, change.dimensions] as const];
4950
});
50-
updateNodeDimensions(updates);
51+
updateNodeMeasurements(updates);
5152
},
52-
[updateNodeDimensions],
53+
[updateNodeMeasurements],
5354
);
5455

55-
// Layout the tree using the dimensions measured by React Flow itself.
5656
const expandedSubtrees = useGraphRenderingStore((s) => s.expandedSubtrees);
57+
const layoutAnimation = useGraphRenderingStore((s) => s.layoutAnimation);
5758
const layout = useMemo(
5859
() => layoutTree(treeDescription, nodeIdMapping, nodeDimensions, expandedSubtrees),
5960
[treeDescription, nodeIdMapping, nodeDimensions, expandedSubtrees],
6061
);
62+
const animatedLayout = useAnimatedGraphLayout(
63+
layout,
64+
layoutAnimation,
65+
layout.nodes.every((node) => nodeDimensions.has(node.id)),
66+
);
6167

6268
return (
6369
<ReactFlow
64-
nodes={layout.nodes}
65-
edges={layout.edges}
70+
nodes={animatedLayout.nodes}
71+
edges={animatedLayout.edges}
6672
nodeOrigin={[0.5, 0]}
6773
nodeTypes={nodeTypes}
6874
edgeTypes={edgeTypes}
@@ -84,13 +90,13 @@ function QueryGraphInternal({treeDescription, children, nodeIdMapping}: QueryGra
8490
}
8591

8692
function createGraphState(treeDescription: TreeDescription) {
87-
let nextId = 0;
93+
let nextNodeId = 0;
8894
const nodeIdMapping = new Map<TreeNode, string>();
8995
const expandedSubtrees: Record<string, boolean> = {};
9096
visitTreeNodes(
9197
treeDescription.root,
9298
(node) => {
93-
const id = "" + nextId++;
99+
const id = "" + nextNodeId++;
94100
nodeIdMapping.set(node, id);
95101
if (node.expandedByDefault) expandedSubtrees[id] = true;
96102
},

query-graphs/src/ui/QueryNode.css

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

10098
.qg-graph-node.qg-expanded & {
10199
max-width: 30em;

query-graphs/src/ui/QueryNode.tsx

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,117 @@
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+
clonedBodyWrapper?.style.removeProperty("width");
43+
clonedBodyWrapper?.style.removeProperty("height");
44+
clonedBodyWrapper?.style.removeProperty("max-width");
45+
clonedBodyWrapper?.style.removeProperty("max-height");
46+
flowNode.parentElement?.append(clone);
47+
const measurements = {
48+
node: {width: clone.offsetWidth, height: clone.offsetHeight},
49+
body: {width: clonedBodyWrapper?.offsetWidth ?? 0, height: clonedBodyWrapper?.offsetHeight ?? 0},
50+
};
51+
clone.remove();
52+
return measurements;
53+
}, []);
54+
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]);
2183

2284
const onClick = useCallback(
2385
(e: MouseEvent) => {
2486
if (e.shiftKey) {
25-
if (hasSubtree) toggleSubtree(id);
87+
if (hasSubtree) toggleSubtree(id, animationStartTime());
2688
} else {
27-
if (hasProperties) toggleNode(id);
89+
if (hasProperties) {
90+
const target = measureTargetDimensions(!expanded);
91+
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);
102+
}
28103
}
29104
e.stopPropagation();
30105
},
31-
[toggleNode, toggleSubtree, hasProperties, hasSubtree, id],
106+
[toggleNode, toggleSubtree, hasProperties, hasSubtree, expanded, id, measureTargetDimensions],
32107
);
33108
const onSubtreeHandleClick = useCallback(
34109
(e: MouseEvent) => {
35-
if (hasSubtree) toggleSubtree(id);
110+
if (hasSubtree) toggleSubtree(id, animationStartTime());
36111
e.stopPropagation();
37112
},
38113
[toggleSubtree, hasSubtree, id],
39114
);
40-
41115
const children = [] as ReactElement[];
42116
for (const [key, value] of (data.properties || []).entries()) {
43117
children.push(
@@ -75,15 +149,15 @@ function QueryNode({data, id}: NodeProps<QueryGraphNode>) {
75149
return (
76150
<>
77151
<Handle type="target" position={Position.Top} />
78-
<div className={nodeClassName} onClick={onClick}>
152+
<div ref={graphNodeRef} className={nodeClassName} onClick={onClick}>
79153
<div className="qg-graph-node-head">
80154
{colorBar(data.barsAbove, "above")}
81155
<NodeIcon icon={data.icon} iconColor={data.iconColor} />
82156
<div className="qg-graph-node-label" style={{background: data.nodeColor}}>
83157
{data.name}
84158
</div>
85159
</div>
86-
<div className="qg-graph-node-body-wrapper nowheel">
160+
<div ref={bodyWrapperRef} className="qg-graph-node-body-wrapper nowheel">
87161
<div className="qg-graph-node-body">{children}</div>
88162
</div>
89163
{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: 72 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,38 @@ 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+
// 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+
929
export interface GraphRenderingState {
1030
// `expandedNodes` tracks which nodes show their property detail panel (toggled by a plain click).
1131
expandedNodes: Record<string, boolean>;
12-
toggleExpandedNode: (nodeId: string) => void;
32+
toggleExpandedNode: (nodeId: string, targetDimensions: Dimensions, sizeAnimation: NodeSizeAnimation | undefined) => void;
33+
finishNodeAnimation: (nodeId: string) => void;
1334
// `expandedSubtrees` tracks which nodes reveal their `collapsedChildren` (toggled by shift-click or the +/- handle).
1435
expandedSubtrees: Record<string, boolean>;
15-
toggleExpandedSubtree: (nodeId: string) => void;
16-
nodeDimensions: Map<string, Dimensions>;
17-
updateNodeDimensions: (updates: readonly (readonly [string, Dimensions])[]) => void;
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;
1841
}
1942

2043
export type GraphRenderingStore = StoreApi<GraphRenderingState>;
@@ -24,29 +47,61 @@ export function createGraphRenderingStore(expandedSubtrees: Record<string, boole
2447
devtools((set) => ({
2548
expandedNodes: {},
2649
expandedSubtrees,
27-
toggleExpandedNode: (nodeId) =>
28-
set((state) => ({
29-
expandedNodes: {
30-
...state.expandedNodes,
31-
[nodeId]: !state.expandedNodes[nodeId],
32-
},
33-
})),
34-
toggleExpandedSubtree: (nodeId) =>
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) =>
3580
set((state) => ({
3681
expandedSubtrees: {
3782
...state.expandedSubtrees,
3883
[nodeId]: !state.expandedSubtrees[nodeId],
3984
},
85+
layoutAnimation: startedAt === undefined ? undefined : {kind: "subtree", startedAt, anchorNodeId: nodeId},
4086
})),
4187
nodeDimensions: new Map(),
42-
updateNodeDimensions: (updates) =>
88+
nodeSizeAnimations: new Map(),
89+
layoutAnimation: undefined,
90+
updateNodeMeasurements: (updates) =>
4391
set((state) => {
44-
let nodeDimensions: Map<string, Dimensions> | undefined;
45-
for (const [nodeId, dimensions] of updates) {
92+
let nodeDimensions: Map<string, GraphNodeDimensions> | undefined;
93+
for (const [nodeId, measured] of updates) {
4694
const previous = state.nodeDimensions.get(nodeId);
47-
if (previous?.width === dimensions.width && previous.height === dimensions.height) continue;
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;
48103
nodeDimensions ??= new Map(state.nodeDimensions);
49-
nodeDimensions.set(nodeId, dimensions);
104+
nodeDimensions.set(nodeId, {measured, target});
50105
}
51106
return nodeDimensions === undefined ? state : {nodeDimensions};
52107
}),

0 commit comments

Comments
 (0)