feat: implement Story 2.2 — ELK.js auto-layout engine in Web Worker

Add automatic diagram layout via ELK.js running in a dedicated Web Worker.
Nodes animate smoothly (200ms ease-out) to computed positions using the
Sugiyama/layered algorithm. Includes layout direction controls (DOWN/RIGHT/
LEFT/UP), edge routing modes (orthogonal/splines/polyline), 200-node soft
cap warning, single-flight race condition protection, and 10s worker timeout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-02-24 19:24:37 +00:00
parent 5033109656
commit 7dd5af17ac
15 changed files with 1239 additions and 10 deletions

View File

@@ -0,0 +1,149 @@
"use client";
import { useCallback, useEffect, useRef } from "react";
import { useReactFlow } from "@xyflow/react";
import { toast } from "sonner";
import { useGraphStore } from "../stores/useGraphStore";
import {
computeLayout,
terminateWorker,
SOFT_CAP_NODE_COUNT,
} from "../lib/elk-layout";
import type { ElkLayoutOptions } from "../lib/elk-layout";
const DEBOUNCE_MS = 300;
const LAYOUT_ANIMATION_MS = 200;
const LOADING_INDICATOR_DELAY_MS = 200;
export function useAutoLayout() {
const { fitView } = useReactFlow();
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isFirstLayout = useRef(true);
const nodeCount = useGraphStore((s) => s.nodeCount);
const setNodes = useGraphStore((s) => s.setNodes);
const layoutDirection = useGraphStore((s) => s.layoutDirection);
const edgeRouting = useGraphStore((s) => s.edgeRouting);
const isLayouting = useGraphStore((s) => s.isLayouting);
const setIsLayouting = useGraphStore((s) => s.setIsLayouting);
const runLayout = useCallback(
async (options?: Partial<ElkLayoutOptions>) => {
const currentNodes = useGraphStore.getState().nodes;
const currentEdges = useGraphStore.getState().edges;
if (currentNodes.length === 0) return;
if (currentNodes.length > SOFT_CAP_NODE_COUNT) {
toast.warning(
`This diagram has ${currentNodes.length} nodes (recommended max: ${SOFT_CAP_NODE_COUNT}). Consider splitting into smaller diagrams for better performance.`,
);
}
// Only show loading indicator if layout takes > 200ms (AC#2)
const loadingTimer = setTimeout(() => {
setIsLayouting(true);
}, LOADING_INDICATOR_DELAY_MS);
// Add layouting class for CSS transition animation
const flowNodes =
document.querySelectorAll<HTMLElement>(".react-flow__node");
flowNodes.forEach((el) => el.classList.add("layouting"));
try {
const layoutedNodes = await computeLayout(
currentNodes,
currentEdges,
{
direction:
options?.direction ??
useGraphStore.getState().layoutDirection,
edgeRouting:
options?.edgeRouting ??
useGraphStore.getState().edgeRouting,
...options,
},
);
setNodes(layoutedNodes);
// Fit view after layout, with a small delay to let transition run
setTimeout(() => {
fitView({ duration: LAYOUT_ANIMATION_MS, padding: 0.1 });
}, LAYOUT_ANIMATION_MS);
} catch (error) {
// Ignore cancellations from single-flight pattern
if (
error instanceof Error &&
(error.message === "Layout superseded" ||
error.message === "Layout cancelled")
) {
return;
}
toast.error(
`Layout failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
clearTimeout(loadingTimer);
// Remove layouting class after animation
setTimeout(() => {
const flowNodesFinal =
document.querySelectorAll<HTMLElement>(".react-flow__node");
flowNodesFinal.forEach((el) => el.classList.remove("layouting"));
setIsLayouting(false);
}, LAYOUT_ANIMATION_MS);
}
},
[setNodes, setIsLayouting, fitView],
);
const triggerLayout = useCallback(
(options?: Partial<ElkLayoutOptions>) => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
runLayout(options);
}, DEBOUNCE_MS);
},
[runLayout],
);
// Run layout on initial load when there are nodes
useEffect(() => {
if (isFirstLayout.current && nodeCount > 0) {
isFirstLayout.current = false;
// Check if all nodes are at default position (0,0) — meaning they need layout
const currentNodes = useGraphStore.getState().nodes;
const needsLayout = currentNodes.every(
(n) => n.position.x === 0 && n.position.y === 0,
);
if (needsLayout) {
runLayout();
}
}
}, [nodeCount, runLayout]);
// Re-layout when direction or routing changes
useEffect(() => {
if (!isFirstLayout.current && nodeCount > 0) {
triggerLayout();
}
// Only trigger on direction/routing changes, not on node count
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layoutDirection, edgeRouting]);
// Cleanup worker on unmount
useEffect(() => {
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
terminateWorker();
};
}, []);
return { triggerLayout, isLayouting };
}