@nodius/layouting 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +1092 -122
  3. package/dist/algorithms/component-packing.d.ts +9 -0
  4. package/dist/algorithms/component-packing.d.ts.map +1 -0
  5. package/dist/algorithms/coordinate-assignment.d.ts +7 -0
  6. package/dist/algorithms/coordinate-assignment.d.ts.map +1 -0
  7. package/dist/algorithms/crossing-minimization.d.ts +11 -0
  8. package/dist/algorithms/crossing-minimization.d.ts.map +1 -0
  9. package/dist/algorithms/cycle-breaking.d.ts +8 -0
  10. package/dist/algorithms/cycle-breaking.d.ts.map +1 -0
  11. package/dist/algorithms/edge-routing.d.ts +17 -0
  12. package/dist/algorithms/edge-routing.d.ts.map +1 -0
  13. package/dist/algorithms/layer-assignment.d.ts +20 -0
  14. package/dist/algorithms/layer-assignment.d.ts.map +1 -0
  15. package/dist/algorithms/value-cluster.d.ts +15 -0
  16. package/dist/algorithms/value-cluster.d.ts.map +1 -0
  17. package/dist/algorithms/value-placement.d.ts +25 -0
  18. package/dist/algorithms/value-placement.d.ts.map +1 -0
  19. package/dist/debug.d.ts +20 -0
  20. package/dist/debug.d.ts.map +1 -0
  21. package/dist/graph.d.ts +50 -0
  22. package/dist/graph.d.ts.map +1 -0
  23. package/dist/incremental.d.ts +33 -0
  24. package/dist/incremental.d.ts.map +1 -0
  25. package/dist/index.d.ts +7 -176
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +1159 -234
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +1156 -233
  30. package/dist/index.mjs.map +1 -1
  31. package/dist/layout.d.ts +10 -0
  32. package/dist/layout.d.ts.map +1 -0
  33. package/dist/proposals.d.ts +44 -0
  34. package/dist/proposals.d.ts.map +1 -0
  35. package/dist/types.d.ts +214 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/package.json +5 -4
  38. package/dist/index.d.mts +0 -176
package/README.md CHANGED
@@ -1,145 +1,952 @@
1
- # nodius-layouting
1
+ # @nodius/layouting
2
2
 
3
- A **zero-dependency**, high-performance graph layouting library for node-based technical diagrams.
3
+ A **zero-dependency**, high-performance graph layouting library for node-based
4
+ technical diagrams.
4
5
 
