Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
"scripts": {
"lint": "eslint . --fix",
"lint-test": "eslint .",
"pretest": "pnpm --filter @tableau/query-graphs build",
"test": "node --import=tsx --test --test-isolation=none \"query-graphs/test/*.test.ts\" \"standalone-app/test/*.test.ts\"",
"pretest:update": "pnpm --filter @tableau/query-graphs build",
"test:update": "node --import=tsx --test --test-isolation=none --test-update-snapshots \"query-graphs/test/*.test.ts\" \"standalone-app/test/*.test.ts\""
},
"devDependencies": {
Expand Down
62 changes: 62 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions query-graphs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,8 @@ function MyPlanViewer({planText}: {planText: string}) {
}
```

`animationSpeed` selects the graph-transition duration and defaults to `"medium"`; the browser's reduced-motion preference always disables those transitions. Hosts can append buttons beside the built-in graph controls through `additionalControls`. Use the exported `QueryGraphControlButton` for matching React Flow styling without depending on React Flow directly.
Comment thread
vogelsgesang marked this conversation as resolved.
Outdated

Pass `{format: "hyper"}` (or another registered format) as the second argument to bypass automatic format detection.

The component imports its own CSS (`QueryGraph.css`, `QueryNode.css`, `NodeIcon.css`) and react-flow's default stylesheet; with a bundler that honors the package's `sideEffects`, those styles are included automatically when you import the component.
Expand Down
27 changes: 19 additions & 8 deletions query-graphs/src/ui/QueryGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import "@xyflow/react/dist/style.css";

import type {TreeDescription, TreeNode} from "../tree-description";
import type {MouseEvent, ReactNode} from "react";
import {useId, useMemo} from "react";
import {useMemo} from "react";
import cc from "classcat";
import {QueryNode} from "./QueryNode";
import type {QueryGraphNode} from "./QueryNode";
Expand All @@ -13,14 +13,20 @@ import {createGraphRenderingStore, GraphRenderingStoreContext, useGraphRendering
import {AnimateGraphChangeContext, useAnimatedGraphLayout, useAnimateGraphChange} from "./useAnimatedGraphLayout";
import {indexGraph} from "./graph-index";
import type {TreeParents} from "./tree-topology";
import {SettingsButton, SettingsPanel} from "./SettingsPanel";
import type {AnimationSpeed} from "./animation-timing";
import "./QueryGraph.css";

interface QueryGraphProps {
export interface QueryGraphProps {
treeDescription: TreeDescription;
children: ReactNode | ReactNode[];
/** Selects the duration of graph layout transitions. Defaults to medium. */
animationSpeed?: AnimationSpeed;
/** Additional buttons, normally `QueryGraphControlButton`s, appended to the built-in graph controls. */
additionalControls?: ReactNode;
}

export const QueryGraphControlButton = ControlButton;

interface QueryGraphInternalProps extends QueryGraphProps {
nodeIdMapping: ReadonlyMap<TreeNode, string>;
treeParents: TreeParents;
Expand Down Expand Up @@ -83,10 +89,16 @@ function ExpandedNodesControl() {
);
}

function QueryGraphInternal({treeDescription, children, nodeIdMapping, treeParents}: QueryGraphInternalProps) {
const settingsPopoverId = useId();
function QueryGraphInternal({
treeDescription,
children,
animationSpeed = "medium",
additionalControls,
nodeIdMapping,
treeParents,
}: QueryGraphInternalProps) {
const expandedSubtrees = useGraphRenderingStore((s) => s.expandedSubtrees);
const animatedLayout = useAnimatedGraphLayout(treeDescription, nodeIdMapping, treeParents, expandedSubtrees);
const animatedLayout = useAnimatedGraphLayout(treeDescription, nodeIdMapping, treeParents, expandedSubtrees, animationSpeed);
// Hide the full tree initially to avoid flickering. We use `opacity` instead
// of `visibility: hidden` because React Flow overrides inherited
// visibility on nodes after measuring them.
Expand Down Expand Up @@ -116,9 +128,8 @@ function QueryGraphInternal({treeDescription, children, nodeIdMapping, treeParen
<MiniMap zoomable={true} pannable={true} nodeColor={minimapNodeColor} nodeComponent={QueryGraphMiniMapNode} />
<Controls showInteractive={false}>
<ExpandedNodesControl />
<SettingsButton popoverId={settingsPopoverId} />
{additionalControls}
</Controls>
<SettingsPanel popoverId={settingsPopoverId} />
</ReactFlow>
</AnimateGraphChangeContext.Provider>
);
Expand Down
14 changes: 11 additions & 3 deletions query-graphs/src/ui/animation-timing.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
import {useSyncExternalStore} from "react";
import {animationSpeedOptions, settingsStore} from "./settings";

export const animationSpeedOptions = [
{value: "off", label: "Off", duration: undefined},
{value: "fast", label: "Fast", duration: 100},
{value: "medium", label: "Medium", duration: 200},
{value: "slow", label: "Slow", duration: 500},
{value: "debug", label: "Debug", duration: 5000},
] as const;

export type AnimationSpeed = (typeof animationSpeedOptions)[number]["value"];

const reducedMotionQuery = "(prefers-reduced-motion: reduce)";

Expand All @@ -16,9 +25,8 @@ export function usePrefersReducedMotion(): boolean {
);
}

export function getGraphAnimationDuration(): number | undefined {
export function getGraphAnimationDuration(animationSpeed: AnimationSpeed): number | undefined {
if (window.matchMedia(reducedMotionQuery).matches) return undefined;
const animationSpeed = settingsStore.getState().values.animationSpeed;
return animationSpeedOptions.find(({value}) => value === animationSpeed)?.duration;
}

Expand Down
6 changes: 4 additions & 2 deletions query-graphs/src/ui/useAnimatedGraphLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {TreeDescription, TreeNode} from "../tree-description";
import type {QueryGraphNode} from "./QueryNode";
import {layoutTree} from "./tree-layout";
import {getGraphAnimationDuration, graphAnimationProgress} from "./animation-timing";
import type {AnimationSpeed} from "./animation-timing";
import type {GraphLayout} from "./animated-layout";
import {
closestAnimationAnchor,
Expand Down Expand Up @@ -181,6 +182,7 @@ export function useAnimatedGraphLayout(
nodeIds: ReadonlyMap<TreeNode, string>,
treeParents: TreeParents,
expandedSubtrees: Record<string, boolean>,
animationSpeed: AnimationSpeed,
): GraphLayout & {
onNodesChange: (changes: NodeChange<QueryGraphNode>[]) => void;
animateGraphChange: AnimateGraphChange;
Expand Down Expand Up @@ -259,11 +261,11 @@ export function useAnimatedGraphLayout(
for (const {nodeId} of resizingNodes) finishNodeResize(nodeResizesRef.current, nodeId);
for (const [nodeId, resize] of resizes) nodeResizesRef.current.set(nodeId, resize);
animationAnchorNodeIdsRef.current = anchorAllVisibleNodes ? new Set() : undefined;
animationDurationRef.current = getGraphAnimationDuration();
animationDurationRef.current = getGraphAnimationDuration(animationSpeed);
applyChange();
setGraphChangeRevision((revision) => revision + 1);
},
[cancelLayoutFrame],
[animationSpeed, cancelLayoutFrame],
);

// Apply each computed target to the currently rendered frame. New
Expand Down
7 changes: 2 additions & 5 deletions query-graphs/test/animation-timing.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import {getGraphAnimationDuration, graphAnimationProgress} from "../src/ui/animation-timing";
import {settingsStore} from "../src/ui/settings";

test("the selected animation speed resolves to its duration unless motion is disabled", () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
Expand All @@ -11,12 +10,10 @@ test("the selected animation speed resolves to its duration unless motion is dis
value: {matchMedia: () => ({matches: reducedMotion})},
});
try {
settingsStore.getState().setSettings({animationSpeed: "slow"});
assert.equal(getGraphAnimationDuration(), 500);
assert.equal(getGraphAnimationDuration("slow"), 500);
reducedMotion = true;
assert.equal(getGraphAnimationDuration(), undefined);
assert.equal(getGraphAnimationDuration("slow"), undefined);
} finally {
settingsStore.getState().setSettings({animationSpeed: "medium"});
if (previousWindow === undefined) Reflect.deleteProperty(globalThis, "window");
else Object.defineProperty(globalThis, "window", previousWindow);
}
Expand Down
6 changes: 5 additions & 1 deletion standalone-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@
"@codemirror/lint": "^6.9.7",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.5",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.12",
"@replit/codemirror-emacs": "^6.1.0",
"@replit/codemirror-vim": "^6.4.0",
"@tableau/query-graphs": "workspace:^",
"@types/node": "^24.13.3",
"@types/react": "^19.2.18",
Expand All @@ -58,6 +61,7 @@
"webpack-cli": "^7.2.2",
"webpack-dev-server": "^6.0.0",
"webpack-merge": "^6.0.1",
"workbox-webpack-plugin": "^7.4.1"
"workbox-webpack-plugin": "^7.4.1",
"zustand": "^5.0.0"
}
}
Loading
Loading