Files
turbostarter/_bmad-output/implementation-artifacts/2-2-elk-js-auto-layout-engine-in-web-worker.md
Alejandro Gutiérrez 7dd5af17ac 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>
2026-02-24 19:24:37 +00:00

26 KiB

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

  • Task 1: Install elkjs and configure Web Worker (AC: #1, #2)

    • 1.1: Install elkjs@0.11.0 in apps/web
    • 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
    • 1.3: Verify Web Worker loads correctly in Next.js 16 with Turbopack
  • Task 2: Create ELK layout engine module (AC: #1, #4, #5)

    • 2.1: Create apps/web/src/modules/diagram/lib/elk-layout.ts — main layout module with computeLayout(nodes, edges, options) function
    • 2.2: Implement buildElkGraph() — converts DiagramNode[]/DiagramEdge[] into ELK graph format (children with width/height, edges with sources/targets)
    • 2.3: Implement resolvePositions() — maps ELK output coordinates back to @xyflow/react Node positions
    • 2.4: Support layout options: direction (DOWN/RIGHT/LEFT/UP), edge routing (ORTHOGONAL/SPLINES/POLYLINE), spacing
    • 2.5: Implement manual position override logic — skip ELK positioning for nodes where node.data.position was set by user drag
  • Task 3: Create useAutoLayout hook (AC: #1, #2, #3, #4)

    • 3.1: Create apps/web/src/modules/diagram/hooks/useAutoLayout.ts — React hook wrapping the layout engine with debouncing (300ms)
    • 3.2: Add isLayouting state for loading indicator
    • 3.3: Add 200-node soft cap warning via toast() from sonner
    • 3.4: Implement smooth node animation — apply new positions with CSS transition (200ms ease-out) via @xyflow/react's setNodes with position updates
  • Task 4: Integrate auto-layout into DiagramCanvas (AC: #1, #2, #4)

    • 4.1: Wire useAutoLayout into DiagramCanvas.tsx — trigger layout on initial load when diagram has nodes
    • 4.2: Expose triggerLayout() function via useAutoLayout hook for manual trigger and future AI mutation integration
    • 4.3: Add subtle loading overlay when isLayouting is true (Panel component with loading indicator)
  • Task 5: Add layout direction controls to EditorStatusBar (AC: #4)

    • 5.1: Add layout direction dropdown to EditorStatusBar.tsx — selector for DOWN/RIGHT/LEFT/UP
    • 5.2: Add edge routing dropdown — selector for ORTHOGONAL/SPLINES/POLYLINE
    • 5.3: Store layout direction and edge routing in useGraphStore as layoutDirection and edgeRouting state
    • 5.4: Direction/routing changes trigger re-layout via useAutoLayout
  • Task 6: Tests (AC: all)

    • 6.1: Unit tests for buildElkGraph() — converting diagram nodes/edges to ELK format (10 tests)
    • 6.2: Unit tests for resolvePositions() — mapping ELK output back to @xyflow positions (6 tests)
    • 6.3: Unit tests for layout options (direction, edge routing, spacing) — covered in buildElkGraph tests
    • 6.4: Unit test for SOFT_CAP_NODE_COUNT constant (1 test)
    • 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.

// 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:

// 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:

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:

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:
.react-flow__node {
  transition: transform 200ms ease-out;
}
  1. 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:

.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:

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):

import ELK from 'elkjs/lib/elk.bundled.js';

Import for main thread API only (if using workerFactory):

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
  • LayoutOptionsRecord<string, string> for ELK options

ELK graph input format:

{
  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/tosources/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