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:
@@ -43,6 +43,7 @@
|
||||
"@xyflow/react": "12.10.1",
|
||||
"accept-language": "3.0.20",
|
||||
"dayjs": "1.11.19",
|
||||
"elkjs": "0.11.0",
|
||||
"envin": "catalog:",
|
||||
"marked": "16.4.1",
|
||||
"motion": "12.23.24",
|
||||
@@ -73,6 +74,7 @@
|
||||
"@turbostarter/eslint-config": "workspace:*",
|
||||
"@turbostarter/prettier-config": "workspace:*",
|
||||
"@turbostarter/tsconfig": "workspace:*",
|
||||
"@turbostarter/vitest-config": "workspace:*",
|
||||
"@types/node": "catalog:node22",
|
||||
"@types/react": "catalog:react19",
|
||||
"@types/react-dom": "catalog:react19",
|
||||
@@ -80,7 +82,6 @@
|
||||
"eslint": "catalog:",
|
||||
"prettier": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"@turbostarter/vitest-config": "workspace:*",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,4 +39,9 @@
|
||||
--edge-default: oklch(0.55 0.01 286);
|
||||
--edge-selected: oklch(0.623 0.214 260);
|
||||
}
|
||||
|
||||
/* ELK layout animation — only active during auto-layout transitions */
|
||||
.react-flow__node.layouting {
|
||||
transition: transform 200ms ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,9 +7,11 @@ import {
|
||||
Controls,
|
||||
MiniMap,
|
||||
BackgroundVariant,
|
||||
Panel,
|
||||
} from "@xyflow/react";
|
||||
|
||||
import { useGraphStore } from "../../stores/useGraphStore";
|
||||
import { useAutoLayout } from "../../hooks/useAutoLayout";
|
||||
|
||||
const nodeTypes = {};
|
||||
|
||||
@@ -20,6 +22,8 @@ function CanvasInner() {
|
||||
const onEdgesChange = useGraphStore((s) => s.onEdgesChange);
|
||||
const onViewportChange = useGraphStore((s) => s.onViewportChange);
|
||||
|
||||
const { isLayouting } = useAutoLayout();
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<ReactFlow
|
||||
@@ -45,6 +49,13 @@ function CanvasInner() {
|
||||
zoomable
|
||||
style={{ width: 120, height: 80 }}
|
||||
/>
|
||||
{isLayouting && (
|
||||
<Panel position="top-center">
|
||||
<div className="rounded-md bg-background/80 px-3 py-1.5 text-xs text-muted-foreground backdrop-blur-sm border border-border">
|
||||
Computing layout...
|
||||
</div>
|
||||
</Panel>
|
||||
)}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -27,6 +27,8 @@ export function DiagramEditor({ diagram }: DiagramEditorProps) {
|
||||
const initializeFromGraphData = useGraphStore(
|
||||
(s) => s.initializeFromGraphData,
|
||||
);
|
||||
const setLayoutDirection = useGraphStore((s) => s.setLayoutDirection);
|
||||
const setEdgeRouting = useGraphStore((s) => s.setEdgeRouting);
|
||||
const resetStore = useGraphStore((s) => s.reset);
|
||||
|
||||
// Initialize graph store from diagram data; reset on unmount
|
||||
@@ -39,8 +41,17 @@ export function DiagramEditor({ diagram }: DiagramEditorProps) {
|
||||
};
|
||||
const { nodes, edges } = graphToFlow(graphData);
|
||||
initializeFromGraphData(nodes, edges);
|
||||
|
||||
// Initialize layout settings from diagram metadata
|
||||
if (graphData.meta?.layoutDirection) {
|
||||
setLayoutDirection(graphData.meta.layoutDirection);
|
||||
}
|
||||
if (graphData.meta?.edgeRouting) {
|
||||
setEdgeRouting(graphData.meta.edgeRouting);
|
||||
}
|
||||
|
||||
return () => resetStore();
|
||||
}, [diagram.id, diagram.graphData, initializeFromGraphData, resetStore]);
|
||||
}, [diagram.id, diagram.graphData, initializeFromGraphData, setLayoutDirection, setEdgeRouting, resetStore]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
|
||||
@@ -5,6 +5,20 @@ import { diagramTypeConfig } from "../DiagramCard";
|
||||
import { useGraphStore } from "../../stores/useGraphStore";
|
||||
|
||||
import type { DiagramType } from "../../types/graph";
|
||||
import type { LayoutDirection, EdgeRouting } from "../../lib/elk-layout";
|
||||
|
||||
const DIRECTION_LABELS: Record<LayoutDirection, string> = {
|
||||
DOWN: "Top to Bottom",
|
||||
RIGHT: "Left to Right",
|
||||
UP: "Bottom to Top",
|
||||
LEFT: "Right to Left",
|
||||
};
|
||||
|
||||
const ROUTING_LABELS: Record<EdgeRouting, string> = {
|
||||
ORTHOGONAL: "Orthogonal",
|
||||
SPLINES: "Splines",
|
||||
POLYLINE: "Polyline",
|
||||
};
|
||||
|
||||
interface EditorStatusBarProps {
|
||||
diagramType: DiagramType;
|
||||
@@ -13,6 +27,10 @@ interface EditorStatusBarProps {
|
||||
export function EditorStatusBar({ diagramType }: EditorStatusBarProps) {
|
||||
const zoomLevel = useGraphStore((s) => s.zoomLevel);
|
||||
const nodeCount = useGraphStore((s) => s.nodeCount);
|
||||
const layoutDirection = useGraphStore((s) => s.layoutDirection);
|
||||
const edgeRouting = useGraphStore((s) => s.edgeRouting);
|
||||
const setLayoutDirection = useGraphStore((s) => s.setLayoutDirection);
|
||||
const setEdgeRouting = useGraphStore((s) => s.setEdgeRouting);
|
||||
const config = diagramTypeConfig[diagramType];
|
||||
const TypeIcon = config.icon;
|
||||
|
||||
@@ -30,6 +48,38 @@ export function EditorStatusBar({ diagramType }: EditorStatusBarProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
value={layoutDirection}
|
||||
onChange={(e) =>
|
||||
setLayoutDirection(e.target.value as LayoutDirection)
|
||||
}
|
||||
className="h-5 rounded border border-border bg-transparent px-1 text-xs text-muted-foreground focus:outline-none"
|
||||
aria-label="Layout direction"
|
||||
>
|
||||
{(Object.keys(DIRECTION_LABELS) as LayoutDirection[]).map((dir) => (
|
||||
<option key={dir} value={dir}>
|
||||
{DIRECTION_LABELS[dir]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
value={edgeRouting}
|
||||
onChange={(e) => setEdgeRouting(e.target.value as EdgeRouting)}
|
||||
className="h-5 rounded border border-border bg-transparent px-1 text-xs text-muted-foreground focus:outline-none"
|
||||
aria-label="Edge routing"
|
||||
>
|
||||
{(Object.keys(ROUTING_LABELS) as EdgeRouting[]).map((routing) => (
|
||||
<option key={routing} value={routing}>
|
||||
{ROUTING_LABELS[routing]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
|
||||
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 };
|
||||
}
|
||||
236
apps/web/src/modules/diagram/lib/elk-layout.test.ts
Normal file
236
apps/web/src/modules/diagram/lib/elk-layout.test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Node, Edge } from "@xyflow/react";
|
||||
|
||||
import { buildElkGraph, resolvePositions, SOFT_CAP_NODE_COUNT } from "./elk-layout";
|
||||
|
||||
import type { DiagramNode } from "../types/graph";
|
||||
|
||||
// ── Test Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
function createNode(
|
||||
id: string,
|
||||
overrides: Partial<DiagramNode> = {},
|
||||
): Node {
|
||||
return {
|
||||
id,
|
||||
type: "default",
|
||||
position: { x: 0, y: 0 },
|
||||
data: {
|
||||
id,
|
||||
type: "flow:process",
|
||||
label: `Node ${id}`,
|
||||
...overrides,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createEdge(id: string, source: string, target: string): Edge {
|
||||
return {
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
type: "default",
|
||||
data: { id, from: source, to: target },
|
||||
};
|
||||
}
|
||||
|
||||
// ── buildElkGraph Tests ─────────────────────────────────────────────────────
|
||||
|
||||
describe("buildElkGraph", () => {
|
||||
it("should create an ELK graph with correct root structure", () => {
|
||||
const nodes = [createNode("n1"), createNode("n2")];
|
||||
const edges = [createEdge("e1", "n1", "n2")];
|
||||
|
||||
const result = buildElkGraph(nodes, edges);
|
||||
|
||||
expect(result.id).toBe("root");
|
||||
expect(result.layoutOptions?.["elk.algorithm"]).toBe("layered");
|
||||
expect(result.children).toHaveLength(2);
|
||||
expect(result.edges).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should use default layout options when none provided", () => {
|
||||
const result = buildElkGraph([createNode("n1")], []);
|
||||
|
||||
expect(result.layoutOptions?.["elk.direction"]).toBe("DOWN");
|
||||
expect(result.layoutOptions?.["elk.edgeRouting"]).toBe("ORTHOGONAL");
|
||||
expect(result.layoutOptions?.["elk.spacing.nodeNode"]).toBe("80");
|
||||
expect(
|
||||
result.layoutOptions?.["elk.layered.spacing.nodeNodeBetweenLayers"],
|
||||
).toBe("100");
|
||||
});
|
||||
|
||||
it("should apply custom layout direction and edge routing", () => {
|
||||
const result = buildElkGraph([createNode("n1")], [], {
|
||||
direction: "RIGHT",
|
||||
edgeRouting: "SPLINES",
|
||||
});
|
||||
|
||||
expect(result.layoutOptions?.["elk.direction"]).toBe("RIGHT");
|
||||
expect(result.layoutOptions?.["elk.edgeRouting"]).toBe("SPLINES");
|
||||
});
|
||||
|
||||
it("should apply custom spacing options", () => {
|
||||
const result = buildElkGraph([createNode("n1")], [], {
|
||||
nodeSpacing: 40,
|
||||
layerSpacing: 60,
|
||||
});
|
||||
|
||||
expect(result.layoutOptions?.["elk.spacing.nodeNode"]).toBe("40");
|
||||
expect(
|
||||
result.layoutOptions?.["elk.layered.spacing.nodeNodeBetweenLayers"],
|
||||
).toBe("60");
|
||||
});
|
||||
|
||||
it("should use node w field for width when available", () => {
|
||||
const nodes = [createNode("n1", { w: 200 })];
|
||||
const result = buildElkGraph(nodes, []);
|
||||
|
||||
expect(result.children?.[0]?.width).toBe(200);
|
||||
});
|
||||
|
||||
it("should use default width (150) when w field is not set", () => {
|
||||
const nodes = [createNode("n1")];
|
||||
const result = buildElkGraph(nodes, []);
|
||||
|
||||
expect(result.children?.[0]?.width).toBe(150);
|
||||
});
|
||||
|
||||
it("should map edges with sources/targets arrays", () => {
|
||||
const edges = [createEdge("e1", "n1", "n2")];
|
||||
const result = buildElkGraph([createNode("n1"), createNode("n2")], edges);
|
||||
|
||||
const elkEdge = result.edges?.[0] as { sources: string[]; targets: string[] };
|
||||
expect(elkEdge.sources).toEqual(["n1"]);
|
||||
expect(elkEdge.targets).toEqual(["n2"]);
|
||||
});
|
||||
|
||||
it("should handle empty nodes and edges", () => {
|
||||
const result = buildElkGraph([], []);
|
||||
|
||||
expect(result.children).toHaveLength(0);
|
||||
expect(result.edges).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should support all four layout directions", () => {
|
||||
for (const direction of ["DOWN", "RIGHT", "LEFT", "UP"] as const) {
|
||||
const result = buildElkGraph([createNode("n1")], [], { direction });
|
||||
expect(result.layoutOptions?.["elk.direction"]).toBe(direction);
|
||||
}
|
||||
});
|
||||
|
||||
it("should support all three edge routing modes", () => {
|
||||
for (const edgeRouting of ["ORTHOGONAL", "SPLINES", "POLYLINE"] as const) {
|
||||
const result = buildElkGraph([createNode("n1")], [], { edgeRouting });
|
||||
expect(result.layoutOptions?.["elk.edgeRouting"]).toBe(edgeRouting);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── resolvePositions Tests ──────────────────────────────────────────────────
|
||||
|
||||
describe("resolvePositions", () => {
|
||||
it("should map ELK positions to nodes", () => {
|
||||
const originalNodes = [createNode("n1"), createNode("n2")];
|
||||
const elkGraph = {
|
||||
id: "root",
|
||||
children: [
|
||||
{ id: "n1", x: 100, y: 200, width: 150, height: 50 },
|
||||
{ id: "n2", x: 300, y: 400, width: 150, height: 50 },
|
||||
],
|
||||
};
|
||||
|
||||
const result = resolvePositions(elkGraph, originalNodes);
|
||||
|
||||
expect(result[0]?.position).toEqual({ x: 100, y: 200 });
|
||||
expect(result[1]?.position).toEqual({ x: 300, y: 400 });
|
||||
});
|
||||
|
||||
it("should skip nodes with manuallyPositioned flag", () => {
|
||||
const manualNode = createNode("n1", { manuallyPositioned: true });
|
||||
const autoNode = createNode("n2");
|
||||
|
||||
const elkGraph = {
|
||||
id: "root",
|
||||
children: [
|
||||
{ id: "n1", x: 100, y: 200, width: 150, height: 50 },
|
||||
{ id: "n2", x: 300, y: 400, width: 150, height: 50 },
|
||||
],
|
||||
};
|
||||
|
||||
const result = resolvePositions(elkGraph, [manualNode, autoNode]);
|
||||
|
||||
// Manual node should keep its original position (0,0 from createNode)
|
||||
expect(result[0]?.position).toEqual({ x: 0, y: 0 });
|
||||
// Auto node should get ELK position
|
||||
expect(result[1]?.position).toEqual({ x: 300, y: 400 });
|
||||
});
|
||||
|
||||
it("should NOT skip nodes that have position but no manuallyPositioned flag", () => {
|
||||
const nodeWithPosition = createNode("n1", { position: { x: 50, y: 50 } });
|
||||
const elkGraph = {
|
||||
id: "root",
|
||||
children: [
|
||||
{ id: "n1", x: 100, y: 200, width: 150, height: 50 },
|
||||
],
|
||||
};
|
||||
|
||||
const result = resolvePositions(elkGraph, [nodeWithPosition]);
|
||||
|
||||
// Should get ELK position since manuallyPositioned is not set
|
||||
expect(result[0]?.position).toEqual({ x: 100, y: 200 });
|
||||
});
|
||||
|
||||
it("should preserve node when no matching ELK position exists", () => {
|
||||
const originalNodes = [createNode("n1"), createNode("orphan")];
|
||||
const elkGraph = {
|
||||
id: "root",
|
||||
children: [{ id: "n1", x: 100, y: 200, width: 150, height: 50 }],
|
||||
};
|
||||
|
||||
const result = resolvePositions(elkGraph, originalNodes);
|
||||
|
||||
expect(result[0]?.position).toEqual({ x: 100, y: 200 });
|
||||
expect(result[1]?.position).toEqual({ x: 0, y: 0 }); // unchanged
|
||||
});
|
||||
|
||||
it("should handle empty ELK graph", () => {
|
||||
const originalNodes = [createNode("n1")];
|
||||
const elkGraph = { id: "root", children: [] };
|
||||
|
||||
const result = resolvePositions(elkGraph, originalNodes);
|
||||
|
||||
expect(result[0]?.position).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it("should handle ELK graph with no children property", () => {
|
||||
const originalNodes = [createNode("n1")];
|
||||
const elkGraph = { id: "root" };
|
||||
|
||||
const result = resolvePositions(elkGraph, originalNodes);
|
||||
|
||||
expect(result[0]?.position).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it("should not mutate original nodes", () => {
|
||||
const originalNodes = [createNode("n1")];
|
||||
const originalPosition = { ...originalNodes[0]!.position };
|
||||
const elkGraph = {
|
||||
id: "root",
|
||||
children: [{ id: "n1", x: 100, y: 200, width: 150, height: 50 }],
|
||||
};
|
||||
|
||||
resolvePositions(elkGraph, originalNodes);
|
||||
|
||||
expect(originalNodes[0]!.position).toEqual(originalPosition);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Constants Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
describe("SOFT_CAP_NODE_COUNT", () => {
|
||||
it("should be 200", () => {
|
||||
expect(SOFT_CAP_NODE_COUNT).toBe(200);
|
||||
});
|
||||
});
|
||||
176
apps/web/src/modules/diagram/lib/elk-layout.ts
Normal file
176
apps/web/src/modules/diagram/lib/elk-layout.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type { Node, Edge } from "@xyflow/react";
|
||||
import type { ElkNode, ElkExtendedEdge } from "elkjs";
|
||||
|
||||
import type { DiagramNode } from "../types/graph";
|
||||
import type { ElkWorkerResponse } from "./elk-worker";
|
||||
|
||||
// ── Layout Options ──────────────────────────────────────────────────────────
|
||||
|
||||
export type LayoutDirection = "DOWN" | "RIGHT" | "LEFT" | "UP";
|
||||
export type EdgeRouting = "ORTHOGONAL" | "SPLINES" | "POLYLINE";
|
||||
|
||||
export interface ElkLayoutOptions {
|
||||
direction: LayoutDirection;
|
||||
edgeRouting: EdgeRouting;
|
||||
nodeSpacing?: number;
|
||||
layerSpacing?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: ElkLayoutOptions = {
|
||||
direction: "DOWN",
|
||||
edgeRouting: "ORTHOGONAL",
|
||||
nodeSpacing: 80,
|
||||
layerSpacing: 100,
|
||||
};
|
||||
|
||||
const DEFAULT_NODE_WIDTH = 150;
|
||||
const DEFAULT_NODE_HEIGHT = 50;
|
||||
export const SOFT_CAP_NODE_COUNT = 200;
|
||||
|
||||
// ── ELK Graph Building ─────────────────────────────────────────────────────
|
||||
|
||||
export function buildElkGraph(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
options: Partial<ElkLayoutOptions> = {},
|
||||
): ElkNode {
|
||||
const opts = { ...DEFAULT_OPTIONS, ...options };
|
||||
|
||||
return {
|
||||
id: "root",
|
||||
layoutOptions: {
|
||||
"elk.algorithm": "layered",
|
||||
"elk.direction": opts.direction,
|
||||
"elk.edgeRouting": opts.edgeRouting,
|
||||
"elk.spacing.nodeNode": String(opts.nodeSpacing ?? 80),
|
||||
"elk.layered.spacing.nodeNodeBetweenLayers": String(
|
||||
opts.layerSpacing ?? 100,
|
||||
),
|
||||
"elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
|
||||
"elk.layered.nodePlacement.strategy": "BRANDES_KOEPF",
|
||||
},
|
||||
children: nodes.map((node) => {
|
||||
const data = node.data as unknown as DiagramNode;
|
||||
return {
|
||||
id: node.id,
|
||||
width: data.w ?? node.measured?.width ?? DEFAULT_NODE_WIDTH,
|
||||
height: node.measured?.height ?? DEFAULT_NODE_HEIGHT,
|
||||
};
|
||||
}),
|
||||
edges: edges.map(
|
||||
(edge) =>
|
||||
({
|
||||
id: edge.id,
|
||||
sources: [edge.source],
|
||||
targets: [edge.target],
|
||||
}) as ElkExtendedEdge,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Position Resolution ─────────────────────────────────────────────────────
|
||||
|
||||
export function resolvePositions(
|
||||
elkGraph: ElkNode,
|
||||
originalNodes: Node[],
|
||||
): Node[] {
|
||||
const positionMap = new Map<string, { x: number; y: number }>();
|
||||
for (const child of elkGraph.children ?? []) {
|
||||
if (child.x !== undefined && child.y !== undefined) {
|
||||
positionMap.set(child.id, { x: child.x, y: child.y });
|
||||
}
|
||||
}
|
||||
|
||||
return originalNodes.map((node) => {
|
||||
const data = node.data as unknown as DiagramNode;
|
||||
// Skip nodes explicitly marked as manually positioned (set by user drag in Story 2.9)
|
||||
if (data.manuallyPositioned) return node;
|
||||
|
||||
const elkPos = positionMap.get(node.id);
|
||||
if (!elkPos) return node;
|
||||
|
||||
return {
|
||||
...node,
|
||||
position: elkPos,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Web Worker Management ───────────────────────────────────────────────────
|
||||
|
||||
let worker: Worker | null = null;
|
||||
let pendingReject: ((reason: Error) => void) | null = null;
|
||||
|
||||
const LAYOUT_TIMEOUT_MS = 10_000;
|
||||
|
||||
function getWorker(): Worker {
|
||||
if (!worker) {
|
||||
worker = new Worker(new URL("./elk-worker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
});
|
||||
}
|
||||
return worker;
|
||||
}
|
||||
|
||||
export function terminateWorker(): void {
|
||||
if (pendingReject) {
|
||||
pendingReject(new Error("Layout cancelled"));
|
||||
pendingReject = null;
|
||||
}
|
||||
if (worker) {
|
||||
worker.terminate();
|
||||
worker = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main Layout Function ────────────────────────────────────────────────────
|
||||
|
||||
export function computeLayout(
|
||||
nodes: Node[],
|
||||
edges: Edge[],
|
||||
options: Partial<ElkLayoutOptions> = {},
|
||||
): Promise<Node[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (nodes.length === 0) {
|
||||
resolve(nodes);
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel any in-flight layout (single-flight pattern)
|
||||
if (pendingReject) {
|
||||
pendingReject(new Error("Layout superseded"));
|
||||
pendingReject = null;
|
||||
}
|
||||
|
||||
const elkGraph = buildElkGraph(nodes, edges, options);
|
||||
const w = getWorker();
|
||||
|
||||
let settled = false;
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
pendingReject = null;
|
||||
w.removeEventListener("message", handler);
|
||||
reject(new Error("ELK layout timed out"));
|
||||
}
|
||||
}, LAYOUT_TIMEOUT_MS);
|
||||
|
||||
const handler = (event: MessageEvent<ElkWorkerResponse>) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
pendingReject = null;
|
||||
w.removeEventListener("message", handler);
|
||||
|
||||
if (event.data.type === "result" && event.data.graph) {
|
||||
resolve(resolvePositions(event.data.graph, nodes));
|
||||
} else {
|
||||
reject(new Error(event.data.message ?? "ELK layout failed"));
|
||||
}
|
||||
};
|
||||
|
||||
pendingReject = reject;
|
||||
w.addEventListener("message", handler);
|
||||
w.postMessage({ type: "layout", graph: elkGraph });
|
||||
});
|
||||
}
|
||||
33
apps/web/src/modules/diagram/lib/elk-worker.ts
Normal file
33
apps/web/src/modules/diagram/lib/elk-worker.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import ELK from "elkjs/lib/elk.bundled.js";
|
||||
|
||||
import type { ElkNode } from "elkjs";
|
||||
|
||||
const elk = new ELK();
|
||||
|
||||
export interface ElkWorkerRequest {
|
||||
type: "layout";
|
||||
graph: ElkNode;
|
||||
}
|
||||
|
||||
export interface ElkWorkerResponse {
|
||||
type: "result" | "error";
|
||||
graph?: ElkNode;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
self.onmessage = async (event: MessageEvent<ElkWorkerRequest>) => {
|
||||
if (event.data.type !== "layout") return;
|
||||
|
||||
try {
|
||||
const result = await elk.layout(event.data.graph);
|
||||
(self as unknown as Worker).postMessage({
|
||||
type: "result",
|
||||
graph: result,
|
||||
} satisfies ElkWorkerResponse);
|
||||
} catch (error) {
|
||||
(self as unknown as Worker).postMessage({
|
||||
type: "error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
} satisfies ElkWorkerResponse);
|
||||
}
|
||||
};
|
||||
@@ -18,13 +18,7 @@ const makeEdge = (id: string, source: string, target: string): Edge => ({
|
||||
|
||||
describe("useGraphStore", () => {
|
||||
beforeEach(() => {
|
||||
useGraphStore.setState({
|
||||
nodes: [],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
nodeCount: 0,
|
||||
zoomLevel: 100,
|
||||
});
|
||||
useGraphStore.getState().reset();
|
||||
});
|
||||
|
||||
describe("initializeFromGraphData", () => {
|
||||
@@ -121,4 +115,45 @@ describe("useGraphStore", () => {
|
||||
expect(useGraphStore.getState().zoomLevel).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout state", () => {
|
||||
it("should have default layout direction DOWN", () => {
|
||||
expect(useGraphStore.getState().layoutDirection).toBe("DOWN");
|
||||
});
|
||||
|
||||
it("should have default edge routing ORTHOGONAL", () => {
|
||||
expect(useGraphStore.getState().edgeRouting).toBe("ORTHOGONAL");
|
||||
});
|
||||
|
||||
it("should have default isLayouting false", () => {
|
||||
expect(useGraphStore.getState().isLayouting).toBe(false);
|
||||
});
|
||||
|
||||
it("should update layout direction", () => {
|
||||
useGraphStore.getState().setLayoutDirection("RIGHT");
|
||||
expect(useGraphStore.getState().layoutDirection).toBe("RIGHT");
|
||||
});
|
||||
|
||||
it("should update edge routing", () => {
|
||||
useGraphStore.getState().setEdgeRouting("SPLINES");
|
||||
expect(useGraphStore.getState().edgeRouting).toBe("SPLINES");
|
||||
});
|
||||
|
||||
it("should update isLayouting", () => {
|
||||
useGraphStore.getState().setIsLayouting(true);
|
||||
expect(useGraphStore.getState().isLayouting).toBe(true);
|
||||
});
|
||||
|
||||
it("should reset layout state on reset()", () => {
|
||||
useGraphStore.getState().setLayoutDirection("LEFT");
|
||||
useGraphStore.getState().setEdgeRouting("POLYLINE");
|
||||
useGraphStore.getState().setIsLayouting(true);
|
||||
|
||||
useGraphStore.getState().reset();
|
||||
|
||||
expect(useGraphStore.getState().layoutDirection).toBe("DOWN");
|
||||
expect(useGraphStore.getState().edgeRouting).toBe("ORTHOGONAL");
|
||||
expect(useGraphStore.getState().isLayouting).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,17 +11,25 @@ import type {
|
||||
Viewport,
|
||||
} from "@xyflow/react";
|
||||
|
||||
import type { LayoutDirection, EdgeRouting } from "../lib/elk-layout";
|
||||
|
||||
interface GraphState {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
viewport: Viewport;
|
||||
nodeCount: number;
|
||||
zoomLevel: number;
|
||||
layoutDirection: LayoutDirection;
|
||||
edgeRouting: EdgeRouting;
|
||||
isLayouting: boolean;
|
||||
setNodes: (nodes: Node[]) => void;
|
||||
setEdges: (edges: Edge[]) => void;
|
||||
onNodesChange: OnNodesChange;
|
||||
onEdgesChange: OnEdgesChange;
|
||||
onViewportChange: (viewport: Viewport) => void;
|
||||
setLayoutDirection: (direction: LayoutDirection) => void;
|
||||
setEdgeRouting: (routing: EdgeRouting) => void;
|
||||
setIsLayouting: (isLayouting: boolean) => void;
|
||||
initializeFromGraphData: (nodes: Node[], edges: Edge[]) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
@@ -32,6 +40,9 @@ export const useGraphStore = create<GraphState>((set, get) => ({
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
nodeCount: 0,
|
||||
zoomLevel: 100,
|
||||
layoutDirection: "DOWN",
|
||||
edgeRouting: "ORTHOGONAL",
|
||||
isLayouting: false,
|
||||
|
||||
setNodes: (nodes) => set({ nodes, nodeCount: nodes.length }),
|
||||
setEdges: (edges) => set({ edges }),
|
||||
@@ -49,6 +60,10 @@ export const useGraphStore = create<GraphState>((set, get) => ({
|
||||
set({ viewport, zoomLevel: Math.round(viewport.zoom * 100) });
|
||||
},
|
||||
|
||||
setLayoutDirection: (layoutDirection) => set({ layoutDirection }),
|
||||
setEdgeRouting: (edgeRouting) => set({ edgeRouting }),
|
||||
setIsLayouting: (isLayouting) => set({ isLayouting }),
|
||||
|
||||
initializeFromGraphData: (nodes, edges) => {
|
||||
set({ nodes, edges, nodeCount: nodes.length });
|
||||
},
|
||||
@@ -60,6 +75,9 @@ export const useGraphStore = create<GraphState>((set, get) => ({
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
nodeCount: 0,
|
||||
zoomLevel: 100,
|
||||
layoutDirection: "DOWN",
|
||||
edgeRouting: "ORTHOGONAL",
|
||||
isLayouting: false,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface DiagramNode {
|
||||
color?: string;
|
||||
w?: number;
|
||||
position?: { x: number; y: number };
|
||||
manuallyPositioned?: boolean;
|
||||
lane?: string;
|
||||
group?: string;
|
||||
columns?: Column[];
|
||||
|
||||
Reference in New Issue
Block a user