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:
149
apps/web/src/modules/diagram/hooks/useAutoLayout.ts
Normal file
149
apps/web/src/modules/diagram/hooks/useAutoLayout.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user