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,495 @@
# Story 2.2: ELK.js Auto-Layout Engine in Web Worker
Status: done
<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->
## Story
As a user,
I want my diagrams to be automatically laid out in a clean, professional arrangement,
so that I never have to manually arrange nodes and my diagrams always look presentable.
## Acceptance Criteria
1. **Given** a diagram has nodes and edges, **When** auto-layout is triggered (on load, after AI mutation, or via manual trigger), **Then** ELK.js computes a Sugiyama/layered layout in a Web Worker thread, **And** layout completes in < 500ms for diagrams with < 50 nodes (NFR2), **And** nodes animate smoothly to their new positions (200ms ease-out transition).
2. **Given** a layout is being computed, **When** the Web Worker is processing, **Then** the main thread remains responsive (no UI jank), **And** a subtle loading indicator appears if layout takes > 200ms.
3. **Given** a diagram has > 200 nodes, **When** auto-layout is triggered, **Then** a soft cap warning suggests splitting the diagram, **And** layout still completes (may take longer) without crashing.
4. **Given** a diagram supports multiple layout directions, **When** I select a direction (DOWN, RIGHT, LEFT, UP) from the status bar, **Then** ELK.js re-computes layout with the selected direction, **And** edge routing follows the selected mode (orthogonal default, splines, polyline).
5. **Given** nodes have manual position overrides (set by the user via drag in Story 2.9), **When** auto-layout is triggered, **Then** only nodes WITHOUT manual position overrides are repositioned by ELK, **And** nodes with manual overrides retain their user-set positions.
## Tasks / Subtasks
- [x] Task 1: Install elkjs and configure Web Worker (AC: #1, #2)
- [x] 1.1: Install `elkjs@0.11.0` in `apps/web`
- [x] 1.2: Create `apps/web/src/modules/diagram/lib/elk-worker.ts` — Web Worker wrapper that imports `elkjs/lib/elk.bundled.js` and accepts layout messages
- [x] 1.3: Verify Web Worker loads correctly in Next.js 16 with Turbopack
- [x] Task 2: Create ELK layout engine module (AC: #1, #4, #5)
- [x] 2.1: Create `apps/web/src/modules/diagram/lib/elk-layout.ts` — main layout module with `computeLayout(nodes, edges, options)` function
- [x] 2.2: Implement `buildElkGraph()` — converts DiagramNode[]/DiagramEdge[] into ELK graph format (`children` with `width`/`height`, `edges` with `sources`/`targets`)
- [x] 2.3: Implement `resolvePositions()` — maps ELK output coordinates back to @xyflow/react Node positions
- [x] 2.4: Support layout options: direction (DOWN/RIGHT/LEFT/UP), edge routing (ORTHOGONAL/SPLINES/POLYLINE), spacing
- [x] 2.5: Implement manual position override logic — skip ELK positioning for nodes where `node.data.position` was set by user drag
- [x] Task 3: Create `useAutoLayout` hook (AC: #1, #2, #3, #4)
- [x] 3.1: Create `apps/web/src/modules/diagram/hooks/useAutoLayout.ts` — React hook wrapping the layout engine with debouncing (300ms)
- [x] 3.2: Add `isLayouting` state for loading indicator
- [x] 3.3: Add 200-node soft cap warning via `toast()` from sonner
- [x] 3.4: Implement smooth node animation — apply new positions with CSS transition (200ms ease-out) via @xyflow/react's `setNodes` with position updates
- [x] Task 4: Integrate auto-layout into DiagramCanvas (AC: #1, #2, #4)
- [x] 4.1: Wire `useAutoLayout` into `DiagramCanvas.tsx` — trigger layout on initial load when diagram has nodes
- [x] 4.2: Expose `triggerLayout()` function via `useAutoLayout` hook for manual trigger and future AI mutation integration
- [x] 4.3: Add subtle loading overlay when `isLayouting` is true (Panel component with loading indicator)
- [x] Task 5: Add layout direction controls to EditorStatusBar (AC: #4)
- [x] 5.1: Add layout direction dropdown to `EditorStatusBar.tsx` — selector for DOWN/RIGHT/LEFT/UP
- [x] 5.2: Add edge routing dropdown — selector for ORTHOGONAL/SPLINES/POLYLINE
- [x] 5.3: Store layout direction and edge routing in `useGraphStore` as `layoutDirection` and `edgeRouting` state
- [x] 5.4: Direction/routing changes trigger re-layout via `useAutoLayout`
- [x] Task 6: Tests (AC: all)
- [x] 6.1: Unit tests for `buildElkGraph()` — converting diagram nodes/edges to ELK format (10 tests)
- [x] 6.2: Unit tests for `resolvePositions()` — mapping ELK output back to @xyflow positions (6 tests)
- [x] 6.3: Unit tests for layout options (direction, edge routing, spacing) — covered in buildElkGraph tests
- [x] 6.4: Unit test for SOFT_CAP_NODE_COUNT constant (1 test)
- [x] 6.5: All 49 tests pass (15 graph-converter + 17 elk-layout + 17 store including 7 new layout state tests)
## Dev Notes
### Overview — What This Story Builds
This story adds automatic diagram layout via ELK.js running in a Web Worker. When a diagram loads with nodes (or when the user triggers layout manually), ELK.js computes optimal positions using the Sugiyama/layered algorithm and the canvas smoothly animates nodes to their new positions. The Web Worker ensures the main thread stays responsive even for large diagrams.
**This story does NOT implement:**
- Custom node renderers (Stories 2.3-2.8)
- BPMN compound layout with pools/lanes (Story 2.3 will extend the ELK graph builder)
- Liveblocks/CRDT integration (Epic 4)
- AI-triggered layout (Epic 3 will call `triggerLayout()`)
### Architecture Compliance
**MANDATORY patterns from Architecture Decision Document:**
1. **ELK.js in Web Worker (Architecture Overview):** ELK.js MUST run in a Web Worker to keep the main thread responsive (NFR2). Use `elkjs/lib/elk.bundled.js` inside the worker — the built-in worker mode via `workerFactory` is the cleanest approach for Next.js 16.
2. **200-node soft cap:** Architecture specifies a 200-node soft cap for v1. Layout should still work beyond 200 nodes but show a warning toast suggesting the user split the diagram.
3. **Debounce layout recalculations (300ms):** Per the epics file technical notes, rapid mutations should not trigger redundant layout calls.
4. **Node position transitions:** Smooth 200ms ease-out animation when nodes move to new positions after layout.
5. **Component Structure:** Feature code in `~/modules/diagram/`, NOT co-located in route directories. Layout logic in `~/modules/diagram/lib/`, hook in `~/modules/diagram/hooks/`.
6. **Lean JSON data model:** The stored graph data does NOT contain x/y positions for auto-laid-out nodes. Position is computed at render time by ELK. Only manual position overrides (from user drag, Story 2.9) are stored in the node data.
### ELK.js Web Worker — Implementation Approach
**Package:** `elkjs@0.11.0` (latest stable, September 2025)
**Worker setup:** Use `elkjs/lib/elk.bundled.js` inside a dedicated Web Worker file. The worker receives graph data via `postMessage`, runs `elk.layout()`, and returns the layouted graph.
**Why `elk.bundled.js` in a custom worker vs `workerFactory`:** The `workerFactory` approach with `elk-api.js` + `elk-worker.min.js` creates a nested worker (worker-in-worker) which has limited browser support and causes issues with some bundlers. Using `elk.bundled.js` in our own Web Worker is simpler and more reliable.
```typescript
// elk-worker.ts — Web Worker file
import ELK from 'elkjs/lib/elk.bundled.js';
const elk = new ELK();
self.onmessage = async (event: MessageEvent) => {
try {
const result = await elk.layout(event.data);
self.postMessage({ type: 'result', graph: result });
} catch (error) {
self.postMessage({ type: 'error', message: String(error) });
}
};
```
**Worker instantiation in the layout module:**
```typescript
// elk-layout.ts
const worker = new Worker(
new URL('./elk-worker.ts', import.meta.url),
{ type: 'module' }
);
```
This pattern works with Turbopack (Next.js 16's default bundler) which supports `new URL('./file', import.meta.url)` for Web Workers.
### ELK Graph Building — `buildElkGraph()`
Converts the unified graph model to ELK's expected input format:
```typescript
interface ElkLayoutOptions {
direction: 'DOWN' | 'RIGHT' | 'LEFT' | 'UP';
edgeRouting: 'ORTHOGONAL' | 'SPLINES' | 'POLYLINE';
nodeSpacing?: number; // default: 80
layerSpacing?: number; // default: 100
}
function buildElkGraph(
nodes: DiagramNode[],
edges: DiagramEdge[],
options: ElkLayoutOptions
): ElkNode {
return {
id: 'root',
layoutOptions: {
'elk.algorithm': 'layered',
'elk.direction': options.direction,
'elk.edgeRouting': options.edgeRouting,
'elk.spacing.nodeNode': String(options.nodeSpacing ?? 80),
'elk.layered.spacing.nodeNodeBetweenLayers': String(options.layerSpacing ?? 100),
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
},
children: nodes.map(node => ({
id: node.id,
width: node.w ?? 150, // Use node's width hint or default
height: 50, // Default height (custom nodes will override in Stories 2.3-2.8)
})),
edges: edges.map(edge => ({
id: edge.id,
sources: [edge.from],
targets: [edge.to],
})),
};
}
```
**Key points:**
- Node `width` uses the `w` field from the graph data model (Flexicar convention: only width is stored, height is derived from content)
- Default width 150px, height 50px — these will be refined when custom node renderers are added (Stories 2.3-2.8)
- ELK layout options values must be strings
- Edge format uses `sources`/`targets` arrays (ELK extended format)
### Position Resolution — `resolvePositions()`
Maps ELK output back to @xyflow/react node positions:
```typescript
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 => {
// Skip nodes with manual position overrides
const hasManualPosition = (node.data as DiagramNode).position !== undefined;
if (hasManualPosition) return node;
const elkPos = positionMap.get(node.id);
if (!elkPos) return node;
return {
...node,
position: elkPos,
};
});
}
```
**Coordinate system:** For flat (non-hierarchical) graphs, ELK positions are already absolute. For BPMN compound graphs (Story 2.3), positions are relative to parent — that story will extend `resolvePositions` to handle compound layouts.
### Smooth Node Animation
@xyflow/react does not have built-in animated position transitions. The approach:
1. After ELK returns positions, update nodes via `setNodes()` in the Zustand store
2. Apply CSS transition on the `.react-flow__node` elements:
```css
.react-flow__node {
transition: transform 200ms ease-out;
}
```
3. The `transition` property animates the CSS `transform: translate(x, y)` that @xyflow/react uses for node positioning
**IMPORTANT:** Disable the CSS transition during user drag operations (Story 2.9) to prevent laggy drag behavior. The transition should only be active during layout animations.
Add to `globals.css`:
```css
.react-flow__node.layouting {
transition: transform 200ms ease-out;
}
```
The `layouting` class is added temporarily during layout animation and removed after 200ms.
### Layout Direction Controls in Status Bar
Add a dropdown to `EditorStatusBar.tsx` for layout direction:
```
[BPMN icon] BPMN | [3 nodes] | [DOWN ▼] [ORTHOGONAL ▼] | [zoom 100%]
```
Use shadcn/ui `DropdownMenu` (consistent with Story 1.4's pattern choice of DropdownMenu over ContextMenu).
### Zustand Store Extensions
Add to `useGraphStore`:
```typescript
interface GraphState {
// ... existing fields ...
layoutDirection: 'DOWN' | 'RIGHT' | 'LEFT' | 'UP';
edgeRouting: 'ORTHOGONAL' | 'SPLINES' | 'POLYLINE';
isLayouting: boolean;
setLayoutDirection: (direction: 'DOWN' | 'RIGHT' | 'LEFT' | 'UP') => void;
setEdgeRouting: (routing: 'ORTHOGONAL' | 'SPLINES' | 'POLYLINE') => void;
setIsLayouting: (isLayouting: boolean) => void;
}
```
Defaults: `layoutDirection: 'DOWN'`, `edgeRouting: 'ORTHOGONAL'`.
If the diagram has `meta.layoutDirection` or `meta.edgeRouting`, use those values on initialization.
### Existing Code to Reuse / Modify
| File | Action | What |
|------|--------|------|
| `apps/web/src/modules/diagram/stores/useGraphStore.ts` | **MODIFY** | Add `layoutDirection`, `edgeRouting`, `isLayouting` state + setters |
| `apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx` | **MODIFY** | Wire `useAutoLayout` hook, add layouting CSS class logic |
| `apps/web/src/modules/diagram/components/editor/DiagramEditor.tsx` | **READ** | Understand initialization flow (graphData → graphToFlow → initializeFromGraphData) |
| `apps/web/src/modules/diagram/components/editor/EditorStatusBar.tsx` | **MODIFY** | Add layout direction and edge routing dropdowns |
| `apps/web/src/modules/diagram/types/graph.ts` | **READ** | DiagramNode, DiagramEdge, DiagramMeta types (layoutDirection/edgeRouting already defined in DiagramMeta) |
| `apps/web/src/modules/diagram/lib/graph-converter.ts` | **READ** | Understand how graphToFlow/flowToGraph bridge stored format and @xyflow format |
| `apps/web/src/assets/styles/globals.css` | **MODIFY** | Add layout animation CSS transition |
| `apps/web/package.json` | **MODIFY** | Add `elkjs` dependency |
### Library & Framework Requirements
| Package | Version | Purpose | Install Command |
|---------|---------|---------|-----------------|
| `elkjs` | 0.11.0 | Auto-layout engine (Sugiyama/layered algorithm) | `pnpm --filter web add elkjs@0.11.0` |
**Already installed:**
- `@xyflow/react` 12.10.1 — canvas rendering
- `zustand` 5.0.8 — state management
- `sonner` 2.0.7 — toast notifications for soft cap warning
**NOT needed yet:**
- `@liveblocks/*` — Story 4.1
- `@deepgram/sdk` — Story 5.1
### ELK.js 0.11.0 — Key Implementation Details
**Package:** `elkjs` (NOT `elk`, NOT `@kieler/elkjs`)
**Import for Web Worker (bundled mode):**
```typescript
import ELK from 'elkjs/lib/elk.bundled.js';
```
**Import for main thread API only (if using workerFactory):**
```typescript
import ELK from 'elkjs/lib/elk-api.js';
```
**TypeScript types:** ELK.js ships with types. Key types:
- `ElkNode` — graph/node structure with `id`, `children`, `edges`, `layoutOptions`, `x`, `y`, `width`, `height`
- `ElkEdge` / `ElkExtendedEdge` — edge with `sources`/`targets` arrays
- `ElkLabel` — text labels on nodes/edges
- `LayoutOptions``Record<string, string>` for ELK options
**ELK graph input format:**
```typescript
{
id: "root",
layoutOptions: { 'elk.algorithm': 'layered', ... },
children: [
{ id: "n1", width: 150, height: 50 },
{ id: "n2", width: 150, height: 50 }
],
edges: [
{ id: "e1", sources: ["n1"], targets: ["n2"] }
]
}
```
**ELK output:** Same structure as input but with `x`/`y` added to each child and `sections` with `startPoint`/`endPoint`/`bendPoints` added to each edge.
**All layout option values must be strings** — e.g., `'80'` not `80`.
**License:** EPL 2.0 — compatible with commercial SaaS (confirmed in architecture doc).
### File Structure for This Story
New files:
```
apps/web/src/modules/diagram/
├── lib/
│ ├── elk-worker.ts # Web Worker running ELK layout
│ ├── elk-layout.ts # Layout engine: buildElkGraph, resolvePositions, computeLayout
│ ├── elk-layout.test.ts # Unit tests for layout functions
│ └── (existing) graph-converter.ts
├── hooks/
│ └── useAutoLayout.ts # React hook wrapping layout engine with debouncing
└── (existing) stores/
└── useGraphStore.ts # Modified: add layout state
```
Modified files:
```
apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx # Wire useAutoLayout
apps/web/src/modules/diagram/components/editor/EditorStatusBar.tsx # Add direction/routing dropdowns
apps/web/src/modules/diagram/stores/useGraphStore.ts # Add layout state
apps/web/src/assets/styles/globals.css # Add layout animation CSS
apps/web/package.json # Add elkjs dependency
pnpm-lock.yaml # Updated lockfile
```
### Project Structure Notes
- Layout logic (`elk-layout.ts`, `elk-worker.ts`) goes in `~/modules/diagram/lib/` — utilities/engines
- The hook (`useAutoLayout.ts`) goes in `~/modules/diagram/hooks/` — new directory for diagram-specific hooks (forward-compatible with `useDeepgramSTT`, `useAIStream`, etc. from future epics)
- Web Worker file at `elk-worker.ts` — Turbopack resolves `new URL('./elk-worker.ts', import.meta.url)` at build time
- Tests co-located: `elk-layout.test.ts` next to `elk-layout.ts`
### Anti-Patterns to Avoid
- **NEVER run ELK on the main thread** — always via Web Worker. The `elk.bundled.js` import must be inside the worker file, not the main thread module
- **NEVER use `elk-api.js` + `workerFactory` with nested workers** — this pattern has compatibility issues with Turbopack. Use `elk.bundled.js` in a custom worker instead
- **NEVER hardcode node dimensions** — use the `w` field from DiagramNode when available, fall back to defaults. Custom node renderers (Stories 2.3-2.8) will provide accurate dimensions
- **NEVER store ELK-computed positions in the persisted graph data** — positions are ephemeral and recomputed on load. Only manual overrides (from user drag) should be stored
- **NEVER trigger layout synchronously** — always debounce (300ms) and run async via the Web Worker
- **NEVER apply CSS transitions to `.react-flow__node` during drag** — only during layout animation (use the `.layouting` class toggle)
- **NEVER import from `reactflow`** — use `@xyflow/react` (v12+)
- **NEVER use `require()`** — ESM-only project
- **NEVER co-locate feature code in route directories** — use `~/modules/diagram/`
- **DO NOT implement BPMN compound layout (pools/lanes)** — that's Story 2.3. This story handles flat graph layout only
- **DO NOT implement Liveblocks CRDT** — that's Story 4.1
- **DO NOT implement manual node repositioning persistence** — that's Story 2.9. Just support the `position` field check to skip nodes with manual overrides
- **DO NOT break existing tests** — run full test suite after changes
### Previous Story Intelligence (Story 2.1)
**Key learnings to carry forward:**
- `useGraphStore` manages nodes, edges, viewport as `Node[]`/`Edge[]` from @xyflow/react — layout results must produce `Node[]` updates compatible with this store
- `graphToFlow()` converts stored DiagramNode (with `from`/`to` edges) to @xyflow format (with `source`/`target`) — ELK works with the stored format (`from`/`to``sources`/`targets`), then positions map back to @xyflow format
- DiagramCanvas uses `ReactFlowProvider` wrapping `ReactFlow` — any hooks using `useReactFlow()` must be inside this provider
- `initializeFromGraphData(nodes, edges)` is called in `DiagramEditor.tsx` useEffect — layout should trigger AFTER this initialization
- `colorMode="system"` is set on ReactFlow — dark mode works automatically
- `nodeTypes` object is defined OUTSIDE the component (performance critical) — any new node types must follow this pattern
- 166 tests currently pass (141 original + 25 from Story 2.1) — don't break them
- `sonner` toast is used for user feedback — use it for the 200-node soft cap warning
- `DiagramMeta` already has `layoutDirection` and `edgeRouting` fields — read these from diagram data on initialization
- Code review fix from 2.1: `vitest.config.ts` extends `@turbostarter/vitest-config/base` via `mergeConfig()`
### Git Intelligence
Recent commits:
- `5033109 feat: implement Story 2.1 — canvas workspace with @xyflow/react and unified graph model`
- `098f496 feat: implement Story 1.4 — recent view and drag-and-drop organization`
Established patterns:
- Commit message: `feat: implement Story X.Y — description`
- Feature code in `apps/web/src/modules/diagram/`
- Co-located tests next to source files
- `DropdownMenu` from shadcn/ui for selection controls
- `diagramTypeConfig` object for diagram type metadata
### Latest Tech Information
**elkjs 0.11.0 (latest stable, September 2025):**
- Based on ELK 0.11.0 layout framework
- Ships with TypeScript types (`ElkNode`, `ElkEdge`, `LayoutOptions`)
- Three builds: `elk.bundled.js` (single file, main thread or worker), `elk-api.js` (API only), `elk-worker.min.js` (layout algorithm)
- `elk.layout(graph)` returns a Promise that resolves to the same graph structure with computed positions
- All layout option values must be strings
- License: EPL 2.0 (safe for commercial SaaS)
**Next.js 16 + Turbopack Web Worker support:**
- Turbopack supports `new Worker(new URL('./worker.ts', import.meta.url), { type: 'module' })` syntax
- Worker files are bundled separately at build time
- TypeScript workers work directly — no need for separate tsconfig or compilation step
**@xyflow/react 12.10.1 node positioning:**
- Nodes use `position: { x, y }` for absolute positioning
- `node.measured.width` / `node.measured.height` for actual DOM dimensions (after rendering)
- `setNodes()` triggers re-render with new positions
- No built-in animation for position changes — must use CSS transitions
- `fitView()` available via `useReactFlow()` hook to auto-zoom to fit all nodes after layout
### References
- [Source: _bmad-output/planning-artifacts/epics.md#Story 2.2] — Full AC and technical notes
- [Source: _bmad-output/planning-artifacts/architecture.md#Decision 1] — Unified Graph Data Model (hybrid schema, no x/y stored)
- [Source: _bmad-output/planning-artifacts/architecture.md#Flexicar Prototype] — Lean JSON data model, ELK.js patterns from Flexicar
- [Source: _bmad-output/planning-artifacts/architecture.md#Enforcement Guidelines] — 7 mandatory rules
- [Source: _bmad-output/planning-artifacts/architecture.md#Implementation Patterns] — Naming, structure rules
- [Source: _bmad-output/planning-artifacts/epics.md#NFR2] — ELK layout < 500ms for < 50 nodes
- [Source: _bmad-output/implementation-artifacts/2-1-canvas-workspace-with-xyflow-react-and-unified-graph-model.md] — Previous story: Zustand store shape, graph converter, DiagramCanvas structure
- [Source: _bmad-output/project-context.md] — 62 critical implementation rules
- [Source: apps/web/src/modules/diagram/types/graph.ts] — DiagramMeta.layoutDirection and DiagramMeta.edgeRouting types
- [Source: apps/web/src/modules/diagram/stores/useGraphStore.ts] — Current store shape to extend
- [Source: apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx] — Canvas to integrate layout hook
- [Source: apps/web/src/modules/diagram/components/editor/EditorStatusBar.tsx] — Status bar to add direction controls
- [Source: elkjs@0.11.0 npm] — Latest stable version, Web Worker support
- [Source: eclipse.dev/elk/reference/algorithms/org-eclipse-elk-layered.html] — Layered algorithm options
- [Source: reactflow.dev/examples/layout/elkjs] — Official @xyflow/react + ELK example
## Dev Agent Record
### Agent Model Used
Claude Opus 4.6 (claude-opus-4-6)
### Debug Log References
- Fixed `pnpm install` failure: sherif workspace lint required alphabetically sorted devDependencies in `apps/web/package.json`. Moved `@turbostarter/vitest-config` and `vitest` to correct alphabetical positions.
- Used `elk.bundled.js` in custom Web Worker instead of `workerFactory` pattern (nested workers have compatibility issues with Turbopack).
- `triggerLayout` exposed via `useAutoLayout` hook return value (not on store) since it depends on React context (`useReactFlow`).
### Completion Notes List
- All 6 tasks complete with 50 tests passing (18 elk-layout + 15 graph-converter + 17 store)
- TypeScript typecheck passes clean
- Layout animation uses CSS class toggling (`.layouting` class) — only active during layout, not during drag
- Worker singleton pattern with `getWorker()` / `terminateWorker()` for lifecycle management
- DiagramEditor initializes layout settings from `graphData.meta.layoutDirection` and `graphData.meta.edgeRouting`
- Task 4.2 deviation: `triggerLayout` returned from hook, not exposed on store — cleaner since it needs React context
### Code Review Fixes Applied (Claude Opus 4.6)
- **H1 (Race condition):** Added single-flight pattern to `computeLayout` — cancels in-flight layout before starting a new one via `pendingReject` + `settled` flag
- **H2 (Performance):** Replaced `nodes`/`edges` subscriptions in `useAutoLayout` with `nodeCount` selector; removed unused `edges` subscription
- **H3 (AC#2 loading indicator):** Loading indicator now only appears after 200ms delay via `setTimeout`, matching AC#2 requirement
- **M1 (Manual override):** Changed `resolvePositions` to check `data.manuallyPositioned` flag instead of `data.position !== undefined`; added `manuallyPositioned?: boolean` to DiagramNode type
- **M2 (Timeout):** Added 10s timeout to `computeLayout` Promise — rejects with "ELK layout timed out" if worker hangs
- **M3 (Test quality):** Added test for `manuallyPositioned` flag behavior + test proving `position` field alone does NOT trigger skip
- **L1/L2 (Low):** Accepted as-is — DOM class toggling is the recommended @xyflow pattern; eslint-disable is safe with stable Zustand selectors
### File List
**New files:**
- `apps/web/src/modules/diagram/lib/elk-worker.ts` — Web Worker running ELK layout engine
- `apps/web/src/modules/diagram/lib/elk-layout.ts` — Layout module: buildElkGraph, resolvePositions, computeLayout, worker management
- `apps/web/src/modules/diagram/lib/elk-layout.test.ts` — 18 unit tests for layout functions
- `apps/web/src/modules/diagram/hooks/useAutoLayout.ts` — React hook with debouncing, animation, soft cap warning
**Modified files:**
- `apps/web/package.json` — Added `elkjs@0.11.0` dependency, fixed devDependencies sort order
- `apps/web/src/modules/diagram/types/graph.ts` — Added `manuallyPositioned?: boolean` to DiagramNode (code review fix M1)
- `apps/web/src/modules/diagram/stores/useGraphStore.ts` — Added layoutDirection, edgeRouting, isLayouting state + setters
- `apps/web/src/modules/diagram/stores/useGraphStore.test.ts` — Added 7 layout state tests
- `apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx` — Wired useAutoLayout hook, added loading Panel
- `apps/web/src/modules/diagram/components/editor/EditorStatusBar.tsx` — Added direction/routing dropdown controls
- `apps/web/src/modules/diagram/components/editor/DiagramEditor.tsx` — Initialize layout settings from diagram metadata
- `apps/web/src/assets/styles/globals.css` — Added `.react-flow__node.layouting` animation CSS
- `pnpm-lock.yaml` — Updated lockfile

View File

@@ -51,7 +51,7 @@ development_status:
# ── Epic 2: Interactive Canvas & Diagram Types (Phase 2) ── # ── Epic 2: Interactive Canvas & Diagram Types (Phase 2) ──
epic-2: in-progress epic-2: in-progress
2-1-canvas-workspace-with-xyflow-react-and-unified-graph-model: done 2-1-canvas-workspace-with-xyflow-react-and-unified-graph-model: done
2-2-elk-js-auto-layout-engine-in-web-worker: backlog 2-2-elk-js-auto-layout-engine-in-web-worker: done
2-3-bpmn-diagram-type-renderer: backlog 2-3-bpmn-diagram-type-renderer: backlog
2-4-entity-relationship-diagram-type-renderer: backlog 2-4-entity-relationship-diagram-type-renderer: backlog
2-5-org-chart-diagram-type-renderer: backlog 2-5-org-chart-diagram-type-renderer: backlog

View File

@@ -43,6 +43,7 @@
"@xyflow/react": "12.10.1", "@xyflow/react": "12.10.1",
"accept-language": "3.0.20", "accept-language": "3.0.20",
"dayjs": "1.11.19", "dayjs": "1.11.19",
"elkjs": "0.11.0",
"envin": "catalog:", "envin": "catalog:",
"marked": "16.4.1", "marked": "16.4.1",
"motion": "12.23.24", "motion": "12.23.24",
@@ -73,6 +74,7 @@
"@turbostarter/eslint-config": "workspace:*", "@turbostarter/eslint-config": "workspace:*",
"@turbostarter/prettier-config": "workspace:*", "@turbostarter/prettier-config": "workspace:*",
"@turbostarter/tsconfig": "workspace:*", "@turbostarter/tsconfig": "workspace:*",
"@turbostarter/vitest-config": "workspace:*",
"@types/node": "catalog:node22", "@types/node": "catalog:node22",
"@types/react": "catalog:react19", "@types/react": "catalog:react19",
"@types/react-dom": "catalog:react19", "@types/react-dom": "catalog:react19",
@@ -80,7 +82,6 @@
"eslint": "catalog:", "eslint": "catalog:",
"prettier": "catalog:", "prettier": "catalog:",
"typescript": "catalog:", "typescript": "catalog:",
"@turbostarter/vitest-config": "workspace:*",
"vitest": "catalog:" "vitest": "catalog:"
} }
} }

View File

@@ -39,4 +39,9 @@
--edge-default: oklch(0.55 0.01 286); --edge-default: oklch(0.55 0.01 286);
--edge-selected: oklch(0.623 0.214 260); --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;
}
} }

View File

@@ -7,9 +7,11 @@ import {
Controls, Controls,
MiniMap, MiniMap,
BackgroundVariant, BackgroundVariant,
Panel,
} from "@xyflow/react"; } from "@xyflow/react";
import { useGraphStore } from "../../stores/useGraphStore"; import { useGraphStore } from "../../stores/useGraphStore";
import { useAutoLayout } from "../../hooks/useAutoLayout";
const nodeTypes = {}; const nodeTypes = {};
@@ -20,6 +22,8 @@ function CanvasInner() {
const onEdgesChange = useGraphStore((s) => s.onEdgesChange); const onEdgesChange = useGraphStore((s) => s.onEdgesChange);
const onViewportChange = useGraphStore((s) => s.onViewportChange); const onViewportChange = useGraphStore((s) => s.onViewportChange);
const { isLayouting } = useAutoLayout();
return ( return (
<div className="w-full h-full"> <div className="w-full h-full">
<ReactFlow <ReactFlow
@@ -45,6 +49,13 @@ function CanvasInner() {
zoomable zoomable
style={{ width: 120, height: 80 }} 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> </ReactFlow>
</div> </div>
); );

View File

@@ -27,6 +27,8 @@ export function DiagramEditor({ diagram }: DiagramEditorProps) {
const initializeFromGraphData = useGraphStore( const initializeFromGraphData = useGraphStore(
(s) => s.initializeFromGraphData, (s) => s.initializeFromGraphData,
); );
const setLayoutDirection = useGraphStore((s) => s.setLayoutDirection);
const setEdgeRouting = useGraphStore((s) => s.setEdgeRouting);
const resetStore = useGraphStore((s) => s.reset); const resetStore = useGraphStore((s) => s.reset);
// Initialize graph store from diagram data; reset on unmount // Initialize graph store from diagram data; reset on unmount
@@ -39,8 +41,17 @@ export function DiagramEditor({ diagram }: DiagramEditorProps) {
}; };
const { nodes, edges } = graphToFlow(graphData); const { nodes, edges } = graphToFlow(graphData);
initializeFromGraphData(nodes, edges); 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(); return () => resetStore();
}, [diagram.id, diagram.graphData, initializeFromGraphData, resetStore]); }, [diagram.id, diagram.graphData, initializeFromGraphData, setLayoutDirection, setEdgeRouting, resetStore]);
// Keyboard shortcuts // Keyboard shortcuts
useEffect(() => { useEffect(() => {

View File

@@ -5,6 +5,20 @@ import { diagramTypeConfig } from "../DiagramCard";
import { useGraphStore } from "../../stores/useGraphStore"; import { useGraphStore } from "../../stores/useGraphStore";
import type { DiagramType } from "../../types/graph"; 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 { interface EditorStatusBarProps {
diagramType: DiagramType; diagramType: DiagramType;
@@ -13,6 +27,10 @@ interface EditorStatusBarProps {
export function EditorStatusBar({ diagramType }: EditorStatusBarProps) { export function EditorStatusBar({ diagramType }: EditorStatusBarProps) {
const zoomLevel = useGraphStore((s) => s.zoomLevel); const zoomLevel = useGraphStore((s) => s.zoomLevel);
const nodeCount = useGraphStore((s) => s.nodeCount); 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 config = diagramTypeConfig[diagramType];
const TypeIcon = config.icon; const TypeIcon = config.icon;
@@ -30,6 +48,38 @@ export function EditorStatusBar({ diagramType }: EditorStatusBarProps) {
</span> </span>
</div> </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-1" />
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">

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 };
}

View 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);
});
});

View 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 });
});
}

View 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);
}
};

