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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user