From 7dd5af17ac385f317e1fbb821be281c0b7b36601 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Guti=C3=A9rrez?= <35082514+alezmad@users.noreply.github.com> Date: Tue, 24 Feb 2026 19:24:37 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20implement=20Story=202.2=20=E2=80=94=20E?= =?UTF-8?q?LK.js=20auto-layout=20engine=20in=20Web=20Worker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...elk-js-auto-layout-engine-in-web-worker.md | 495 ++++++++++++++++++ .../sprint-status.yaml | 2 +- apps/web/package.json | 3 +- apps/web/src/assets/styles/globals.css | 5 + .../components/editor/DiagramCanvas.tsx | 11 + .../components/editor/DiagramEditor.tsx | 13 +- .../components/editor/EditorStatusBar.tsx | 50 ++ .../modules/diagram/hooks/useAutoLayout.ts | 149 ++++++ .../modules/diagram/lib/elk-layout.test.ts | 236 +++++++++ .../web/src/modules/diagram/lib/elk-layout.ts | 176 +++++++ .../web/src/modules/diagram/lib/elk-worker.ts | 33 ++ .../diagram/stores/useGraphStore.test.ts | 49 +- .../modules/diagram/stores/useGraphStore.ts | 18 + apps/web/src/modules/diagram/types/graph.ts | 1 + pnpm-lock.yaml | 8 + 15 files changed, 1239 insertions(+), 10 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/2-2-elk-js-auto-layout-engine-in-web-worker.md create mode 100644 apps/web/src/modules/diagram/hooks/useAutoLayout.ts create mode 100644 apps/web/src/modules/diagram/lib/elk-layout.test.ts create mode 100644 apps/web/src/modules/diagram/lib/elk-layout.ts create mode 100644 apps/web/src/modules/diagram/lib/elk-worker.ts diff --git a/_bmad-output/implementation-artifacts/2-2-elk-js-auto-layout-engine-in-web-worker.md b/_bmad-output/implementation-artifacts/2-2-elk-js-auto-layout-engine-in-web-worker.md new file mode 100644 index 0000000..037b81c --- /dev/null +++ b/_bmad-output/implementation-artifacts/2-2-elk-js-auto-layout-engine-in-web-worker.md @@ -0,0 +1,495 @@ +# Story 2.2: ELK.js Auto-Layout Engine in Web Worker + +Status: done + + + +## 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(); + 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` 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 diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index c490384..eed3f63 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -51,7 +51,7 @@ development_status: # ── Epic 2: Interactive Canvas & Diagram Types (Phase 2) ── epic-2: in-progress 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-4-entity-relationship-diagram-type-renderer: backlog 2-5-org-chart-diagram-type-renderer: backlog diff --git a/apps/web/package.json b/apps/web/package.json index bf54766..86c1f12 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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:" } } diff --git a/apps/web/src/assets/styles/globals.css b/apps/web/src/assets/styles/globals.css index 92de7c9..ec6b060 100644 --- a/apps/web/src/assets/styles/globals.css +++ b/apps/web/src/assets/styles/globals.css @@ -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; + } } diff --git a/apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx b/apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx index b0fa643..b3d450d 100644 --- a/apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx +++ b/apps/web/src/modules/diagram/components/editor/DiagramCanvas.tsx @@ -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 (
+ {isLayouting && ( + +
+ Computing layout... +
+
+ )}
); diff --git a/apps/web/src/modules/diagram/components/editor/DiagramEditor.tsx b/apps/web/src/modules/diagram/components/editor/DiagramEditor.tsx index 3f8e67a..7b490b9 100644 --- a/apps/web/src/modules/diagram/components/editor/DiagramEditor.tsx +++ b/apps/web/src/modules/diagram/components/editor/DiagramEditor.tsx @@ -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(() => { diff --git a/apps/web/src/modules/diagram/components/editor/EditorStatusBar.tsx b/apps/web/src/modules/diagram/components/editor/EditorStatusBar.tsx index ecbca15..8e43d43 100644 --- a/apps/web/src/modules/diagram/components/editor/EditorStatusBar.tsx +++ b/apps/web/src/modules/diagram/components/editor/EditorStatusBar.tsx @@ -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 = { + DOWN: "Top to Bottom", + RIGHT: "Left to Right", + UP: "Bottom to Top", + LEFT: "Right to Left", +}; + +const ROUTING_LABELS: Record = { + 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) { +
+ +
+ +
+ +
+
diff --git a/apps/web/src/modules/diagram/hooks/useAutoLayout.ts b/apps/web/src/modules/diagram/hooks/useAutoLayout.ts new file mode 100644 index 0000000..f6d5d26 --- /dev/null +++ b/apps/web/src/modules/diagram/hooks/useAutoLayout.ts @@ -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 | 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) => { + 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(".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(".react-flow__node"); + flowNodesFinal.forEach((el) => el.classList.remove("layouting")); + setIsLayouting(false); + }, LAYOUT_ANIMATION_MS); + } + }, + [setNodes, setIsLayouting, fitView], + ); + + const triggerLayout = useCallback( + (options?: Partial) => { + 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 }; +} diff --git a/apps/web/src/modules/diagram/lib/elk-layout.test.ts b/apps/web/src/modules/diagram/lib/elk-layout.test.ts new file mode 100644 index 0000000..bba2587 --- /dev/null +++ b/apps/web/src/modules/diagram/lib/elk-layout.test.ts @@ -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 = {}, +): 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); + }); +}); diff --git a/apps/web/src/modules/diagram/lib/elk-layout.ts b/apps/web/src/modules/diagram/lib/elk-layout.ts new file mode 100644 index 0000000..09c3612 --- /dev/null +++ b/apps/web/src/modules/diagram/lib/elk-layout.ts @@ -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 = {}, +): 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(); + 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 = {}, +): Promise { + 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) => { + 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 }); + }); +} diff --git a/apps/web/src/modules/diagram/lib/elk-worker.ts b/apps/web/src/modules/diagram/lib/elk-worker.ts new file mode 100644 index 0000000..b9aa440 --- /dev/null +++ b/apps/web/src/modules/diagram/lib/elk-worker.ts @@ -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) => { + 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); + } +}; diff --git a/apps/web/src/modules/diagram/stores/useGraphStore.test.ts b/apps/web/src/modules/diagram/stores/useGraphStore.test.ts index 70639a6..68da482 100644 --- a/apps/web/src/modules/diagram/stores/useGraphStore.test.ts +++ b/apps/web/src/modules/diagram/stores/useGraphStore.test.ts @@ -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); + }); + }); }); diff --git a/apps/web/src/modules/diagram/stores/useGraphStore.ts b/apps/web/src/modules/diagram/stores/useGraphStore.ts index 4dcc518..64e1796 100644 --- a/apps/web/src/modules/diagram/stores/useGraphStore.ts +++ b/apps/web/src/modules/diagram/stores/useGraphStore.ts @@ -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((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((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((set, get) => ({ viewport: { x: 0, y: 0, zoom: 1 }, nodeCount: 0, zoomLevel: 100, + layoutDirection: "DOWN", + edgeRouting: "ORTHOGONAL", + isLayouting: false, }); }, })); diff --git a/apps/web/src/modules/diagram/types/graph.ts b/apps/web/src/modules/diagram/types/graph.ts index 6f2aaaf..bd54053 100644 --- a/apps/web/src/modules/diagram/types/graph.ts +++ b/apps/web/src/modules/diagram/types/graph.ts @@ -25,6 +25,7 @@ export interface DiagramNode { color?: string; w?: number; position?: { x: number; y: number }; + manuallyPositioned?: boolean; lane?: string; group?: string; columns?: Column[]; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d2c386..2719b6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -448,6 +448,9 @@ importers: dayjs: specifier: 1.11.19 version: 1.11.19 + elkjs: + specifier: 0.11.0 + version: 0.11.0 envin: specifier: 'catalog:' 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: resolution: {integrity: sha512-Lv65Btwv9W4J9pyODI6EWpdnhfvrve/us5h1WspW8B2Fb0366REPtY3hX7ounk1CkV/TBjWCEvCBBbYbmV0qCA==} + elkjs@0.11.0: + resolution: {integrity: sha512-u4J8h9mwEDaYMqo0RYJpqNMFDoMK7f+pu4GjcV+N8jIC7TRdORgzkfSjTJemhqONFfH6fBI3wpysgWbhgVWIXw==} + emoji-regex@10.4.0: resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} @@ -25445,6 +25451,8 @@ snapshots: electron-to-chromium@1.5.182: {} + elkjs@0.11.0: {} + emoji-regex@10.4.0: {} emoji-regex@8.0.0: {}