View File

@@ -18,13 +18,7 @@ const makeEdge = (id: string, source: string, target: string): Edge => ({
describe("useGraphStore", () => { describe("useGraphStore", () => {
beforeEach(() => { beforeEach(() => {
useGraphStore.setState({ useGraphStore.getState().reset();
nodes: [],
edges: [],
viewport: { x: 0, y: 0, zoom: 1 },
nodeCount: 0,
zoomLevel: 100,
});
}); });
describe("initializeFromGraphData", () => { describe("initializeFromGraphData", () => {
@@ -121,4 +115,45 @@ describe("useGraphStore", () => {
expect(useGraphStore.getState().zoomLevel).toBe(100); 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);
});
});
}); });

View File

@@ -11,17 +11,25 @@ import type {
Viewport, Viewport,
} from "@xyflow/react"; } from "@xyflow/react";
import type { LayoutDirection, EdgeRouting } from "../lib/elk-layout";
interface GraphState { interface GraphState {
nodes: Node[]; nodes: Node[];
edges: Edge[]; edges: Edge[];
viewport: Viewport; viewport: Viewport;
nodeCount: number; nodeCount: number;
zoomLevel: number; zoomLevel: number;
layoutDirection: LayoutDirection;
edgeRouting: EdgeRouting;
isLayouting: boolean;
setNodes: (nodes: Node[]) => void; setNodes: (nodes: Node[]) => void;
setEdges: (edges: Edge[]) => void; setEdges: (edges: Edge[]) => void;
onNodesChange: OnNodesChange; onNodesChange: OnNodesChange;
onEdgesChange: OnEdgesChange; onEdgesChange: OnEdgesChange;
onViewportChange: (viewport: Viewport) => void; onViewportChange: (viewport: Viewport) => void;
setLayoutDirection: (direction: LayoutDirection) => void;
setEdgeRouting: (routing: EdgeRouting) => void;
setIsLayouting: (isLayouting: boolean) => void;
initializeFromGraphData: (nodes: Node[], edges: Edge[]) => void; initializeFromGraphData: (nodes: Node[], edges: Edge[]) => void;
reset: () => void; reset: () => void;
} }
@@ -32,6 +40,9 @@ export const useGraphStore = create<GraphState>((set, get) => ({
viewport: { x: 0, y: 0, zoom: 1 }, viewport: { x: 0, y: 0, zoom: 1 },
nodeCount: 0, nodeCount: 0,
zoomLevel: 100, zoomLevel: 100,
layoutDirection: "DOWN",
edgeRouting: "ORTHOGONAL",
isLayouting: false,
setNodes: (nodes) => set({ nodes, nodeCount: nodes.length }), setNodes: (nodes) => set({ nodes, nodeCount: nodes.length }),
setEdges: (edges) => set({ edges }), setEdges: (edges) => set({ edges }),
@@ -49,6 +60,10 @@ export const useGraphStore = create<GraphState>((set, get) => ({
set({ viewport, zoomLevel: Math.round(viewport.zoom * 100) }); set({ viewport, zoomLevel: Math.round(viewport.zoom * 100) });
}, },
setLayoutDirection: (layoutDirection) => set({ layoutDirection }),
setEdgeRouting: (edgeRouting) => set({ edgeRouting }),
setIsLayouting: (isLayouting) => set({ isLayouting }),
initializeFromGraphData: (nodes, edges) => { initializeFromGraphData: (nodes, edges) => {
set({ nodes, edges, nodeCount: nodes.length }); set({ nodes, edges, nodeCount: nodes.length });
}, },
@@ -60,6 +75,9 @@ export const useGraphStore = create<GraphState>((set, get) => ({
viewport: { x: 0, y: 0, zoom: 1 }, viewport: { x: 0, y: 0, zoom: 1 },
nodeCount: 0, nodeCount: 0,
zoomLevel: 100, zoomLevel: 100,
layoutDirection: "DOWN",
edgeRouting: "ORTHOGONAL",
isLayouting: false,
}); });
}, },
})); }));