5
- Built for real-world use cases: data pipelines, visual programming environments, workflow editors, and any system where nodes with typed handles are connected by edges.
6
+ Built for real-world use cases: data pipelines, visual programming
7
+ environments, workflow editors, and any system where typed-handle nodes are
8
+ connected by typed edges, with parent/child grouping and side-attached values.
9
+
10
+ > **The killer feature.** Most layout libraries pick one axis (vertical OR
11
+ > horizontal). `@nodius/layouting` mixes both *within the same layout*: the
12
+ > execution rail runs along the chosen axis (e.g. top→bottom), while value
13
+ > nodes — your constants, configs, imports — automatically attach to the
14
+ > **perpendicular** flanks of the node that consumes them. You get a tight,
15
+ > readable 2D layout instead of a long ribbon. See
16
+ > [Mixed orientation](#mixed-orientation-rail--sidecars).
17
+
18
+ ---
19
+
20
+ ## Table of contents
21
+
22
+ - [Features](#features)
23
+ - [Performance](#performance)
24
+ - [Installation](#installation)
25
+ - [Quick start](#quick-start)
26
+ - [Concepts](#concepts)
27
+ - [Mixed orientation: rail + sidecars](#mixed-orientation-rail--sidecars)
28
+ - [Typed edges](#typed-edges-control-vs-data)
29
+ - [Value nodes & sidecars](#value-nodes--sidecars)
30
+ - [Compound (nested) layout](#compound-nested-layout)
31
+ - [Component packing](#component-packing)
32
+ - [Handle proposals (`onProposal`)](#handle-proposals-onproposal)
33
+ - [Rotate vs relocate](#rotate-vs-relocate)
34
+ - [Strategic per-handle placement (`relocate-handles`)](#strategic-per-handle-placement-relocate-handles)
35
+ - [Cookbook — recipes for common patterns](#cookbook--recipes-for-common-patterns)
36
+ - [API](#api)
37
+ - [Types](#types)
38
+ - [Algorithm internals](#algorithm-internals)
39
+ - [Debugging](#debugging)
40
+ - [Playground](#playground)
41
+ - [Development](#development)
42
+
43
+ ---
6
44
 
7
45
  ## Features
8
46
 
9
- - **Zero runtime dependencies** - Pure TypeScript, nothing else
10
- - **Optimized Sugiyama-based layout** - Produces clean, hierarchical technical diagrams
11
- - **Handle-aware** - Each node has multiple handles (input/output) with precise positioning on any side (top, right, bottom, left) with configurable offsets
12
- - **Orthogonal edge routing** - Clean right-angle edge paths that respect handle positions
13
- - **Incremental layout** - Add or remove nodes without recomputing the entire layout
14
- - **4 layout directions** - Top-to-bottom, left-to-right, bottom-to-top, right-to-left
15
- - **Cycle support** - Automatically handles cyclic graphs
16
- - **Scales to 1000+ nodes** - Optimized algorithms (merge-sort crossing count, adaptive iterations)
47
+ - **Zero runtime dependencies** pure TypeScript, nothing else.
48
+ - **Strict-axis Sugiyama** the chosen reading direction (`TB`/`LR`/`BT`/`RL`)
49
+ is honored end to end. Compacity comes from packing and sidecars, never from
50
+ silent local re-orientation.
51
+ - **Typed edges** `control` defines the execution rail; `data` is a weak
52
+ link that pulls value nodes onto the consumer's layer.
53
+ - **Mixed-orientation layout** main rail along one axis, value sidecars on
54
+ the perpendicular flanks. See dedicated section below.
55
+ - **Compound layout** — nodes can declare `parentId`; children are laid out
56
+ inside their parent's bounding box, recursively, at any depth.
57
+ - **Sidecar value placement** — value nodes (only data edges) are attached to
58
+ the flanks of their dominant consumer; the side is picked from the
59
+ consumer's handle position.
60
+ - **Component packing** — disjoint components are packed along the order axis
61
+ for a square-ish aspect ratio.
62
+ - **Handle proposals** — when handle orientation doesn't match the planned
63
+ placement, the engine asks the application via `onProposal` whether to fix
64
+ it. Two flavors are emitted:
65
+ - `rotate` — rotate every handle on the node by 90°/-90°/180°.
66
+ - `relocate-handles` — strategically move **each handle individually** to
67
+ the side that points toward its actual neighbor (computed from a real
68
+ layout preview). The app accepts, modifies, or rejects.
69
+ - **Handle-aware** — every node has multiple input/output handles with
70
+ `top`/`right`/`bottom`/`left` positions and `offset` along the side.
71
+ - **Orthogonal edge routing** through dummy waypoints.
72
+ - **Incremental layout** — `IncrementalLayout` keeps position stability across
73
+ edits (existing positions are blended with the new ones).
74
+ - **Cycle support** — back edges are detected and reversed automatically.
75
+ - **Scales** — ~150 ms for 1 000 nodes; merge-sort based crossing counting;
76
+ adaptive iteration cap on large graphs.
17
77
 
18
78
  ## Performance
19
79
 
20
- Benchmarked on a standard development machine:
80
+ Measured on a standard dev machine (Node 24, single-threaded JS):
21
81
 
22
- | Graph Size | Time |
23
- |---|---|
24
- | 100 nodes, 737 edges (dense) | ~70ms |
25
- | 200 nodes | ~25ms |
26
- | 500 nodes | ~100ms |
27
- | 1000 nodes | ~150ms |
82
+ | Graph size | balanced | draft |
83
+ |----------------------------------|---------:|--------:|
84
+ | 100 nodes | ~9 ms | ~4 ms |
85
+ | 200 nodes | ~17 ms | ~13 ms |
86
+ | 500 nodes | ~36 ms | ~24 ms |
87
+ | 1 000 nodes | ~25 ms | ~18 ms |
88
+
89
+ Optimization knobs you can use today:
90
+
91
+ - **`quality: 'draft' | 'balanced' | 'high'`** — preset. `'draft'` is ~2–3×
92
+ faster (skips transpose, fewer iterations); `'high'` does more iterations
93
+ for dense graphs. Default is `'balanced'`.
94
+ - **`skipTranspose: true`** — skip the per-layer transpose pass
95
+ unconditionally (the largest single cost in balanced mode).
96
+ - **`crossingMinimizationIterations`**, **`coordinateOptimizationIterations`**
97
+ — explicit overrides that win over the preset.
98
+ - **Web Worker** — `LayoutInput` and `LayoutResult` are plain JSON, so
99
+ `layout()` runs cleanly off the main thread via `postMessage`.
100
+
101
+ See [docs/PERFORMANCE.md](docs/PERFORMANCE.md) for full benchmarks, worker
102
+ patterns, and notes on plugging a WASM or WebGPU backend.
28
103
 
29
104
  ## Installation
30
105
 
31
106
  ```bash
32
- npm install nodius-layouting
107
+ npm install @nodius/layouting
108
+ ```
109
+
110
+ ---
111
+
112
+ ## Quick start
113
+
114
+ ```ts
115
+ import { layout } from '@nodius/layouting';
116
+
117
+ const result = layout({
118
+ nodes: [
119
+ { id: 'src', width: 120, height: 60, handles: [
120
+ { id: 'out', type: 'output', position: 'bottom' },
121
+ ]},
122
+ { id: 'transform', width: 150, height: 80, handles: [
123
+ { id: 'in', type: 'input', position: 'top' },
124
+ { id: 'out', type: 'output', position: 'bottom' },
125
+ ]},
126
+ { id: 'sink', width: 120, height: 60, handles: [
127
+ { id: 'in', type: 'input', position: 'top' },
128
+ ]},
129
+ ],
130
+ edges: [
131
+ { id: 'e1', from: 'src', to: 'transform', fromHandle: 'out', toHandle: 'in' },
132
+ { id: 'e2', from: 'transform', to: 'sink', fromHandle: 'out', toHandle: 'in' },
133
+ ],
134
+ }, { direction: 'TB' });
135
+
136
+ // result.nodes → positioned nodes with absolute handle coordinates and parentId echoed
137
+ // result.edges → routed edges with waypoint arrays and `kind`
138
+ ```
139
+
140
+ ---
141
+
142
+ ## Concepts
143
+
144
+ A complete mental model lives in five concepts:
145
+
146
+ | Concept | What it is | Where it shows in the API |
147
+ |-------------|------------------------------------------------------------------------|--------------------------------------|
148
+ | Node | A box on the canvas with a fixed `width` × `height`. | `NodeInput` |
149
+ | Handle | A typed connection point on one side of a node (4 sides, with offset).| `NodeInput.handles[]` |
150
+ | Edge | A connection from one node's handle to another's, with a `kind`. | `EdgeInput` |
151
+ | Rail | The subgraph induced by `control` edges. Drives layer assignment. | implicit — emerges from edge kinds |
152
+ | Value | A node whose every incident edge is `data`. Lands as a sidecar. | implicit — emerges from edge kinds |
153
+ | Compound | A node referenced as `parentId` by other nodes — contains them. | `NodeInput.parentId` |
154
+
155
+ The output mirrors the input, with absolute coordinates and routed edge
156
+ points; `parentId` is echoed back so you can rebuild the hierarchy in your
157
+ renderer.
158
+
159
+ ---
160
+
161
+ ## Mixed orientation: rail + sidecars
162
+
163
+ This is the feature that sets `@nodius/layouting` apart, and the one most
164
+ worth understanding deeply. **The chosen direction is the rail direction.
165
+ Values sit on the perpendicular axis** — so a `TB` layout naturally extends
166
+ sideways for values, a `LR` layout naturally extends top/bottom.
167
+
168
+ ### Why it matters
169
+
170
+ A pure top-to-bottom layout that treats every node identically ends up as a
171
+ long vertical ribbon: configs above the function that uses them, constants
172
+ strewn between two business steps, etc. The graph reads top-to-bottom but
173
+ also "skips" all over. With mixed orientation:
174
+
175
+ ```
176
+ Start Start
177
+ | |
178
+ v v
179
+ fetch vs. [API_KEY] fetch [TIMEOUT]
180
+ / \ |
181
+ API_KEY TIMEOUT Parse
182
+ \ / |
183
+ Parse Done
184
+ |
185
+ v
186
+ Done
33
187
  ```
34
188
 
35
- ## Quick Start
189
+ The rail (`Start → fetch → Parse → Done`) stays a single, tight vertical
190
+ line. The values (`API_KEY`, `TIMEOUT`) attach **horizontally** to the
191
+ consumer (`fetch`). The graph is now square-ish and easy to read.
36
192
 
37
- ```typescript
38
- import { layout } from 'nodius-layouting';
193
+ ### How it works
194
+
195
+ 1. Edges declare a `kind`: `'control'` or `'data'`. Defaults to `'control'`.
196
+ 2. Layer assignment runs on the **control rail only**. Values don't extend
197
+ the rail.
198
+ 3. After the rail is fully placed, **value sidecars** are attached to the
199
+ flanks of their dominant consumer. The side (left vs. right in TB, top vs.
200
+ bottom in LR) is chosen from the *consumer's handle position*:
201
+ - consumer handle on `left` → value sits on the left flank
202
+ - consumer handle on `right` → value sits on the right flank
203
+ 4. If multiple values share one consumer, they stack outward, ordered by
204
+ each value's connecting handle offset.
205
+
206
+ ### Self-contained worked example
207
+
208
+ ```ts
209
+ import { layout } from '@nodius/layouting';
39
210
 
40
211
  const result = layout({
41
212
  nodes: [
42
- {
43
- id: 'source',
44
- width: 120,
45
- height: 60,
46
- handles: [
47
- { id: 'out', type: 'output', position: 'bottom', offset: 0.5 },
48
- ],
49
- },
50
- {
51
- id: 'transform',
52
- width: 150,
53
- height: 80,
54
- handles: [
55
- { id: 'in', type: 'input', position: 'top', offset: 0.5 },
56
- { id: 'out', type: 'output', position: 'bottom', offset: 0.5 },
57
- { id: 'err', type: 'output', position: 'right', offset: 0.5 },
58
- ],
59
- },
60
- {
61
- id: 'sink',
62
- width: 120,
63
- height: 60,
64
- handles: [
65
- { id: 'in', type: 'input', position: 'top', offset: 0.5 },
66
- ],
67
- },
213
+ { id: 'Start', width: 100, height: 50, handles: [
214
+ { id: 'out', type: 'output', position: 'bottom' },
215
+ ]},
216
+ { id: 'fetch', width: 140, height: 70, handles: [
217
+ { id: 'in', type: 'input', position: 'top' },
218
+ // Two value-input handles on the LEFT flank
219
+ { id: 'key', type: 'input', position: 'left', offset: 0.3 },
220
+ { id: 'url', type: 'input', position: 'left', offset: 0.7 },
221
+ // … and one on the RIGHT flank.
222
+ { id: 'tm', type: 'input', position: 'right' },
223
+ { id: 'out', type: 'output', position: 'bottom' },
224
+ ]},
225
+ { id: 'API_KEY', width: 90, height: 40, handles: [
226
+ { id: 'out', type: 'output', position: 'right' },
227
+ ]},
228
+ { id: 'BASE_URL', width: 90, height: 40, handles: [
229
+ { id: 'out', type: 'output', position: 'right' },
230
+ ]},
231
+ { id: 'TIMEOUT', width: 90, height: 40, handles: [
232
+ { id: 'out', type: 'output', position: 'left' },
233
+ ]},
234
+ { id: 'Done', width: 100, height: 50, handles: [
235
+ { id: 'in', type: 'input', position: 'top' },
236
+ ]},
237
+ ],
238
+ edges: [
239
+ { id: 'c1', from: 'Start', to: 'fetch', fromHandle: 'out', toHandle: 'in', kind: 'control' },
240
+ { id: 'c2', from: 'fetch', to: 'Done', fromHandle: 'out', toHandle: 'in', kind: 'control' },
241
+ { id: 'd1', from: 'API_KEY', to: 'fetch', fromHandle: 'out', toHandle: 'key', kind: 'data' },
242
+ { id: 'd2', from: 'BASE_URL', to: 'fetch', fromHandle: 'out', toHandle: 'url', kind: 'data' },
243
+ { id: 'd3', from: 'TIMEOUT', to: 'fetch', fromHandle: 'out', toHandle: 'tm', kind: 'data' },
244
+ ],
245
+ }, { direction: 'TB' });
246
+ ```
247
+
248
+ After layout:
249
+
250
+ - `Start.center.x ≈ fetch.center.x ≈ Done.center.x` — the rail is one line.
251
+ - `API_KEY.center.y ≈ BASE_URL.center.y ≈ TIMEOUT.center.y ≈ fetch.center.y` —
252
+ values sit at fetch's vertical band.
253
+ - `API_KEY` and `BASE_URL` end up on the **left** flank (their consumer
254
+ handles are on `left`); `TIMEOUT` ends up on the **right** flank.
255
+
256
+ ### What if my values have "wrong" handles?
257
+
258
+ Use `onProposal: p => p.proposed` to let the engine auto-fix them. See
259
+ [Rotation proposals](#rotation-proposals-onproposal) below.
260
+
261
+ ---
262
+
263
+ ## Typed edges: control vs data
264
+
265
+ ```ts
266
+ type EdgeKind = 'control' | 'data';
267
+ ```
268
+
269
+ | Kind | Weight | Effect on layout |
270
+ |-----------|---------|----------------------------------------------------------------------------------------------------|
271
+ | `control` | 1 | Drives layer assignment. Dummy nodes are inserted for multi-layer spans. Crossings counted. |
272
+ | `data` | 0.25 | Light link. Pulls value nodes onto their consumer's layer. No dummies — routed directly. |
273
+
274
+ Default is `'control'`. You can override defaults globally:
275
+
276
+ ```ts
277
+ layout(input, {
278
+ edgeWeights: { control: 1, data: 0.1 }, // make data even lighter
279
+ });
280
+ ```
281
+
282
+ Or per-edge:
283
+
284
+ ```ts
285
+ { id: 'd1', from: 'A', to: 'B', fromHandle: 'o', toHandle: 'i',
286
+ kind: 'data', weight: 0.5 } // explicit override
287
+ ```
288
+
289
+ **Use `control` for**: execution flow, sequencing, "happens before" relationships, error branches.
290
+
291
+ **Use `data` for**: configuration, constants, environment imports,
292
+ secondary inputs that don't define order — anything you'd put as a `prop`
293
+ rather than a `step`.
294
+
295
+ ---
296
+
297
+ ## Value nodes & sidecars
298
+
299
+ A node is automatically classified as a **value** when **every** edge touching
300
+ it is of kind `'data'`. Once classified:
301
+
302
+ 1. It is excluded from the control rail layer assignment.
303
+ 2. Its target layer becomes the **median layer of its non-value neighbors**
304
+ (typically the layer of its consumer).
305
+ 3. After the rail is laid out, the value is **attached as a sidecar** to its
306
+ dominant consumer:
307
+ - in `TB`/`BT`: on the consumer's left or right flank
308
+ - in `LR`/`RL`: above or below the consumer
309
+ 4. The side is chosen from the consumer's handle position. Multiple values
310
+ targeting the same consumer stack outward.
311
+
312
+ ### Sidecar side picking — by example
313
+
314
+ ```
315
+ Consumer with the data-input handle on the LEFT (TB direction):
316
+
317
+ consumer.handles = [
318
+ { id: 'in', type: 'input', position: 'top' },
319
+ { id: 'cfg', type: 'input', position: 'left' }, ← value handle
320
+ ]
321
+ edge value → consumer via 'cfg'
322
+
323
+ Result: [ value ]——[ consumer ]
324
+ |
325
+ v
326
+ ```
327
+
328
+ ```
329
+ Three values on the LEFT, one on the RIGHT:
330
+
331
+ Layout output (TB direction):
332
+
333
+ [ V3 ][ V1 ][ consumer ][ V4 ]
334
+ [ V2 ]——┘
335
+ ```
336
+
337
+ Values on the same side are sorted by their handle offset on the consumer, so
338
+ stacking order is predictable and matches the visual order of the consumer's
339
+ handles.
340
+
341
+ ### Multiple consumers per value
342
+
343
+ If a value points to multiple non-value nodes, the engine picks the one with
344
+ the most edges to that value (or alphabetical id as tie-breaker). The value
345
+ becomes a sidecar of that one. Other edges are routed normally.
346
+
347
+ ### Isolated values (no consumer)
348
+
349
+ Rare — they go in a corner. If you see one, it usually means an edge points to
350
+ something that was filtered out.
351
+
352
+ ---
353
+
354
+ ## Compound (nested) layout
355
+
356
+ Set `parentId` on any node to make it a child of another. The parent grows to
357
+ fit its children plus padding and a header strip for its own label.
358
+
359
+ ```ts
360
+ const result = layout({
361
+ nodes: [
362
+ { id: 'Start', width: 110, height: 50, handles: [...] },
363
+ // The compound — its width/height in the input are MINIMUMS;
364
+ // the engine inflates them to fit children.
365
+ { id: 'parallel', width: 220, height: 140, handles: [
366
+ { id: 'in', type: 'input', position: 'top' },
367
+ { id: 'out', type: 'output', position: 'bottom' },
368
+ ]},
369
+ { id: 'fetch users', parentId: 'parallel', width: 130, height: 50, handles: [...] },
370
+ { id: 'fetch orders', parentId: 'parallel', width: 130, height: 50, handles: [...] },
371
+ { id: 'End', width: 100, height: 50, handles: [...] },
372
+ ],
373
+ edges: [
374
+ { id: 'c1', from: 'Start', to: 'parallel', fromHandle: 'out', toHandle: 'in', kind: 'control' },
375
+ { id: 'c2', from: 'parallel', to: 'End', fromHandle: 'out', toHandle: 'in', kind: 'control' },
376
+ ],
377
+ });
378
+ ```
379
+
380
+ After layout:
381
+
382
+ - `fetch users` and `fetch orders` are placed **inside** `parallel`'s
383
+ bounding box, side by side.
384
+ - `parallel` is positioned in the rail just like any other node — its size is
385
+ big enough to contain its children.
386
+ - `End` sits below the entire `parallel` block.
387
+ - Each output node has `parentId` echoed (`'parallel'` for the children).
388
+
389
+ ### Nesting
390
+
391
+ Compounds can be nested arbitrarily — children of a compound can themselves
392
+ be compounds with their own children. The engine processes them deepest-first
393
+ so the size at each level reflects the full subtree below it.
394
+
395
+ ### Edges across the compound boundary
396
+
397
+ - Edges between the compound itself and its siblings (e.g. `Start → parallel`)
398
+ are routed at the root level.
399
+ - Edges between children of the same compound (e.g. `transform → enrich`
400
+ inside a `map` compound) are routed inside the compound.
401
+ - Edges that "escape" a compound (a child connecting to something outside)
402
+ are not yet a first-class feature — model them with explicit handles on the
403
+ compound itself.
404
+
405
+ ### Tuning the padding
406
+
407
+ ```ts
408
+ layout(input, { compoundPadding: 24 }); // default 24
409
+ ```
410
+
411
+ The header strip (28 px) is reserved at the top of every compound for its own
412
+ label. The padding wraps the children on all four sides.
413
+
414
+ ---
415
+
416
+ ## Component packing
417
+
418
+ Disjoint components are packed side-by-side along the order axis (perpendicular
419
+ to the flow direction). This keeps the aspect ratio square-ish instead of
420
+ producing a long ribbon.
421
+
422
+ ```ts
423
+ layout(input, { packComponents: true }); // default
424
+ layout(input, { packComponents: false }); // each component starts from 0
425
+ ```
426
+
427
+ Packing respects compound groups — a compound and all of its children always
428
+ move as one block. Components are sorted largest-first along the rank axis so
429
+ the dominant flow leads the layout.
430
+
431
+ ---
432
+
433
+ ## Handle proposals (`onProposal`)
434
+
435
+ When a node's handles don't match how the engine plans to place it, the engine
436
+ **emits a proposal** to the application. The application decides what to do
437
+ with it.
438
+
439
+ ### The signature
440
+
441
+ ```ts
442
+ type ProposalCallback = (
443
+ proposal: LayoutProposal,
444
+ ) => NodeInput | null | undefined | void;
445
+
446
+ type LayoutProposal = RotateProposal | RelocateHandlesProposal;
447
+
448
+ interface RotateProposal {
449
+ type: 'rotate';
450
+ nodeId: string;
451
+ current: NodeInput; // the original node
452
+ proposed: NodeInput; // every handle rotated by `rotation`
453
+ rotation: 90 | -90 | 180; // degrees clockwise
454
+ reason: string;
455
+ }
456
+
457
+ interface RelocateHandlesProposal {
458
+ type: 'relocate-handles';
459
+ nodeId: string;
460
+ current: NodeInput; // the original node
461
+ proposed: NodeInput; // each affected handle moved to its optimal side
462
+ changes: Record<string, { from: HandleSide; to: HandleSide }>;
463
+ reason: string; // e.g. "out_a: top→bottom, out_b: top→right"
464
+ }
465
+ ```
466
+
467
+ Return value (same for both types):
468
+
469
+ | Returned | Meaning |
470
+ |----------------------|--------------------------------------------------------|
471
+ | `proposal.proposed` | Accept as-is. |
472
+ | Modified `NodeInput` | Accept with tweaks (keep some handles untouched). |
473
+ | `null` / nothing | Reject — use the original node. |
474
+
475
+ ### Rotate vs relocate
476
+
477
+ Both fix mis-placed handles, but at different granularities:
478
+
479
+ | Proposal | Granularity | Driven by | Best for |
480
+ |--------------------|-------------|------------------------|-------------------------------------------|
481
+ | `rotate` | Whole node | Direction only | Symmetric nodes, simple graphs |
482
+ | `relocate-handles` | Per handle | Real neighbor geometry | Multi-output nodes, mixed orientations |
483
+
484
+ The engine runs **both passes**, in this order:
485
+
486
+ 1. **Rotate pass** — direction-only check (no preview needed). Emits a single
487
+ rotation if every handle is wrong for the chosen direction.
488
+ 2. **Relocate pass** — runs a *preview layout* with current handles to see
489
+ where every neighbor actually ends up, then proposes, per handle, the side
490
+ that points to its neighbor's center.
491
+
492
+ The relocate pass is a strict superset of rotate: it can move two handles in
493
+ opposite directions, leave correctly-placed ones alone, or split a single
494
+ node's outputs across all four sides. It costs **one extra layout pass** but
495
+ only when `onProposal` is provided.
496
+
497
+ ### When does the rotate pass emit a proposal?
498
+
499
+ #### Rail nodes (any node with at least one control edge)
500
+
501
+ Expected handle layout for the chosen direction:
502
+
503
+ | Direction | Inputs on | Outputs on |
504
+ |-----------|-----------|------------|
505
+ | `TB` | top | bottom |
506
+ | `BT` | bottom | top |
507
+ | `LR` | left | right |
508
+ | `RL` | right | left |
509
+
510
+ If a node has handles facing the wrong axis (e.g. `top`/`bottom` handles in a
511
+ `LR` graph), the engine proposes the rotation that maximizes alignment.
512
+
513
+ #### Value nodes (every incident edge is data)
514
+
515
+ Values land **opposite** the consumer's handle. Expected output:
516
+
517
+ | Direction | Consumer handle on | Value output expected |
518
+ |-----------|--------------------|------------------------|
519
+ | `TB`/`BT` | `left` | `right` |
520
+ | `TB`/`BT` | `right` | `left` |
521
+ | `LR`/`RL` | `top` | `bottom` |
522
+ | `LR`/`RL` | `bottom` | `top` |
523
+
524
+ This is what makes the "I defined my value with a `bottom` handle by reflex,
525
+ but it sits horizontally — please fix it" case work transparently.
526
+
527
+ ### Strategic per-handle placement (`relocate-handles`)
528
+
529
+ This pass is **the killer feature** for graphs where a single node has
530
+ neighbors in multiple directions. Examples that motivate it:
531
+
532
+ - A central **hub** with control edges flowing top↔bottom AND value sidecars
533
+ on the flanks. A simple rotation cannot satisfy both axes.
534
+ - A **broker / pub-sub** node where publishers feed it from one side and
535
+ subscribers from the other.
536
+ - A **bidirectional mesh** of services where each pair has a request/reply
537
+ exchange across multiple handles.
538
+ - A pipeline whose handles were **defined randomly** and need each handle
539
+ fixed individually.
540
+
541
+ Mechanism:
542
+
543
+ 1. The engine lays out the graph **once** with the input handles to discover
544
+ where every node actually ends up.
545
+ 2. For each node with at least one connected handle, it groups handles by id,
546
+ sums per-handle votes for each side (weighted by `1 / distance` so close
547
+ neighbors win), and picks the strongest side per handle.
548
+ 3. If at least one handle would change side, it bundles all the moves into
549
+ one `RelocateHandlesProposal` for that node — with a `changes` map you can
550
+ inspect to see exactly what would move.
551
+
552
+ #### Example: a multi-handle hub
553
+
554
+ ```ts
555
+ import { layout } from '@nodius/layouting';
556
+
557
+ const input = {
558
+ nodes: [
559
+ { id: 'Trigger', width: 110, height: 50, handles: [
560
+ { id: 'go', type: 'output', position: 'bottom', offset: 0.5 },
561
+ ]},
562
+ // Hub: every handle defined on 'bottom' on purpose.
563
+ { id: 'Hub', width: 220, height: 100, handles: [
564
+ { id: 'in', type: 'input', position: 'bottom', offset: 0.5 },
565
+ { id: 'out', type: 'output', position: 'bottom', offset: 0.5 },
566
+ { id: 'lg', type: 'output', position: 'bottom', offset: 0.2 },
567
+ { id: 'rg', type: 'output', position: 'bottom', offset: 0.4 },
568
+ { id: 'ldata', type: 'input', position: 'bottom', offset: 0.6 },
569
+ { id: 'rdata', type: 'input', position: 'bottom', offset: 0.8 },
570
+ ]},
571
+ { id: 'Continue', width: 110, height: 50, handles: [
572
+ { id: 'in', type: 'input', position: 'top', offset: 0.5 },
573
+ ]},
574
+ { id: 'LogA', width: 90, height: 40, handles: [{ id: 'in', type: 'input', position: 'right', offset: 0.5 }]},
575
+ { id: 'LogB', width: 90, height: 40, handles: [{ id: 'in', type: 'input', position: 'left', offset: 0.5 }]},
576
+ { id: 'CONF_A', width: 90, height: 40, handles: [{ id: 'out', type: 'output', position: 'right', offset: 0.5 }]},
577
+ { id: 'CONF_B', width: 90, height: 40, handles: [{ id: 'out', type: 'output', position: 'left', offset: 0.5 }]},
578
+ ],
579
+ edges: [
580
+ { id: 'c0', from: 'Trigger', to: 'Hub', fromHandle: 'go', toHandle: 'in', kind: 'control' },
581
+ { id: 'c1', from: 'Hub', to: 'Continue', fromHandle: 'out', toHandle: 'in', kind: 'control' },
582
+ { id: 'l1', from: 'Hub', to: 'LogA', fromHandle: 'lg', toHandle: 'in', kind: 'data' },
583
+ { id: 'l2', from: 'Hub', to: 'LogB', fromHandle: 'rg', toHandle: 'in', kind: 'data' },
584
+ { id: 'd1', from: 'CONF_A', to: 'Hub', fromHandle: 'out', toHandle: 'ldata', kind: 'data' },
585
+ { id: 'd2', from: 'CONF_B', to: 'Hub', fromHandle: 'out', toHandle: 'rdata', kind: 'data' },
586
+ ],
587
+ };
588
+
589
+ const result = layout(input, {
590
+ direction: 'TB',
591
+ onProposal: (p) => p.proposed, // accept everything
592
+ });
593
+ ```
594
+
595
+ After auto-fix, the Hub's six handles each land on the correct side:
596
+
597
+ | Handle | Neighbor | Original | Relocated |
598
+ |-----------|-----------------|----------|-----------|
599
+ | `in` | Trigger (above) | `bottom` | `top` |
600
+ | `out` | Continue (below)| `bottom` | `bottom` |
601
+ | `lg` | LogA (left) | `bottom` | `left` |
602
+ | `rg` | LogB (right) | `bottom` | `right` |
603
+ | `ldata` | CONF_A (left) | `bottom` | `left` |
604
+ | `rdata` | CONF_B (right) | `bottom` | `right` |
605
+
606
+ A single `rotate` proposal could not have produced this — the six handles
607
+ need to spread across four different sides, which is exactly what
608
+ `relocate-handles` does.
609
+
610
+ ### Three patterns of use
611
+
612
+ **1. Accept everything (the easy mode):**
613
+
614
+ ```ts
615
+ layout(input, {
616
+ direction: 'LR',
617
+ onProposal: (p) => p.proposed,
618
+ });
619
+ ```
620
+
621
+ **2. Observe without applying — useful for logging/debugging:**
622
+
623
+ ```ts
624
+ const log: LayoutProposal[] = [];
625
+ layout(input, {
626
+ onProposal: (p) => { log.push(p); return null; },
627
+ });
628
+
629
+ for (const p of log) {
630
+ if (p.type === 'rotate') {
631
+ console.log(`[rotate] ${p.nodeId} by ${p.rotation}°`);
632
+ } else {
633
+ const moves = Object.entries(p.changes)
634
+ .map(([h, c]) => `${h}: ${c.from}→${c.to}`).join(', ');
635
+ console.log(`[relocate] ${p.nodeId} — ${moves}`);
636
+ }
637
+ }
638
+ ```
639
+
640
+ **3. Partial accept — pick which moves to apply:**
641
+
642
+ ```ts
643
+ layout(input, {
644
+ direction: 'TB',
645
+ onProposal: (p) => {
646
+ if (p.type !== 'relocate-handles') return null;
647
+ // Apply only changes whose target is on the left.
648
+ return {
649
+ ...p.current,
650
+ handles: p.current.handles.map(h => {
651
+ const change = p.changes[h.id];
652
+ if (change && change.to === 'left') {
653
+ return { ...h, position: 'left' };
654
+ }
655
+ return h;
656
+ }),
657
+ };
658
+ },
659
+ });
660
+ ```
661
+
662
+ ### Helper: `rotateHandles`
663
+
664
+ If you build your own rotation logic, import the same utility the engine uses:
665
+
666
+ ```ts
667
+ import { rotateHandles } from '@nodius/layouting';
668
+
669
+ const rotated = rotateHandles(node.handles, 90);
670
+ // rotation in {90, -90, 180}, clockwise. Offsets are preserved.
671
+ ```
672
+
673
+ ---
674
+
675
+ ## Cookbook — recipes for common patterns
676
+
677
+ ### Linear pipeline with constants
678
+
679
+ ```ts
680
+ import { layout } from '@nodius/layouting';
681
+
682
+ layout({
683
+ nodes: [
684
+ { id: 'load', width: 120, height: 50, handles: [
685
+ { id: 'in', type: 'input', position: 'top' },
686
+ { id: 'cfg', type: 'input', position: 'left' },
687
+ { id: 'out', type: 'output', position: 'bottom' },
688
+ ]},
689
+ { id: 'CONFIG', width: 100, height: 40, handles: [
690
+ { id: 'out', type: 'output', position: 'right' },
691
+ ]},
692
+ { id: 'save', width: 120, height: 50, handles: [
693
+ { id: 'in', type: 'input', position: 'top' },
694
+ ]},
695
+ ],
696
+ edges: [
697
+ { id: 'c', from: 'load', to: 'save', fromHandle: 'out', toHandle: 'in', kind: 'control' },
698
+ { id: 'd', from: 'CONFIG', to: 'load', fromHandle: 'out', toHandle: 'cfg', kind: 'data' },
699
+ ],
700
+ }, { direction: 'TB' });
701
+ ```
702
+
703
+ Result: `load → save` vertically, `CONFIG` to the left of `load`.
704
+
705
+ ### Parallel branches (Promise.all)
706
+
707
+ ```ts
708
+ layout({
709
+ nodes: [
710
+ { id: 'Start', width: 100, height: 50, handles: [
711
+ { id: 'out', type: 'output', position: 'bottom' },
712
+ ]},
713
+ { id: 'parallel', width: 240, height: 130, handles: [
714
+ { id: 'in', type: 'input', position: 'top' },
715
+ { id: 'out', type: 'output', position: 'bottom' },
716
+ ]},
717
+ { id: 'a', parentId: 'parallel', width: 100, height: 50, handles: [
718
+ { id: 'in', type: 'input', position: 'top' },
719
+ { id: 'out', type: 'output', position: 'bottom' },
720
+ ]},
721
+ { id: 'b', parentId: 'parallel', width: 100, height: 50, handles: [
722
+ { id: 'in', type: 'input', position: 'top' },
723
+ { id: 'out', type: 'output', position: 'bottom' },
724
+ ]},
725
+ { id: 'Done', width: 100, height: 50, handles: [
726
+ { id: 'in', type: 'input', position: 'top' },
727
+ ]},
728
+ ],
729
+ edges: [
730
+ { id: 'c1', from: 'Start', to: 'parallel', fromHandle: 'out', toHandle: 'in', kind: 'control' },
731
+ { id: 'c2', from: 'parallel', to: 'Done', fromHandle: 'out', toHandle: 'in', kind: 'control' },
732
+ ],
733
+ });
734
+ ```
735
+
736
+ `a` and `b` are placed side by side inside `parallel`'s box. The rail is
737
+ `Start → parallel → Done`.
738
+
739
+ ### Try / Catch
740
+
741
+ Model the happy path inside one compound and the error path inside another.
742
+ A single `try` node with two outputs (`ok` and `err`) branches to either path.
743
+
744
+ ```ts
745
+ layout({
746
+ nodes: [
747
+ { id: 'try', width: 200, height: 120, handles: [
748
+ { id: 'in', type: 'input', position: 'top' },
749
+ { id: 'ok', type: 'output', position: 'bottom', offset: 0.3 },
750
+ { id: 'err', type: 'output', position: 'right' },
751
+ ]},
752
+ // … 'try' children (parentId: 'try') …
753
+ { id: 'catch', width: 180, height: 100, handles: [
754
+ { id: 'in', type: 'input', position: 'top' },
755
+ { id: 'out', type: 'output', position: 'bottom' },
756
+ ]},
757
+ // … 'catch' children (parentId: 'catch') …
68
758
  ],
69
759
  edges: [
70
- { id: 'e1', from: 'source', to: 'transform', fromHandle: 'out', toHandle: 'in' },
71
- { id: 'e2', from: 'transform', to: 'sink', fromHandle: 'out', toHandle: 'in' },
760
+ { id: 'ok_path', from: 'try', to: 'End', fromHandle: 'ok', toHandle: 'in', kind: 'control' },
761
+ { id: 'err_path', from: 'try', to: 'catch', fromHandle: 'err', toHandle: 'in', kind: 'control' },
762
+ // …
72
763
  ],
73
764
  });
765
+ ```
766
+
767
+ See the `Try / Catch (compound)` example in the playground for a complete
768
+ version.
769
+
770
+ ### Switch / Case
74
771
 
75
- // result.nodes positioned nodes with absolute handle coordinates
76
- // result.edges routed edges with waypoint arrays
772
+ A single dispatcher with one output per branch. Each branch can be a single
773
+ node or a compound.
774
+
775
+ ```ts
776
+ const dispatcher = {
777
+ id: 'method?', width: 100, height: 50, handles: [
778
+ { id: 'in', type: 'input', position: 'top' },
779
+ { id: 'GET', type: 'output', position: 'bottom', offset: 0.2 },
780
+ { id: 'POST', type: 'output', position: 'bottom', offset: 0.4 },
781
+ { id: 'PUT', type: 'output', position: 'bottom', offset: 0.6 },
782
+ { id: 'DEL', type: 'output', position: 'bottom', offset: 0.8 },
783
+ ],
784
+ };
785
+ ```
786
+
787
+ See the `Switch / Case` example in the playground.
788
+
789
+ ### Map / Reduce pipeline with seeded reducer
790
+
791
+ Combine a compound (`map`) with a sidecar value (`SEED`) on the reducer.
792
+
793
+ See the `Map / Reduce pipeline` example in the playground.
794
+
795
+ ### HTTP middleware chain with config values
796
+
797
+ Every middleware in a vertical rail, each one with a `data`-edge constant on
798
+ the left, plus a side-effect (`audit`) on the right.
799
+
800
+ See the `HTTP middleware chain` example in the playground.
801
+
802
+ ### Disjoint components
803
+
804
+ Just emit them and let `packComponents` (default on) place them side by side.
805
+
806
+ ```ts
807
+ layout({
808
+ nodes: [...nodesA, ...nodesB, ...nodesC],
809
+ edges: [...edgesA, ...edgesB, ...edgesC], // each (A,B,C) self-contained
810
+ });
77
811
  ```
78
812
 
813
+ The three components sit side by side, largest first.
814
+
815
+ ### Cycles
816
+
817
+ Cycles are detected during the cycle-breaking phase; the back edges are
818
+ reversed for layout purposes only. The output edge keeps its original `from`
819
+ and `to`; only the routing reflects the reversal.
820
+
821
+ ```ts
822
+ layout({
823
+ nodes: [...],
824
+ edges: [
825
+ { id: 'a', from: 'A', to: 'B', ... },
826
+ { id: 'b', from: 'B', to: 'A', ... }, // ← back edge, reversed internally
827
+ ],
828
+ });
829
+ ```
830
+
831
+ ### Migrating from a "flat" graph
832
+
833
+ If you already have a working layout with all edges as control, you can adopt
834
+ typed edges incrementally:
835
+
836
+ ```ts
837
+ const result = layout({
838
+ nodes,
839
+ edges: edges.map(e => ({
840
+ ...e,
841
+ kind: e.isConfig ? 'data' : 'control', // your own classifier
842
+ })),
843
+ });
844
+ ```
845
+
846
+ The output stays compatible; values just relocate to sidecars.
847
+
848
+ ---
849
+
79
850
  ## API
80
851
 
81
852
  ### `layout(input, options?)`
82
853
 
83
- Compute a complete layout for the given graph. Returns positioned nodes and routed edges.
854
+ Compute a complete layout for the given graph. Returns positioned nodes and
855
+ routed edges.
84
856
 
85
- ```typescript
857
+ ```ts
86
858
  function layout(input: LayoutInput, options?: LayoutOptions): LayoutResult;
87
859
  ```
88
860
 
861
+ Use this for one-shot layouts: full recompute every time.
862
+
89
863
  ### `IncrementalLayout`
90
864
 
91
- Maintains layout state for incremental updates. Use this when you need to add or remove nodes without full recomputation.
865
+ Maintains state for incremental updates with position stability:
92
866
 
93
- ```typescript
94
- import { IncrementalLayout } from 'nodius-layouting';
867
+ ```ts
868
+ import { IncrementalLayout } from '@nodius/layouting';
95
869
 
96
870
  const inc = new IncrementalLayout({ direction: 'LR' });
97
871
 
98
- // Set initial graph
99
- const result1 = inc.setGraph({ nodes: [...], edges: [...] });
872
+ // Initial layout
873
+ const r1 = inc.setGraph({ nodes: [...], edges: [...] });
874
+
875
+ // Add things; existing positions are blended 70/30 (new/old).
876
+ const r2 = inc.addNodes([newNode], [newEdge]);
877
+ const r3 = inc.removeNodes(['stale_id']); // connected edges removed too
878
+ inc.addEdges([{ id: 'e', from: 'A', to: 'B', fromHandle: 'o', toHandle: 'i' }]);
879
+ inc.removeEdges(['e']);
880
+
881
+ inc.getResult(); // current cached LayoutResult | null
882
+ ```
883
+
884
+ Position stability formula (per node, per axis):
885
+ `new_position = 0.7 * fresh_compute + 0.3 * previous_position`.
886
+
887
+ ### `printLayout(result, options?)`
100
888
 
101
- // Add nodes and edges incrementally
102
- const result2 = inc.addNodes(
103
- [{ id: 'new_node', width: 100, height: 60, handles: [...] }],
104
- [{ id: 'new_edge', from: 'existing', to: 'new_node', fromHandle: 'out', toHandle: 'in' }]
105
- );
889
+ Debug helper that renders a `LayoutResult` to a text block: per-Y-band
890
+ summary, hierarchy, edges, overlaps, and an optional ASCII grid.
106
891
 
107
- // Remove nodes (connected edges are removed automatically)
108
- const result3 = inc.removeNodes(['node_to_remove']);
892
+ ```ts
893
+ import { layout, printLayout } from '@nodius/layouting';
109
894
 
110
- // Add/remove edges independently
111
- inc.addEdges([...]);
112
- inc.removeEdges(['edge_id']);
895
+ const r = layout(input);
896
+ console.log(printLayout(r));
897
+ // Or skip the ASCII grid:
898
+ console.log(printLayout(r, { grid: false }));
899
+ ```
900
+
901
+ Useful when iterating on the algorithm without opening a browser, or when
902
+ writing failing tests where you want to inspect coordinates.
903
+
904
+ ### `rotateHandles(handles, rotation)`
113
905
 
114
- // Get current result
115
- const current = inc.getResult();
906
+ Utility re-exported for applications that build their own handle-rotation
907
+ logic in `onProposal`. Rotates each handle's `position` clockwise; `offset`
908
+ is preserved. `rotation` is `90 | -90 | 180`.
909
+
910
+ ```ts
911
+ import { rotateHandles } from '@nodius/layouting';
912
+ const rotated = rotateHandles(node.handles, 90);
116
913
  ```
117
914
 
118
- ### Types
915
+ ### `countAllCrossings(graph, layers)`
916
+
917
+ Internal helper exposed for testing — counts the number of edge crossings
918
+ across all adjacent layers. Mostly useful in your own regression tests.
119
919
 
120
- #### Input Types
920
+ ---
121
921
 
122
- ```typescript
922
+ ## Types
923
+
924
+ ### Input
925
+
926
+ ```ts
123
927
  interface NodeInput {
124
928
  id: string;
125
929
  width: number;
126
930
  height: number;
127
931
  handles: HandleInput[];
932
+ parentId?: string; // make this node a child of another (compound layout)
128
933
  }
129
934
 
130
935
  interface HandleInput {
131
936
  id: string;
132
937
  type: 'input' | 'output';
133
938
  position: 'top' | 'right' | 'bottom' | 'left';
134
- offset?: number; // 0-1 position along the side. Default: 0.5
939
+ offset?: number; // 0..1 along the side. Default: 0.5
135
940
  }
136
941
 
137
942
  interface EdgeInput {
138
943
  id: string;
139
- from: string; // Source node ID
140
- to: string; // Target node ID
141
- fromHandle: string; // Source handle ID
142
- toHandle: string; // Target handle ID
944
+ from: string;
945
+ to: string;
946
+ fromHandle: string;
947
+ toHandle: string;
948
+ kind?: 'control' | 'data'; // default: 'control'
949
+ weight?: number; // default: 1 (control), 0.25 (data)
143
950
  }
144
951
 
145
952
  interface LayoutInput {
@@ -148,22 +955,40 @@ interface LayoutInput {
148
955
  }
149
956
  ```
150
957
 
151
- #### Options
958
+ ### Options
152
959
 
153
- ```typescript
960
+ ```ts
154
961
  interface LayoutOptions {
155
- direction?: 'TB' | 'LR' | 'BT' | 'RL'; // Default: 'TB'
156
- nodeSpacing?: number; // Default: 40
157
- layerSpacing?: number; // Default: 60
158
- crossingMinimizationIterations?: number; // Default: 24
159
- coordinateOptimizationIterations?: number; // Default: 8
160
- edgeMargin?: number; // Default: 20
962
+ // ─── Direction & spacing ────────────────────────────────────────────
963
+ direction?: 'TB' | 'LR' | 'BT' | 'RL'; // Default: 'TB'
964
+ nodeSpacing?: number; // Default: 40 (px between siblings)
965
+ layerSpacing?: number; // Default: 60 (px between layers)
966
+ edgeMargin?: number; // Default: 20 (entry/exit margin)
967
+
968
+ // ─── Iteration counts ───────────────────────────────────────────────
969
+ crossingMinimizationIterations?: number; // Default: 24 (auto-reduced for >200 nodes)
970
+ coordinateOptimizationIterations?: number; // Default: 8
971
+
972
+ // ─── Typed edges ────────────────────────────────────────────────────
973
+ edgeWeights?: { control?: number; data?: number };
974
+ // Default: { control: 1, data: 0.25 }
975
+ // Individual edges can override via EdgeInput.weight.
976
+
977
+ // ─── Component packing ──────────────────────────────────────────────
978
+ packComponents?: boolean; // Default: true
979
+
980
+ // ─── Compound layout ────────────────────────────────────────────────
981
+ compoundPadding?: number; // Default: 24 (px around children)
982
+
983
+ // ─── Proposals ──────────────────────────────────────────────────────
984
+ onProposal?: ProposalCallback;
985
+ // Called once per node when the engine wants to suggest a rotation.
161
986
  }
162
987
  ```
163
988
 
164
- #### Output Types
989
+ ### Output
165
990
 
166
- ```typescript
991
+ ```ts
167
992
  interface LayoutResult {
168
993
  nodes: NodeOutput[];
169
994
  edges: EdgeOutput[];
@@ -175,15 +1000,16 @@ interface NodeOutput {
175
1000
  y: number;
176
1001
  width: number;
177
1002
  height: number;
178
- handles: HandleOutput[]; // Absolute positions
1003
+ handles: HandleOutput[]; // absolute positions
1004
+ parentId?: string; // echoed from the input — keep your hierarchy
179
1005
  }
180
1006
 
181
1007
  interface HandleOutput {
182
1008
  id: string;
183
1009
  type: 'input' | 'output';
184
1010
  position: 'top' | 'right' | 'bottom' | 'left';
185
- x: number; // Absolute x position
186
- y: number; // Absolute y position
1011
+ x: number; // absolute, includes node position
1012
+ y: number;
187
1013
  }
188
1014
 
189
1015
  interface EdgeOutput {
@@ -192,62 +1018,206 @@ interface EdgeOutput {
192
1018
  to: string;
193
1019
  fromHandle: string;
194
1020
  toHandle: string;
195
- points: Point[]; // Ordered waypoints from source to target
1021
+ points: Point[]; // ordered waypoints from source to target
1022
+ kind: 'control' | 'data';
196
1023
  }
1024
+
1025
+ interface Point { x: number; y: number; }
197
1026
  ```
198
1027
 
199
- ## Algorithm
1028
+ ### Proposals
200
1029
 
201
- The layout engine uses a modified **Sugiyama algorithm** with five phases:
1030
+ ```ts
1031
+ type LayoutProposal = RotateProposal | RelocateHandlesProposal;
202
1032
 
203
- 1. **Cycle Breaking** - DFS-based back edge detection and reversal
204
- 2. **Layer Assignment** - Longest-path layering with dummy node insertion for long edges
205
- 3. **Crossing Minimization** - Barycenter heuristic with up/down sweeps and transpose improvement. Uses merge-sort based O(E log V) inversion counting
206
- 4. **Coordinate Assignment** - Median-based iterative positioning with spacing constraints and multi-pass centering
207
- 5. **Edge Routing** - Orthogonal path computation through dummy node waypoints with handle-aware entry/exit directions
1033
+ interface RotateProposal {
1034
+ type: 'rotate';
1035
+ nodeId: string;
1036
+ current: NodeInput; // unchanged input node
1037
+ proposed: NodeInput; // all handles rotated by `rotation`
1038
+ rotation: 90 | -90 | 180; // clockwise
1039
+ reason: string;
1040
+ }
208
1041
 
209
- ### Incremental Layout
1042
+ interface RelocateHandlesProposal {
1043
+ type: 'relocate-handles';
1044
+ nodeId: string;
1045
+ current: NodeInput; // unchanged input node
1046
+ proposed: NodeInput; // each affected handle moved
1047
+ /** Per-handle changes; only handles whose side differs are present. */
1048
+ changes: Record<string, { from: HandleSide; to: HandleSide }>;
1049
+ reason: string;
1050
+ }
210
1051
 
211
- The `IncrementalLayout` class provides position stability when modifying graphs:
1052
+ type ProposalCallback = (
1053
+ proposal: LayoutProposal,
1054
+ ) => NodeInput | null | undefined | void;
1055
+ ```
212
1056
 
213
- - New nodes are inserted into appropriate layers based on their connections
214
- - Existing node positions are blended (70% new / 30% old) to reduce visual disruption
215
- - Full crossing minimization and coordinate assignment run on the updated graph
216
- - Only affected edges are re-routed
1057
+ ---
217
1058
 
218
- ## Playground
1059
+ ## Algorithm internals
1060
+
1061
+ The engine is a modified **Sugiyama algorithm** with these phases (in order):
1062
+
1063
+ 1. **Proposals** — two passes when `onProposal` is set:
1064
+ - **Rotate pass**: scan input nodes; emit `RotateProposal` when the whole
1065
+ node faces the wrong axis for the chosen direction.
1066
+ - **Relocate pass**: run a preview layout, then emit
1067
+ `RelocateHandlesProposal` per node with each handle's optimal side
1068
+ (computed from the preview's actual neighbor positions). The
1069
+ application accepts/rejects each proposal via `onProposal`.
1070
+ 2. **Compound resolution** — group nodes by `parentId`; recursively lay out
1071
+ each compound's children bottom-up so the compound's bounding box is
1072
+ known before it appears at the parent level.
1073
+ 3. **Cycle breaking** — DFS-based back-edge detection and reversal.
1074
+ 4. **Two-pass layer assignment**:
1075
+ - **control rail** via longest-path on control-only edges between non-value
1076
+ nodes;
1077
+ - **values** are pulled onto the median layer of their non-value neighbors.
1078
+ 5. **Dummy node insertion** for long control edges only — data edges route
1079
+ directly (typically span 0 or 1 layers).
1080
+ 6. **Crossing minimization on the rail** — barycenter heuristic with up/down
1081
+ sweeps and transpose improvement; merge-sort inversion counting for O(E log V).
1082
+ 7. **Coordinate assignment on the rail** — median-based iterative positioning
1083
+ with spacing constraints; values are excluded so the rail stays aligned.
1084
+ 8. **Sidecar value placement** — values are attached to the flanks of their
1085
+ dominant consumer; side chosen from the consumer's handle position.
1086
+ 9. **Edge routing** — orthogonal paths through dummy waypoints with
1087
+ handle-aware entry/exit directions; `kind` is preserved on output.
1088
+ 10. **Component packing** — disjoint components packed along the order axis;
1089
+ compound groups travel as a single block.
1090
+
1091
+ ### Why the rail is laid out without values
1092
+
1093
+ Including values in the rail's layer assignment would inflate the layer's
1094
+ width (every value adds a slot), which would shift the rail's center.
1095
+ Excluding values during the rail's coordinate assignment is what guarantees
1096
+ that, say, `Start → fetch → Parse → Done` stay perfectly aligned in TB even
1097
+ when fetch has five values attached to it.
1098
+
1099
+ ---
219
1100
 
220
- A visual playground is included in the `playground/` directory:
1101
+ ## Debugging
1102
+
1103
+ ### `printLayout(result)`
1104
+
1105
+ Best first move. Prints:
1106
+
1107
+ - bounding box
1108
+ - nodes grouped by Y band (TB-style) — useful to verify layer separation
1109
+ - hierarchy (compounds with their children, recursively)
1110
+ - every edge: kind, source/target, point count
1111
+ - a list of overlapping nodes (should be empty in a healthy layout)
1112
+ - an ASCII sketch of the final positions
1113
+
1114
+ ```ts
1115
+ console.log(printLayout(layout(input), { gridWidth: 100 }));
1116
+ ```
1117
+
1118
+ ### Inspecting proposals
1119
+
1120
+ ```ts
1121
+ layout(input, {
1122
+ onProposal: (p) => {
1123
+ console.log(`[proposal] ${p.nodeId}: rotate by ${p.rotation}° — ${p.reason}`);
1124
+ return null; // observe only
1125
+ },
1126
+ });
1127
+ ```
1128
+
1129
+ ### Verifying typed-edge classification
1130
+
1131
+ A node is considered a "value" iff every incident edge has `kind: 'data'`. If
1132
+ a node you expected to be a value is appearing in the rail, double-check:
1133
+
1134
+ - Are any of its edges still `kind: 'control'` (or missing `kind`, which
1135
+ defaults to control)?
1136
+ - Did you misspell `kind`? It must be a string `'data'`, not the EdgeKind type
1137
+ imported then mis-used.
1138
+
1139
+ ### Common gotchas
1140
+
1141
+ | Symptom | Likely cause |
1142
+ |----------------------------------------------------------|----------------------------------------------------------------------|
1143
+ | Rail is not vertical/horizontal anymore | Some control edges leak between unrelated components — packComponents helps |
1144
+ | Value lands in the middle of the rail | The value still has a control edge — re-check its edge kinds |
1145
+ | Compound child sticks out of the parent box | Child has handles wider than expected — check `width`/`height` math |
1146
+ | `End` node sits to the side of the rail | Its only inbound edge is `data`, not `control` |
1147
+ | Sidecar values all stack on one side | The consumer's handle for those values is on a single side — split them |
1148
+
1149
+ ---
1150
+
1151
+ ## Playground
221
1152
 
222
1153
  ```bash
223
1154
  cd playground
224
1155
  npm install
225
- npm run dev
1156
+ npm run dev # → http://localhost:6501
226
1157
  ```
227
1158
 
228
- This opens a Vite dev server with:
229
- - Predefined example graphs (chain, diamond, data pipeline, hub, binary tree, cycle)
230
- - Editable JSON input
231
- - Before/after SVG visualization
232
- - Configurable layout direction and spacing
233
- - Real-time performance metrics
1159
+ The playground ships with curated examples covering every layout feature:
1160
+
1161
+ | Example | What it showcases |
1162
+ |--------------------------|----------------------------------------------------|
1163
+ | Simple Chain | Bare-bones linear rail |
1164
+ | Diamond | Branch + join |
1165
+ | Data Pipeline | Realistic multi-handle nodes |
1166
+ | Multi-Handle Hub | One source fanning out to many workers |
1167
+ | Binary Tree | Tree with two outputs per parent |
1168
+ | Compound · Promise.all | Parallel children inside a compound |
1169
+ | **Floating Values** | **Mixed orientation + auto-rotate demo** |
1170
+ | Compound + Values | A compound that takes a value as a config |
1171
+ | Try / Catch (compound) | Two side-by-side compounds (happy + error path) |
1172
+ | Switch / Case | One dispatcher, many branches converging |
1173
+ | Map / Reduce pipeline | Compound + seeded reducer (sidecar value) |
1174
+ | HTTP middleware chain | Long vertical rail with three left-side configs |
1175
+ | Disjoint Components | `packComponents` packing in action |
1176
+ | **Star Hub (relocate)** | **Single node, 6 handles, 4 different sides — per-handle placement** |
1177
+ | **Pub/Sub Broker** | **Bidirectional broker: publishers ↔ broker ↔ subscribers** |
1178
+ | **Bidirectional Mesh** | **3 services with request/reply edges in both directions** |
1179
+ | **Wrong Handles Everywhere** | **Linear pipeline with scrambled handles — auto-fix corrects each one** |
1180
+ | Cycle Example | Back edge handling |
1181
+
1182
+ Toolbar toggles:
1183
+
1184
+ - **Direction** — `TB`, `LR`, `BT`, `RL`.
1185
+ - **Node / Layer spacing** — sliders.
1186
+ - **Pack components** — turns off packing of disjoint subgraphs.
1187
+ - **Auto-rotate handles** — when on, `onProposal` returns `p.proposed` so the
1188
+ engine's suggestions are applied automatically — **both** rotate and
1189
+ relocate proposals. Toggle it off on `Star Hub (relocate)`,
1190
+ `Pub/Sub Broker`, or `Wrong Handles Everywhere` to see how bad the layout
1191
+ looks without it; toggle it back on to watch every handle snap into place.
1192
+
1193
+ ---
234
1194
 
235
1195
  ## Development
236
1196
 
237
1197
  ```bash
238
- # Install dependencies
239
1198
  npm install
240
1199
 
241
- # Run tests
242
- npm test
243
-
244
- # Run tests in watch mode
1200
+ npm test # 93 tests cover compound, sidecars, rotate + relocate proposals, perf, cycles…
245
1201
  npm run test:watch
1202
+ npm run build # tsup bundle + tsc declaration files
246
1203
 
247
- # Build the library
248
- npm run build
1204
+ # Playground:
1205
+ cd playground
1206
+ npm install
1207
+ npm run dev # vite on http://localhost:6501
249
1208
  ```
250
1209
 
1210
+ ### Tests of note
1211
+
1212
+ - `tests/compound.test.ts` — the original "Option A améliorée" contract:
1213
+ compound bounding box + value pull on `Test 2`.
1214
+ - `tests/value-placement.test.ts` — sidecar contract: rail alignment, sidecar
1215
+ side picking, LR direction, no-overlap.
1216
+ - `tests/proposals.test.ts` — `onProposal` lifecycle: emission, rejection,
1217
+ acceptance, partial accept, value-specific rotation suggestions.
1218
+ - `tests/diag-values.test.ts` — `printLayout` smoke test on the Floating
1219
+ Values scenario.
1220
+
251
1221
  ## License
252
1222
 
253
1223
  MIT