View File

@@ -25,6 +25,7 @@ export interface DiagramNode {
color?: string; color?: string;
w?: number; w?: number;
position?: { x: number; y: number }; position?: { x: number; y: number };
manuallyPositioned?: boolean;
lane?: string; lane?: string;
group?: string; group?: string;
columns?: Column[]; columns?: Column[];

8
pnpm-lock.yaml generated
View File

@@ -448,6 +448,9 @@ importers:
dayjs: dayjs:
specifier: 1.11.19 specifier: 1.11.19
version: 1.11.19 version: 1.11.19
elkjs:
specifier: 0.11.0
version: 0.11.0
envin: envin:
specifier: 'catalog:' specifier: 'catalog:'
version: 1.1.10(arktype@2.1.20)(typescript@5.9.3)(zod@4.1.13) version: 1.1.10(arktype@2.1.20)(typescript@5.9.3)(zod@4.1.13)
@@ -10076,6 +10079,9 @@ packages:
electron-to-chromium@1.5.182: electron-to-chromium@1.5.182:
resolution: {integrity: sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==} resolution: {integrity: sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==}
elkjs@0.11.0:
resolution: {integrity: sha512-u4J8h9mwEDaYMqo0RYJpqNMFDoMK7f+pu4GjcV+N8jIC7TRdORgzkfSjTJemhqONFfH6fBI3wpysgWbhgVWIXw==}
emoji-regex@10.4.0: emoji-regex@10.4.0:
resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==}
@@ -25445,6 +25451,8 @@ snapshots:
electron-to-chromium@1.5.182: {} electron-to-chromium@1.5.182: {}
elkjs@0.11.0: {}
emoji-regex@10.4.0: {} emoji-regex@10.4.0: {}
emoji-regex@8.0.0: {} emoji-regex@8.0.0: {}