@nodius/layouting 0.1.1 → 0.1.4

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.
package/README.md CHANGED
@@ -3,36 +3,106 @@
3
3
  A **zero-dependency**, high-performance graph layouting library for node-based
4
4
  technical diagrams.
5
5
 
6
- Built for real-world use cases: data pipelines, visual programming environments,
7
- workflow editors, and any system where typed-handle nodes are connected by
8
- typed edges, with parent/child grouping and side-attached values.
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
+ - [The phases in depth](#the-phases-in-depth)
40
+ - [Which knob affects which phase](#which-knob-affects-which-phase)
41
+ - [Debugging](#debugging)
42
+ - [Playground](#playground)
43
+ - [⚙ Optimizations panel](#-optimizations-panel)
44
+ - [Development](#development)
45
+
46
+ ---
9
47
 
10
48
  ## Features
11
49
 
12
- - **Zero runtime dependencies** — pure TypeScript, nothing else
13
- - **Strict-axis Sugiyama** — the chosen reading direction is honored end to end; compacity comes from packing and sidecars, never from local re-orientation
14
- - **Typed edges** (`control` / `data`) `data` edges have light weight and pull their value nodes onto the consumer's layer instead of extending the rail
15
- - **Compound layout** — nodes can declare `parentId`; children are laid out inside their parent's bounding box recursively
16
- - **Sidecar value placement** — value nodes (only data edges) are attached to the flanks of their consumer, picked from the consumer's handle side
17
- - **Component packing** disjoint components are packed along the order axis for a square-ish aspect ratio
18
- - **Rotation proposals** — when handle orientation doesn't match the chosen direction (or the value's sidecar slot), the engine asks the application via `onProposal` whether to rotate
19
- - **Handle-aware** every node has multiple input/output handles with `top`/`right`/`bottom`/`left` positions and configurable offsets
20
- - **Orthogonal edge routing** through dummy waypoints
21
- - **Incremental layout** keep position stability across edits via `IncrementalLayout`
22
- - **4 layout directions** — `TB`, `LR`, `BT`, `RL`
23
- - **Cycle support** back edges are detected and reversed automatically
24
- - **Scales** — ~150 ms on 1000 nodes; merge-sort based crossing counting; adaptive iteration cap on large graphs
50
+ - **Zero runtime dependencies** — pure TypeScript, nothing else.
51
+ - **Strict-axis Sugiyama** — the chosen reading direction (`TB`/`LR`/`BT`/`RL`)
52
+ is honored end to end. Compacity comes from packing and sidecars, never from
53
+ silent local re-orientation.
54
+ - **Typed edges** — `control` defines the execution rail; `data` is a weak
55
+ link that pulls value nodes onto the consumer's layer.
56
+ - **Mixed-orientation layout** — main rail along one axis, value sidecars on
57
+ the perpendicular flanks. See dedicated section below.
58
+ - **Compound layout** nodes can declare `parentId`; children are laid out
59
+ inside their parent's bounding box, recursively, at any depth.
60
+ - **Sidecar value placement** — value nodes (only data edges) are attached to
61
+ the flanks of their dominant consumer; the side is picked from the
62
+ consumer's handle position.
63
+ - **Component packing** — disjoint components are packed along the order axis
64
+ for a square-ish aspect ratio.
65
+ - **Handle proposals** — when handle orientation doesn't match the planned
66
+ placement, the engine asks the application via `onProposal` whether to fix
67
+ it. Two flavors are emitted:
68
+ - `rotate` — rotate every handle on the node by 90°/-90°/180°.
69
+ - `relocate-handles` — strategically move **each handle individually** to
70
+ the side that points toward its actual neighbor (computed from a real
71
+ layout preview). The app accepts, modifies, or rejects.
72
+ - **Handle-aware** — every node has multiple input/output handles with
73
+ `top`/`right`/`bottom`/`left` positions and `offset` along the side.
74
+ - **Orthogonal edge routing** through dummy waypoints.
75
+ - **Incremental layout** — `IncrementalLayout` keeps position stability across
76
+ edits (existing positions are blended with the new ones).
77
+ - **Cycle support** — back edges are detected and reversed automatically.
78
+ - **Scales** — ~150 ms for 1 000 nodes; merge-sort based crossing counting;
79
+ adaptive iteration cap on large graphs.
25
80
 
26
81
  ## Performance
27
82
 
28
- Measured on a standard dev machine (Node 22, vitest run):
83
+ Measured on a standard dev machine (Node 24, single-threaded JS):
29
84
 
30
- | Graph size | Time |
31
- |----------------------------------|--------|
32
- | 100 nodes, 737 edges (dense) | ~70 ms |
33
- | 200 nodes | ~25 ms |
34
- | 500 nodes | ~90 ms |
35
- | 1000 nodes | ~150 ms|
85
+ | Graph size | balanced | draft |
86
+ |----------------------------------|---------:|--------:|
87
+ | 100 nodes | ~9 ms | ~4 ms |
88
+ | 200 nodes | ~17 ms | ~13 ms |
89
+ | 500 nodes | ~36 ms | ~24 ms |
90
+ | 1 000 nodes | ~25 ms | ~18 ms |
91
+
92
+ Optimization knobs you can use today:
93
+
94
+ - **`quality: 'draft' | 'balanced' | 'high'`** — preset. `'draft'` is ~2–3×
95
+ faster (skips transpose, fewer iterations); `'high'` does more iterations
96
+ for dense graphs. Default is `'balanced'`.
97
+ - **`skipTranspose: true`** — skip the per-layer transpose pass
98
+ unconditionally (the largest single cost in balanced mode).
99
+ - **`crossingMinimizationIterations`**, **`coordinateOptimizationIterations`**
100
+ — explicit overrides that win over the preset.
101
+ - **Web Worker** — `LayoutInput` and `LayoutResult` are plain JSON, so
102
+ `layout()` runs cleanly off the main thread via `postMessage`.
103
+
104
+ See [docs/PERFORMANCE.md](docs/PERFORMANCE.md) for full benchmarks, worker
105
+ patterns, and notes on plugging a WASM or WebGPU backend.
36
106
 
37
107
  ## Installation
38
108
 
@@ -40,6 +110,8 @@ Measured on a standard dev machine (Node 22, vitest run):
40
110
  npm install @nodius/layouting
41
111
  ```
42
112
 
113
+ ---
114
+
43
115
  ## Quick start
44
116
 
45
117
  ```ts
@@ -59,8 +131,8 @@ const result = layout({
59
131
  ]},
60
132
  ],
61
133
  edges: [
62
- { id: 'e1', from: 'src', to: 'transform', fromHandle: 'out', toHandle: 'in' },
63
- { id: 'e2', from: 'transform', to: 'sink', fromHandle: 'out', toHandle: 'in' },
134
+ { id: 'e1', from: 'src', to: 'transform', fromHandle: 'out', toHandle: 'in' },
135
+ { id: 'e2', from: 'transform', to: 'sink', fromHandle: 'out', toHandle: 'in' },
64
136
  ],
65
137
  }, { direction: 'TB' });
66
138
 
@@ -68,14 +140,73 @@ const result = layout({
68
140
  // result.edges → routed edges with waypoint arrays and `kind`
69
141
  ```
70
142
 
71
- ## Typed edges and value nodes
143
+ ---
144
+
145
+ ## Concepts
146
+
147
+ A complete mental model lives in five concepts:
148
+
149
+ | Concept | What it is | Where it shows in the API |
150
+ |-------------|------------------------------------------------------------------------|--------------------------------------|
151
+ | Node | A box on the canvas with a fixed `width` × `height`. | `NodeInput` |
152
+ | Handle | A typed connection point on one side of a node (4 sides, with offset).| `NodeInput.handles[]` |
153
+ | Edge | A connection from one node's handle to another's, with a `kind`. | `EdgeInput` |
154
+ | Rail | The subgraph induced by `control` edges. Drives layer assignment. | implicit — emerges from edge kinds |
155
+ | Value | A node whose every incident edge is `data`. Lands as a sidecar. | implicit — emerges from edge kinds |
156
+ | Compound | A node referenced as `parentId` by other nodes — contains them. | `NodeInput.parentId` |
157
+
158
+ The output mirrors the input, with absolute coordinates and routed edge
159
+ points; `parentId` is echoed back so you can rebuild the hierarchy in your
160
+ renderer.
161
+
162
+ ---
163
+
164
+ ## Mixed orientation: rail + sidecars
165
+
166
+ This is the feature that sets `@nodius/layouting` apart, and the one most
167
+ worth understanding deeply. **The chosen direction is the rail direction.
168
+ Values sit on the perpendicular axis** — so a `TB` layout naturally extends
169
+ sideways for values, a `LR` layout naturally extends top/bottom.
170
+
171
+ ### Why it matters
172
+
173
+ A pure top-to-bottom layout that treats every node identically ends up as a
174
+ long vertical ribbon: configs above the function that uses them, constants
175
+ strewn between two business steps, etc. The graph reads top-to-bottom but
176
+ also "skips" all over. With mixed orientation:
177
+
178
+ ```
179
+ Start Start
180
+ | |
181
+ v v
182
+ fetch vs. [API_KEY] fetch [TIMEOUT]
183
+ / \ |
184
+ API_KEY TIMEOUT Parse
185
+ \ / |
186
+ Parse Done
187
+ |
188
+ v
189
+ Done
190
+ ```
191
+
192
+ The rail (`Start → fetch → Parse → Done`) stays a single, tight vertical
193
+ line. The values (`API_KEY`, `TIMEOUT`) attach **horizontally** to the
194
+ consumer (`fetch`). The graph is now square-ish and easy to read.
195
+
196
+ ### How it works
197
+
198
+ 1. Edges declare a `kind`: `'control'` or `'data'`. Defaults to `'control'`.
199
+ 2. Layer assignment runs on the **control rail only**. Values don't extend
200
+ the rail.
201
+ 3. After the rail is fully placed, **value sidecars** are attached to the
202
+ flanks of their dominant consumer. The side (left vs. right in TB, top vs.
203
+ bottom in LR) is chosen from the *consumer's handle position*:
204
+ - consumer handle on `left` → value sits on the left flank
205
+ - consumer handle on `right` → value sits on the right flank
206
+ 4. If multiple values share one consumer, they stack outward, ordered by
207
+ each value's connecting handle offset.
72
208
 
73
- Edges accept a `kind` of `'control'` (default) or `'data'`. **Control edges**
74
- define the execution rail and drive layer assignment. **Data edges** are weak
75
- links — their endpoints are pulled onto the consumer's layer, and a node whose
76
- every incident edge is a data edge becomes a **value**: it is attached as a
77
- sidecar to the flank of its dominant consumer rather than living inside the
78
- rail.
209
+ ### Self-contained worked example
79
210
 
80
211
  ```ts
81
212
  import { layout } from '@nodius/layouting';
@@ -87,104 +218,653 @@ const result = layout({
87
218
  ]},
88
219
  { id: 'fetch', width: 140, height: 70, handles: [
89
220
  { id: 'in', type: 'input', position: 'top' },
221
+ // Two value-input handles on the LEFT flank …
90
222
  { id: 'key', type: 'input', position: 'left', offset: 0.3 },
91
223
  { id: 'url', type: 'input', position: 'left', offset: 0.7 },
224
+ // … and one on the RIGHT flank.
225
+ { id: 'tm', type: 'input', position: 'right' },
92
226
  { id: 'out', type: 'output', position: 'bottom' },
93
227
  ]},
94
- { id: 'API_KEY', width: 100, height: 40, handles: [
228
+ { id: 'API_KEY', width: 90, height: 40, handles: [
95
229
  { id: 'out', type: 'output', position: 'right' },
96
230
  ]},
97
- { id: 'BASE_URL', width: 100, height: 40, handles: [
231
+ { id: 'BASE_URL', width: 90, height: 40, handles: [
98
232
  { id: 'out', type: 'output', position: 'right' },
99
233
  ]},
234
+ { id: 'TIMEOUT', width: 90, height: 40, handles: [
235
+ { id: 'out', type: 'output', position: 'left' },
236
+ ]},
100
237
  { id: 'Done', width: 100, height: 50, handles: [
101
238
  { id: 'in', type: 'input', position: 'top' },
102
239
  ]},
103
240
  ],
104
241
  edges: [
105
- { id: 'c1', from: 'Start', to: 'fetch', fromHandle: 'out', toHandle: 'in', kind: 'control' },
106
- { id: 'c2', from: 'fetch', to: 'Done', fromHandle: 'out', toHandle: 'in', kind: 'control' },
242
+ { id: 'c1', from: 'Start', to: 'fetch', fromHandle: 'out', toHandle: 'in', kind: 'control' },
243
+ { id: 'c2', from: 'fetch', to: 'Done', fromHandle: 'out', toHandle: 'in', kind: 'control' },
107
244
  { id: 'd1', from: 'API_KEY', to: 'fetch', fromHandle: 'out', toHandle: 'key', kind: 'data' },
108
245
  { id: 'd2', from: 'BASE_URL', to: 'fetch', fromHandle: 'out', toHandle: 'url', kind: 'data' },
246
+ { id: 'd3', from: 'TIMEOUT', to: 'fetch', fromHandle: 'out', toHandle: 'tm', kind: 'data' },
109
247
  ],
248
+ }, { direction: 'TB' });
249
+ ```
250
+
251
+ After layout:
252
+
253
+ - `Start.center.x ≈ fetch.center.x ≈ Done.center.x` — the rail is one line.
254
+ - `API_KEY.center.y ≈ BASE_URL.center.y ≈ TIMEOUT.center.y ≈ fetch.center.y` —
255
+ values sit at fetch's vertical band.
256
+ - `API_KEY` and `BASE_URL` end up on the **left** flank (their consumer
257
+ handles are on `left`); `TIMEOUT` ends up on the **right** flank.
258
+
259
+ ### What if my values have "wrong" handles?
260
+
261
+ Use `onProposal: p => p.proposed` to let the engine auto-fix them. See
262
+ [Rotation proposals](#rotation-proposals-onproposal) below.
263
+
264
+ ---
265
+
266
+ ## Typed edges: control vs data
267
+
268
+ ```ts
269
+ type EdgeKind = 'control' | 'data';
270
+ ```
271
+
272
+ | Kind | Weight | Effect on layout |
273
+ |-----------|---------|----------------------------------------------------------------------------------------------------|
274
+ | `control` | 1 | Drives layer assignment. Dummy nodes are inserted for multi-layer spans. Crossings counted. |
275
+ | `data` | 0.25 | Light link. Pulls value nodes onto their consumer's layer. No dummies — routed directly. |
276
+
277
+ Default is `'control'`. You can override defaults globally:
278
+
279
+ ```ts
280
+ layout(input, {
281
+ edgeWeights: { control: 1, data: 0.1 }, // make data even lighter
110
282
  });
111
- // Start, fetch and Done line up vertically. API_KEY and BASE_URL sit on
112
- // fetch's left flank, at fetch's vertical band — they don't extend the rail.
113
283
  ```
114
284
 
285
+ Or per-edge:
286
+
287
+ ```ts
288
+ { id: 'd1', from: 'A', to: 'B', fromHandle: 'o', toHandle: 'i',
289
+ kind: 'data', weight: 0.5 } // explicit override
290
+ ```
291
+
292
+ **Use `control` for**: execution flow, sequencing, "happens before" relationships, error branches.
293
+
294
+ **Use `data` for**: configuration, constants, environment imports,
295
+ secondary inputs that don't define order — anything you'd put as a `prop`
296
+ rather than a `step`.
297
+
298
+ ---
299
+
300
+ ## Value nodes & sidecars
301
+
302
+ A node is automatically classified as a **value** when **every** edge touching
303
+ it is of kind `'data'`. Once classified:
304
+
305
+ 1. It is excluded from the control rail layer assignment.
306
+ 2. Its target layer becomes the **median layer of its non-value neighbors**
307
+ (typically the layer of its consumer).
308
+ 3. After the rail is laid out, the value is **attached as a sidecar** to its
309
+ dominant consumer:
310
+ - in `TB`/`BT`: on the consumer's left or right flank
311
+ - in `LR`/`RL`: above or below the consumer
312
+ 4. The side is chosen from the consumer's handle position. Multiple values
313
+ targeting the same consumer stack outward.
314
+
315
+ ### Sidecar side picking — by example
316
+
317
+ ```
318
+ Consumer with the data-input handle on the LEFT (TB direction):
319
+
320
+ consumer.handles = [
321
+ { id: 'in', type: 'input', position: 'top' },
322
+ { id: 'cfg', type: 'input', position: 'left' }, ← value handle
323
+ ]
324
+ edge value → consumer via 'cfg'
325
+
326
+ Result: [ value ]——[ consumer ]
327
+ |
328
+ v
329
+ ```
330
+
331
+ ```
332
+ Three values on the LEFT, one on the RIGHT:
333
+
334
+ Layout output (TB direction):
335
+
336
+ [ V3 ][ V1 ][ consumer ][ V4 ]
337
+ [ V2 ]——┘
338
+ ```
339
+
340
+ Values on the same side are sorted by their handle offset on the consumer, so
341
+ stacking order is predictable and matches the visual order of the consumer's
342
+ handles.
343
+
344
+ ### Multiple consumers per value
345
+
346
+ If a value points to multiple non-value nodes, the engine picks the one with
347
+ the most edges to that value (or alphabetical id as tie-breaker). The value
348
+ becomes a sidecar of that one. Other edges are routed normally.
349
+
350
+ ### Isolated values (no consumer)
351
+
352
+ Rare — they go in a corner. If you see one, it usually means an edge points to
353
+ something that was filtered out.
354
+
355
+ ---
356
+
115
357
  ## Compound (nested) layout
116
358
 
117
- Set `parentId` on any node to make it a child of another. Children are laid out
118
- inside their parent's bounding box; the parent grows to fit them plus an
119
- optional padding and a header strip for its own label.
359
+ Set `parentId` on any node to make it a child of another. The parent grows to
360
+ fit its children plus padding and a header strip for its own label.
120
361
 
121
362
  ```ts
122
363
  const result = layout({
123
364
  nodes: [
124
365
  { id: 'Start', width: 110, height: 50, handles: [...] },
366
+ // The compound — its width/height in the input are MINIMUMS;
367
+ // the engine inflates them to fit children.
125
368
  { id: 'parallel', width: 220, height: 140, handles: [
126
369
  { id: 'in', type: 'input', position: 'top' },
127
370
  { id: 'out', type: 'output', position: 'bottom' },
128
371
  ]},
129
372
  { id: 'fetch users', parentId: 'parallel', width: 130, height: 50, handles: [...] },
130
373
  { id: 'fetch orders', parentId: 'parallel', width: 130, height: 50, handles: [...] },
131
- { id: 'End', width: 100, height: 50, handles: [...] },
374
+ { id: 'End', width: 100, height: 50, handles: [...] },
132
375
  ],
133
376
  edges: [
134
- { id: 'c1', from: 'Start', to: 'parallel', fromHandle: 'out', toHandle: 'in', kind: 'control' },
135
- { id: 'c2', from: 'parallel', to: 'End', fromHandle: 'out', toHandle: 'in', kind: 'control' },
377
+ { id: 'c1', from: 'Start', to: 'parallel', fromHandle: 'out', toHandle: 'in', kind: 'control' },
378
+ { id: 'c2', from: 'parallel', to: 'End', fromHandle: 'out', toHandle: 'in', kind: 'control' },
136
379
  ],
137
380
  });
381
+ ```
382
+
383
+ After layout:
138
384
 
139
- // Output nodes carry `parentId` so you can reconstruct the hierarchy in your renderer.
385
+ - `fetch users` and `fetch orders` are placed **inside** `parallel`'s
386
+ bounding box, side by side.
387
+ - `parallel` is positioned in the rail just like any other node — its size is
388
+ big enough to contain its children.
389
+ - `End` sits below the entire `parallel` block.
390
+ - Each output node has `parentId` echoed (`'parallel'` for the children).
391
+
392
+ ### Nesting
393
+
394
+ Compounds can be nested arbitrarily — children of a compound can themselves
395
+ be compounds with their own children. The engine processes them deepest-first
396
+ so the size at each level reflects the full subtree below it.
397
+
398
+ ### Edges across the compound boundary
399
+
400
+ - Edges between the compound itself and its siblings (e.g. `Start → parallel`)
401
+ are routed at the root level.
402
+ - Edges between children of the same compound (e.g. `transform → enrich`
403
+ inside a `map` compound) are routed inside the compound.
404
+ - Edges that "escape" a compound (a child connecting to something outside)
405
+ are not yet a first-class feature — model them with explicit handles on the
406
+ compound itself.
407
+
408
+ ### Tuning the padding
409
+
410
+ ```ts
411
+ layout(input, { compoundPadding: 24 }); // default 24
140
412
  ```
141
413
 
142
- ## Rotation proposals (`onProposal`)
414
+ The header strip (28 px) is reserved at the top of every compound for its own
415
+ label. The padding wraps the children on all four sides.
143
416
 
144
- When a node's handles don't match how the engine plans to place it, it emits a
145
- **rotation proposal**. The application decides what to do:
417
+ ---
418
+
419
+ ## Component packing
420
+
421
+ Disjoint components are packed side-by-side along the order axis (perpendicular
422
+ to the flow direction). This keeps the aspect ratio square-ish instead of
423
+ producing a long ribbon.
424
+
425
+ ```ts
426
+ layout(input, { packComponents: true }); // default
427
+ layout(input, { packComponents: false }); // each component starts from 0
428
+ ```
429
+
430
+ Packing respects compound groups — a compound and all of its children always
431
+ move as one block. Components are sorted largest-first along the rank axis so
432
+ the dominant flow leads the layout.
433
+
434
+ ---
435
+
436
+ ## Handle proposals (`onProposal`)
437
+
438
+ When a node's handles don't match how the engine plans to place it, the engine
439
+ **emits a proposal** to the application. The application decides what to do
440
+ with it.
441
+
442
+ ### The signature
443
+
444
+ ```ts
445
+ type ProposalCallback = (
446
+ proposal: LayoutProposal,
447
+ ) => NodeInput | null | undefined | void;
448
+
449
+ type LayoutProposal = RotateProposal | RelocateHandlesProposal;
450
+
451
+ interface RotateProposal {
452
+ type: 'rotate';
453
+ nodeId: string;
454
+ current: NodeInput; // the original node
455
+ proposed: NodeInput; // every handle rotated by `rotation`
456
+ rotation: 90 | -90 | 180; // degrees clockwise
457
+ reason: string;
458
+ }
459
+
460
+ interface RelocateHandlesProposal {
461
+ type: 'relocate-handles';
462
+ nodeId: string;
463
+ current: NodeInput; // the original node
464
+ proposed: NodeInput; // each affected handle moved to its optimal side
465
+ changes: Record<string, { from: HandleSide; to: HandleSide }>;
466
+ reason: string; // e.g. "out_a: top→bottom, out_b: top→right"
467
+ }
468
+ ```
469
+
470
+ Return value (same for both types):
471
+
472
+ | Returned | Meaning |
473
+ |----------------------|--------------------------------------------------------|
474
+ | `proposal.proposed` | Accept as-is. |
475
+ | Modified `NodeInput` | Accept with tweaks (keep some handles untouched). |
476
+ | `null` / nothing | Reject — use the original node. |
477
+
478
+ ### Rotate vs relocate
479
+
480
+ Both fix mis-placed handles, but at different granularities:
481
+
482
+ | Proposal | Granularity | Driven by | Best for |
483
+ |--------------------|-------------|------------------------|-------------------------------------------|
484
+ | `rotate` | Whole node | Direction only | Symmetric nodes, simple graphs |
485
+ | `relocate-handles` | Per handle | Real neighbor geometry | Multi-output nodes, mixed orientations |
486
+
487
+ The engine runs **both passes**, in this order:
488
+
489
+ 1. **Rotate pass** — direction-only check (no preview needed). Emits a single
490
+ rotation if every handle is wrong for the chosen direction.
491
+ 2. **Relocate pass** — runs a *preview layout* with current handles to see
492
+ where every neighbor actually ends up, then proposes, per handle, the side
493
+ that points to its neighbor's center.
494
+
495
+ The relocate pass is a strict superset of rotate: it can move two handles in
496
+ opposite directions, leave correctly-placed ones alone, or split a single
497
+ node's outputs across all four sides. It costs **one extra layout pass** but
498
+ only when `onProposal` is provided.
499
+
500
+ ### When does the rotate pass emit a proposal?
501
+
502
+ #### Rail nodes (any node with at least one control edge)
503
+
504
+ Expected handle layout for the chosen direction:
505
+
506
+ | Direction | Inputs on | Outputs on |
507
+ |-----------|-----------|------------|
508
+ | `TB` | top | bottom |
509
+ | `BT` | bottom | top |
510
+ | `LR` | left | right |
511
+ | `RL` | right | left |
512
+
513
+ If a node has handles facing the wrong axis (e.g. `top`/`bottom` handles in a
514
+ `LR` graph), the engine proposes the rotation that maximizes alignment.
515
+
516
+ #### Value nodes (every incident edge is data)
517
+
518
+ Values land **opposite** the consumer's handle. Expected output:
519
+
520
+ | Direction | Consumer handle on | Value output expected |
521
+ |-----------|--------------------|------------------------|
522
+ | `TB`/`BT` | `left` | `right` |
523
+ | `TB`/`BT` | `right` | `left` |
524
+ | `LR`/`RL` | `top` | `bottom` |
525
+ | `LR`/`RL` | `bottom` | `top` |
526
+
527
+ This is what makes the "I defined my value with a `bottom` handle by reflex,
528
+ but it sits horizontally — please fix it" case work transparently.
529
+
530
+ ### Strategic per-handle placement (`relocate-handles`)
531
+
532
+ This pass is **the killer feature** for graphs where a single node has
533
+ neighbors in multiple directions. Examples that motivate it:
534
+
535
+ - A central **hub** with control edges flowing top↔bottom AND value sidecars
536
+ on the flanks. A simple rotation cannot satisfy both axes.
537
+ - A **broker / pub-sub** node where publishers feed it from one side and
538
+ subscribers from the other.
539
+ - A **bidirectional mesh** of services where each pair has a request/reply
540
+ exchange across multiple handles.
541
+ - A pipeline whose handles were **defined randomly** and need each handle
542
+ fixed individually.
543
+
544
+ Mechanism:
545
+
546
+ 1. The engine lays out the graph **once** with the input handles to discover
547
+ where every node actually ends up.
548
+ 2. For each node with at least one connected handle, each handle is assigned a
549
+ side using a **flow-aware** rule rather than raw center-to-center direction:
550
+ - A neighbor in a **different rank band** (a layout predecessor / successor)
551
+ gets a handle on the **flow axis** — top/bottom in TB, left/right in LR —
552
+ *even when it is offset sideways*. This is what keeps a hub's children on
553
+ the bottom edge instead of fanning chaotically onto its flanks.
554
+ - A neighbor that **overlaps the same rank band** (a true sidecar / sibling)
555
+ gets a handle on the **perpendicular axis** (the flank facing it).
556
+ - **Lopsided correction:** when a node's flow neighbors all sit on one side
557
+ of its order-center (a one-directional fan), the nearest one stays on the
558
+ flow edge and farther outliers move to the flank — they would otherwise
559
+ crowd and cross. A *balanced* fan (children on both sides) stays entirely
560
+ on the flow edge.
561
+ Handles serving several edges still vote per side, weighted by `1 / distance`
562
+ so the closest neighbor wins.
563
+ 3. When two or more handles end up on the **same side**, their offsets are
564
+ permuted to match the order of their neighbors along that edge — so the
565
+ edges leaving a shared side never cross each other.
566
+ 4. If any handle would change side or offset, it bundles all the moves into one
567
+ `RelocateHandlesProposal` for that node — with a `changes` map you can
568
+ inspect to see exactly which sides would move.
569
+
570
+ #### Example: a multi-handle hub
146
571
 
147
572
  ```ts
148
573
  import { layout } from '@nodius/layouting';
149
574
 
575
+ const input = {
576
+ nodes: [
577
+ { id: 'Trigger', width: 110, height: 50, handles: [
578
+ { id: 'go', type: 'output', position: 'bottom', offset: 0.5 },
579
+ ]},
580
+ // Hub: every handle defined on 'bottom' on purpose.
581
+ { id: 'Hub', width: 220, height: 100, handles: [
582
+ { id: 'in', type: 'input', position: 'bottom', offset: 0.5 },
583
+ { id: 'out', type: 'output', position: 'bottom', offset: 0.5 },
584
+ { id: 'lg', type: 'output', position: 'bottom', offset: 0.2 },
585
+ { id: 'rg', type: 'output', position: 'bottom', offset: 0.4 },
586
+ { id: 'ldata', type: 'input', position: 'bottom', offset: 0.6 },
587
+ { id: 'rdata', type: 'input', position: 'bottom', offset: 0.8 },
588
+ ]},
589
+ { id: 'Continue', width: 110, height: 50, handles: [
590
+ { id: 'in', type: 'input', position: 'top', offset: 0.5 },
591
+ ]},
592
+ { id: 'LogA', width: 90, height: 40, handles: [{ id: 'in', type: 'input', position: 'right', offset: 0.5 }]},
593
+ { id: 'LogB', width: 90, height: 40, handles: [{ id: 'in', type: 'input', position: 'left', offset: 0.5 }]},
594
+ { id: 'CONF_A', width: 90, height: 40, handles: [{ id: 'out', type: 'output', position: 'right', offset: 0.5 }]},
595
+ { id: 'CONF_B', width: 90, height: 40, handles: [{ id: 'out', type: 'output', position: 'left', offset: 0.5 }]},
596
+ ],
597
+ edges: [
598
+ { id: 'c0', from: 'Trigger', to: 'Hub', fromHandle: 'go', toHandle: 'in', kind: 'control' },
599
+ { id: 'c1', from: 'Hub', to: 'Continue', fromHandle: 'out', toHandle: 'in', kind: 'control' },
600
+ { id: 'l1', from: 'Hub', to: 'LogA', fromHandle: 'lg', toHandle: 'in', kind: 'data' },
601
+ { id: 'l2', from: 'Hub', to: 'LogB', fromHandle: 'rg', toHandle: 'in', kind: 'data' },
602
+ { id: 'd1', from: 'CONF_A', to: 'Hub', fromHandle: 'out', toHandle: 'ldata', kind: 'data' },
603
+ { id: 'd2', from: 'CONF_B', to: 'Hub', fromHandle: 'out', toHandle: 'rdata', kind: 'data' },
604
+ ],
605
+ };
606
+
607
+ const result = layout(input, {
608
+ direction: 'TB',
609
+ onProposal: (p) => p.proposed, // accept everything
610
+ });
611
+ ```
612
+
613
+ After auto-fix, the Hub's six handles each land on the correct side:
614
+
615
+ | Handle | Neighbor | Original | Relocated |
616
+ |-----------|-----------------|----------|-----------|
617
+ | `in` | Trigger (above) | `bottom` | `top` |
618
+ | `out` | Continue (below)| `bottom` | `bottom` |
619
+ | `lg` | LogA (left) | `bottom` | `left` |
620
+ | `rg` | LogB (right) | `bottom` | `right` |
621
+ | `ldata` | CONF_A (left) | `bottom` | `left` |
622
+ | `rdata` | CONF_B (right) | `bottom` | `right` |
623
+
624
+ A single `rotate` proposal could not have produced this — the six handles
625
+ need to spread across four different sides, which is exactly what
626
+ `relocate-handles` does.
627
+
628
+ ### Three patterns of use
629
+
630
+ **1. Accept everything (the easy mode):**
631
+
632
+ ```ts
150
633
  layout(input, {
151
634
  direction: 'LR',
152
- // Accept every proposal and use the engine-suggested rotation:
153
635
  onProposal: (p) => p.proposed,
636
+ });
637
+ ```
154
638
 
155
- // Or filter: only accept rotations for nodes you own
156
- // onProposal: (p) => p.nodeId.startsWith('mine_') ? p.proposed : null,
639
+ **2. Observe without applying useful for logging/debugging:**
157
640
 
158
- // Or partial: rotate only some handles
159
- // onProposal: (p) => ({
160
- // ...p.current,
161
- // handles: p.current.handles.map(h => h.id === 'main' ? { ...h, position: 'right' } : h),
162
- // }),
641
+ ```ts
642
+ const log: LayoutProposal[] = [];
643
+ layout(input, {
644
+ onProposal: (p) => { log.push(p); return null; },
163
645
  });
646
+
647
+ for (const p of log) {
648
+ if (p.type === 'rotate') {
649
+ console.log(`[rotate] ${p.nodeId} by ${p.rotation}°`);
650
+ } else {
651
+ const moves = Object.entries(p.changes)
652
+ .map(([h, c]) => `${h}: ${c.from}→${c.to}`).join(', ');
653
+ console.log(`[relocate] ${p.nodeId} — ${moves}`);
654
+ }
655
+ }
164
656
  ```
165
657
 
166
- The engine reasons about two roles:
658
+ **3. Partial accept pick which moves to apply:**
167
659
 
168
- - **Rail nodes** are aligned to the global flow: in `TB`, inputs go on top,
169
- outputs on bottom.
170
- - **Value nodes** are sidecars: their handles must face the consumer. A value
171
- attached to a consumer's `left` handle will sit on the consumer's left
172
- flank, so its output is expected on `right`.
660
+ ```ts
661
+ layout(input, {
662
+ direction: 'TB',
663
+ onProposal: (p) => {
664
+ if (p.type !== 'relocate-handles') return null;
665
+ // Apply only changes whose target is on the left.
666
+ return {
667
+ ...p.current,
668
+ handles: p.current.handles.map(h => {
669
+ const change = p.changes[h.id];
670
+ if (change && change.to === 'left') {
671
+ return { ...h, position: 'left' };
672
+ }
673
+ return h;
674
+ }),
675
+ };
676
+ },
677
+ });
678
+ ```
173
679
 
174
- Each proposal includes the original node, the rotated proposal, the rotation
175
- angle (`90` / `-90` / `180`), and a human-readable reason.
680
+ ### Helper: `rotateHandles`
176
681
 
177
- ## Component packing
682
+ If you build your own rotation logic, import the same utility the engine uses:
178
683
 
179
- By default disjoint components are packed side-by-side along the order axis.
180
- Disable with `packComponents: false`. Packing respects compound groups — a
181
- compound and its children always travel as one block.
684
+ ```ts
685
+ import { rotateHandles } from '@nodius/layouting';
686
+
687
+ const rotated = rotateHandles(node.handles, 90);
688
+ // rotation in {90, -90, 180}, clockwise. Offsets are preserved.
689
+ ```
690
+
691
+ ---
692
+
693
+ ## Cookbook — recipes for common patterns
694
+
695
+ ### Linear pipeline with constants
182
696
 
183
697
  ```ts
184
- layout(input, { packComponents: true }); // default
185
- layout(input, { packComponents: false }); // long ribbon
698
+ import { layout } from '@nodius/layouting';
699
+
700
+ layout({
701
+ nodes: [
702
+ { id: 'load', width: 120, height: 50, handles: [
703
+ { id: 'in', type: 'input', position: 'top' },
704
+ { id: 'cfg', type: 'input', position: 'left' },
705
+ { id: 'out', type: 'output', position: 'bottom' },
706
+ ]},
707
+ { id: 'CONFIG', width: 100, height: 40, handles: [
708
+ { id: 'out', type: 'output', position: 'right' },
709
+ ]},
710
+ { id: 'save', width: 120, height: 50, handles: [
711
+ { id: 'in', type: 'input', position: 'top' },
712
+ ]},
713
+ ],
714
+ edges: [
715
+ { id: 'c', from: 'load', to: 'save', fromHandle: 'out', toHandle: 'in', kind: 'control' },
716
+ { id: 'd', from: 'CONFIG', to: 'load', fromHandle: 'out', toHandle: 'cfg', kind: 'data' },
717
+ ],
718
+ }, { direction: 'TB' });
186
719
  ```
187
720
 
721
+ Result: `load → save` vertically, `CONFIG` to the left of `load`.
722
+
723
+ ### Parallel branches (Promise.all)
724
+
725
+ ```ts
726
+ layout({
727
+ nodes: [
728
+ { id: 'Start', width: 100, height: 50, handles: [
729
+ { id: 'out', type: 'output', position: 'bottom' },
730
+ ]},
731
+ { id: 'parallel', width: 240, height: 130, handles: [
732
+ { id: 'in', type: 'input', position: 'top' },
733
+ { id: 'out', type: 'output', position: 'bottom' },
734
+ ]},
735
+ { id: 'a', parentId: 'parallel', width: 100, height: 50, handles: [
736
+ { id: 'in', type: 'input', position: 'top' },
737
+ { id: 'out', type: 'output', position: 'bottom' },
738
+ ]},
739
+ { id: 'b', parentId: 'parallel', width: 100, height: 50, handles: [
740
+ { id: 'in', type: 'input', position: 'top' },
741
+ { id: 'out', type: 'output', position: 'bottom' },
742
+ ]},
743
+ { id: 'Done', width: 100, height: 50, handles: [
744
+ { id: 'in', type: 'input', position: 'top' },
745
+ ]},
746
+ ],
747
+ edges: [
748
+ { id: 'c1', from: 'Start', to: 'parallel', fromHandle: 'out', toHandle: 'in', kind: 'control' },
749
+ { id: 'c2', from: 'parallel', to: 'Done', fromHandle: 'out', toHandle: 'in', kind: 'control' },
750
+ ],
751
+ });
752
+ ```
753
+
754
+ `a` and `b` are placed side by side inside `parallel`'s box. The rail is
755
+ `Start → parallel → Done`.
756
+
757
+ ### Try / Catch
758
+
759
+ Model the happy path inside one compound and the error path inside another.
760
+ A single `try` node with two outputs (`ok` and `err`) branches to either path.
761
+
762
+ ```ts
763
+ layout({
764
+ nodes: [
765
+ { id: 'try', width: 200, height: 120, handles: [
766
+ { id: 'in', type: 'input', position: 'top' },
767
+ { id: 'ok', type: 'output', position: 'bottom', offset: 0.3 },
768
+ { id: 'err', type: 'output', position: 'right' },
769
+ ]},
770
+ // … 'try' children (parentId: 'try') …
771
+ { id: 'catch', width: 180, height: 100, handles: [
772
+ { id: 'in', type: 'input', position: 'top' },
773
+ { id: 'out', type: 'output', position: 'bottom' },
774
+ ]},
775
+ // … 'catch' children (parentId: 'catch') …
776
+ ],
777
+ edges: [
778
+ { id: 'ok_path', from: 'try', to: 'End', fromHandle: 'ok', toHandle: 'in', kind: 'control' },
779
+ { id: 'err_path', from: 'try', to: 'catch', fromHandle: 'err', toHandle: 'in', kind: 'control' },
780
+ // …
781
+ ],
782
+ });
783
+ ```
784
+
785
+ See the `Try / Catch (compound)` example in the playground for a complete
786
+ version.
787
+
788
+ ### Switch / Case
789
+
790
+ A single dispatcher with one output per branch. Each branch can be a single
791
+ node or a compound.
792
+
793
+ ```ts
794
+ const dispatcher = {
795
+ id: 'method?', width: 100, height: 50, handles: [
796
+ { id: 'in', type: 'input', position: 'top' },
797
+ { id: 'GET', type: 'output', position: 'bottom', offset: 0.2 },
798
+ { id: 'POST', type: 'output', position: 'bottom', offset: 0.4 },
799
+ { id: 'PUT', type: 'output', position: 'bottom', offset: 0.6 },
800
+ { id: 'DEL', type: 'output', position: 'bottom', offset: 0.8 },
801
+ ],
802
+ };
803
+ ```
804
+
805
+ See the `Switch / Case` example in the playground.
806
+
807
+ ### Map / Reduce pipeline with seeded reducer
808
+
809
+ Combine a compound (`map`) with a sidecar value (`SEED`) on the reducer.
810
+
811
+ See the `Map / Reduce pipeline` example in the playground.
812
+
813
+ ### HTTP middleware chain with config values
814
+
815
+ Every middleware in a vertical rail, each one with a `data`-edge constant on
816
+ the left, plus a side-effect (`audit`) on the right.
817
+
818
+ See the `HTTP middleware chain` example in the playground.
819
+
820
+ ### Disjoint components
821
+
822
+ Just emit them and let `packComponents` (default on) place them side by side.
823
+
824
+ ```ts
825
+ layout({
826
+ nodes: [...nodesA, ...nodesB, ...nodesC],
827
+ edges: [...edgesA, ...edgesB, ...edgesC], // each (A,B,C) self-contained
828
+ });
829
+ ```
830
+
831
+ The three components sit side by side, largest first.
832
+
833
+ ### Cycles
834
+
835
+ Cycles are detected during the cycle-breaking phase; the back edges are
836
+ reversed for layout purposes only. The output edge keeps its original `from`
837
+ and `to`; only the routing reflects the reversal.
838
+
839
+ ```ts
840
+ layout({
841
+ nodes: [...],
842
+ edges: [
843
+ { id: 'a', from: 'A', to: 'B', ... },
844
+ { id: 'b', from: 'B', to: 'A', ... }, // ← back edge, reversed internally
845
+ ],
846
+ });
847
+ ```
848
+
849
+ ### Migrating from a "flat" graph
850
+
851
+ If you already have a working layout with all edges as control, you can adopt
852
+ typed edges incrementally:
853
+
854
+ ```ts
855
+ const result = layout({
856
+ nodes,
857
+ edges: edges.map(e => ({
858
+ ...e,
859
+ kind: e.isConfig ? 'data' : 'control', // your own classifier
860
+ })),
861
+ });
862
+ ```
863
+
864
+ The output stays compatible; values just relocate to sidecars.
865
+
866
+ ---
867
+
188
868
  ## API
189
869
 
190
870
  ### `layout(input, options?)`
@@ -196,6 +876,8 @@ routed edges.
196
876
  function layout(input: LayoutInput, options?: LayoutOptions): LayoutResult;
197
877
  ```
198
878
 
879
+ Use this for one-shot layouts: full recompute every time.
880
+
199
881
  ### `IncrementalLayout`
200
882
 
201
883
  Maintains state for incremental updates with position stability:
@@ -204,36 +886,57 @@ Maintains state for incremental updates with position stability:
204
886
  import { IncrementalLayout } from '@nodius/layouting';
205
887
 
206
888
  const inc = new IncrementalLayout({ direction: 'LR' });
889
+
890
+ // Initial layout
207
891
  const r1 = inc.setGraph({ nodes: [...], edges: [...] });
892
+
893
+ // Add things; existing positions are blended 70/30 (new/old).
208
894
  const r2 = inc.addNodes([newNode], [newEdge]);
209
- const r3 = inc.removeNodes(['stale_id']);
210
- inc.addEdges([...]);
211
- inc.removeEdges(['e1']);
212
- inc.getResult();
895
+ const r3 = inc.removeNodes(['stale_id']); // connected edges removed too
896
+ inc.addEdges([{ id: 'e', from: 'A', to: 'B', fromHandle: 'o', toHandle: 'i' }]);
897
+ inc.removeEdges(['e']);
898
+
899
+ inc.getResult(); // current cached LayoutResult | null
213
900
  ```
214
901
 
902
+ Position stability formula (per node, per axis):
903
+ `new_position = 0.7 * fresh_compute + 0.3 * previous_position`.
904
+
215
905
  ### `printLayout(result, options?)`
216
906
 
217
- Render a layout result to a debug-friendly text block: per-Y-band summary,
218
- node hierarchy, edges, overlaps and an optional ASCII grid. Useful when
219
- iterating on layout strategies without opening a browser.
907
+ Debug helper that renders a `LayoutResult` to a text block: per-Y-band
908
+ summary, hierarchy, edges, overlaps, and an optional ASCII grid.
220
909
 
221
910
  ```ts
222
911
  import { layout, printLayout } from '@nodius/layouting';
912
+
223
913
  const r = layout(input);
224
914
  console.log(printLayout(r));
915
+ // Or skip the ASCII grid:
916
+ console.log(printLayout(r, { grid: false }));
225
917
  ```
226
918
 
919
+ Useful when iterating on the algorithm without opening a browser, or when
920
+ writing failing tests where you want to inspect coordinates.
921
+
227
922
  ### `rotateHandles(handles, rotation)`
228
923
 
229
924
  Utility re-exported for applications that build their own handle-rotation
230
- logic in their `onProposal` callback.
925
+ logic in `onProposal`. Rotates each handle's `position` clockwise; `offset`
926
+ is preserved. `rotation` is `90 | -90 | 180`.
231
927
 
232
928
  ```ts
233
929
  import { rotateHandles } from '@nodius/layouting';
234
930
  const rotated = rotateHandles(node.handles, 90);
235
931
  ```
236
932
 
933
+ ### `countAllCrossings(graph, layers)`
934
+
935
+ Internal helper exposed for testing — counts the number of edge crossings
936
+ across all adjacent layers. Mostly useful in your own regression tests.
937
+
938
+ ---
939
+
237
940
  ## Types
238
941
 
239
942
  ### Input
@@ -244,7 +947,7 @@ interface NodeInput {
244
947
  width: number;
245
948
  height: number;
246
949
  handles: HandleInput[];
247
- parentId?: string; // make this node a child of another
950
+ parentId?: string; // make this node a child of another (compound layout)
248
951
  }
249
952
 
250
953
  interface HandleInput {
@@ -261,7 +964,7 @@ interface EdgeInput {
261
964
  fromHandle: string;
262
965
  toHandle: string;
263
966
  kind?: 'control' | 'data'; // default: 'control'
264
- weight?: number; // override (default: 1 control, 0.25 data)
967
+ weight?: number; // default: 1 (control), 0.25 (data)
265
968
  }
266
969
 
267
970
  interface LayoutInput {
@@ -274,24 +977,30 @@ interface LayoutInput {
274
977
 
275
978
  ```ts
276
979
  interface LayoutOptions {
277
- direction?: 'TB' | 'LR' | 'BT' | 'RL'; // Default: 'TB'
278
- nodeSpacing?: number; // Default: 40
279
- layerSpacing?: number; // Default: 60
280
- crossingMinimizationIterations?: number; // Default: 24
281
- coordinateOptimizationIterations?: number;// Default: 8
282
- edgeMargin?: number; // Default: 20
283
-
284
- // Typed edges
980
+ // ─── Direction & spacing ────────────────────────────────────────────
981
+ direction?: 'TB' | 'LR' | 'BT' | 'RL'; // Default: 'TB'
982
+ nodeSpacing?: number; // Default: 40 (px between siblings)
983
+ layerSpacing?: number; // Default: 60 (px between layers)
984
+ edgeMargin?: number; // Default: 20 (entry/exit margin)
985
+
986
+ // ─── Iteration counts ───────────────────────────────────────────────
987
+ crossingMinimizationIterations?: number; // Default: 24 (auto-reduced for >200 nodes)
988
+ coordinateOptimizationIterations?: number; // Default: 8
989
+
990
+ // ─── Typed edges ────────────────────────────────────────────────────
285
991
  edgeWeights?: { control?: number; data?: number };
992
+ // Default: { control: 1, data: 0.25 }
993
+ // Individual edges can override via EdgeInput.weight.
286
994
 
287
- // Component packing
288
- packComponents?: boolean; // Default: true
995
+ // ─── Component packing ──────────────────────────────────────────────
996
+ packComponents?: boolean; // Default: true
289
997
 
290
- // Compound layout
291
- compoundPadding?: number; // Default: 24
998
+ // ─── Compound layout ────────────────────────────────────────────────
999
+ compoundPadding?: number; // Default: 24 (px around children)
292
1000
 
293
- // Proposals
294
- onProposal?: (p: LayoutProposal) => NodeInput | null | undefined | void;
1001
+ // ─── Proposals ──────────────────────────────────────────────────────
1002
+ onProposal?: ProposalCallback;
1003
+ // Called once per node when the engine wants to suggest a rotation.
295
1004
  }
296
1005
  ```
297
1006
 
@@ -310,14 +1019,14 @@ interface NodeOutput {
310
1019
  width: number;
311
1020
  height: number;
312
1021
  handles: HandleOutput[]; // absolute positions
313
- parentId?: string; // echoed from the input
1022
+ parentId?: string; // echoed from the input — keep your hierarchy
314
1023
  }
315
1024
 
316
1025
  interface HandleOutput {
317
1026
  id: string;
318
1027
  type: 'input' | 'output';
319
1028
  position: 'top' | 'right' | 'bottom' | 'left';
320
- x: number;
1029
+ x: number; // absolute, includes node position
321
1030
  y: number;
322
1031
  }
323
1032
 
@@ -327,47 +1036,245 @@ interface EdgeOutput {
327
1036
  to: string;
328
1037
  fromHandle: string;
329
1038
  toHandle: string;
330
- points: Point[]; // ordered waypoints
1039
+ points: Point[]; // ordered waypoints from source to target
331
1040
  kind: 'control' | 'data';
332
1041
  }
333
1042
 
1043
+ interface Point { x: number; y: number; }
1044
+ ```
1045
+
1046
+ ### Proposals
1047
+
1048
+ ```ts
1049
+ type LayoutProposal = RotateProposal | RelocateHandlesProposal;
1050
+
334
1051
  interface RotateProposal {
335
1052
  type: 'rotate';
336
1053
  nodeId: string;
337
- current: NodeInput;
338
- proposed: NodeInput;
339
- rotation: 90 | -90 | 180;
1054
+ current: NodeInput; // unchanged input node
1055
+ proposed: NodeInput; // all handles rotated by `rotation`
1056
+ rotation: 90 | -90 | 180; // clockwise
340
1057
  reason: string;
341
1058
  }
342
1059
 
343
- type LayoutProposal = RotateProposal;
1060
+ interface RelocateHandlesProposal {
1061
+ type: 'relocate-handles';
1062
+ nodeId: string;
1063
+ current: NodeInput; // unchanged input node
1064
+ proposed: NodeInput; // each affected handle moved
1065
+ /** Per-handle changes; only handles whose side differs are present. */
1066
+ changes: Record<string, { from: HandleSide; to: HandleSide }>;
1067
+ reason: string;
1068
+ }
1069
+
1070
+ type ProposalCallback = (
1071
+ proposal: LayoutProposal,
1072
+ ) => NodeInput | null | undefined | void;
344
1073
  ```
345
1074
 
346
- ## Algorithm
1075
+ ---
1076
+
1077
+ ## Algorithm internals
347
1078
 
348
- The engine uses a modified **Sugiyama algorithm** with these phases:
1079
+ The engine is a modified **Sugiyama algorithm** with these phases (in order):
349
1080
 
350
- 1. **Proposals** — scan input nodes; emit rotation proposals when handle
351
- orientation doesn't match the planned placement (rail vs. sidecar).
1081
+ 1. **Proposals** — two passes when `onProposal` is set:
1082
+ - **Rotate pass**: scan input nodes; emit `RotateProposal` when the whole
1083
+ node faces the wrong axis for the chosen direction.
1084
+ - **Relocate pass**: run a preview layout, then emit
1085
+ `RelocateHandlesProposal` per node with each handle's optimal side. Side
1086
+ selection is **flow-aware** (flow-axis for cross-rank neighbors,
1087
+ perpendicular for same-rank sidecars, with a lopsided-fan correction) and
1088
+ handles sharing a side are **offset-ordered** to match their neighbors so
1089
+ edges don't cross. The application accepts/rejects each proposal via
1090
+ `onProposal`.
352
1091
  2. **Compound resolution** — group nodes by `parentId`; recursively lay out
353
1092
  each compound's children bottom-up so the compound's bounding box is
354
- known before it appears in the parent level.
355
- 3. **Cycle breaking** — DFS-based back edge detection and reversal.
1093
+ known before it appears at the parent level.
1094
+ 3. **Cycle breaking** — DFS-based back-edge detection and reversal.
356
1095
  4. **Two-pass layer assignment**:
357
- - control rail via longest-path on control-only edges between non-value nodes
358
- - values pulled onto the median layer of their non-value neighbors
359
- 5. **Dummy insertion** for long control edges only data edges route directly.
1096
+ - **control rail** via longest-path on control-only edges between non-value
1097
+ nodes;
1098
+ - **values** are pulled onto the median layer of their non-value neighbors.
1099
+ 5. **Dummy node insertion** for long control edges only — data edges route
1100
+ directly (typically span 0 or 1 layers).
360
1101
  6. **Crossing minimization on the rail** — barycenter heuristic with up/down
361
- sweeps and transpose improvement; merge-sort inversion counting.
1102
+ sweeps and transpose improvement; merge-sort inversion counting for O(E log V).
362
1103
  7. **Coordinate assignment on the rail** — median-based iterative positioning
363
- with spacing constraints.
364
- 8. **Sidecar value placement** — values are attached to the flank of their
365
- dominant consumer, side chosen from the consumer's handle position.
1104
+ with spacing constraints; values are excluded so the rail stays aligned.
1105
+ 8. **Sidecar value placement** — values are attached to the flanks of their
1106
+ dominant consumer; side chosen from the consumer's handle position.
366
1107
  9. **Edge routing** — orthogonal paths through dummy waypoints with
367
- handle-aware entry/exit directions; `kind` is preserved.
1108
+ handle-aware entry/exit directions; `kind` is preserved on output.
368
1109
  10. **Component packing** — disjoint components packed along the order axis;
369
1110
  compound groups travel as a single block.
370
1111
 
1112
+ ### The phases in depth
1113
+
1114
+ Each phase is a small, independently testable module under `src/algorithms/`.
1115
+ Understanding what each one does explains *why* the optimization knobs in the
1116
+ playground behave the way they do.
1117
+
1118
+ #### Cycle breaking (`cycle-breaking.ts`)
1119
+
1120
+ A layered layout requires a DAG, but real graphs have feedback loops (retry
1121
+ edges, `Check → Process` in the `Cycle Example`). A DFS visits nodes ordered by
1122
+ out-degree minus in-degree (sources first), and any edge pointing back at a
1123
+ *gray* (in-progress) node is a **back edge** — it gets reversed so the graph
1124
+ becomes acyclic. The reversal is remembered (`reversed: true`) so edge routing
1125
+ can draw the arrow in its original direction. Cost: `O(V + E)`, negligible.
1126
+
1127
+ #### Layer assignment (`layer-assignment.ts`)
1128
+
1129
+ Two passes. **Pass A** runs a longest-path on *control* edges only, between
1130
+ non-value nodes — this fixes the execution "rail" (`Start` at layer 0, each
1131
+ successor one layer deeper). **Pass B** pulls every value onto the **median**
1132
+ layer of its non-value neighbors, so a `CONFIG` constant snaps next to the node
1133
+ that consumes it instead of extending the rail. Then long control edges get
1134
+ **dummy nodes** inserted (one per layer crossed) so the next phases only ever
1135
+ reason about adjacent layers. Dummies dominate the internal node count — a
1136
+ 100-node graph can become ~665 internal nodes, which is why the heavy phases
1137
+ scale with *dummies*, not your input size.
1138
+
1139
+ #### Crossing minimization (`crossing-minimization.ts`) — the hot phase
1140
+
1141
+ This is the **barycenter heuristic**: repeatedly sweep down then up the layers,
1142
+ and within each layer reorder nodes by the average position of their neighbors
1143
+ in the adjacent layer. After each sweep, a **transpose** pass greedily swaps
1144
+ adjacent pairs whenever the swap reduces crossings. Crossings are counted with
1145
+ **merge-sort inversion counting** — `O(E log V)` instead of the naive `O(E²)`.
1146
+ The loop stops early when it reaches zero crossings or stops improving.
1147
+
1148
+ This phase is **40–67 % of total time**, which is exactly why the playground
1149
+ exposes:
1150
+
1151
+ - `crossingMinimizationIterations` — how many sweeps (more = fewer crossings,
1152
+ diminishing returns);
1153
+ - `skipTranspose` — drop the transpose pass entirely (the single biggest cost).
1154
+
1155
+ The engine already auto-scales iterations down and disables transpose for very
1156
+ large graphs; the knobs let you push further.
1157
+
1158
+ #### Coordinate assignment (`coordinate-assignment.ts`) — the other hot phase
1159
+
1160
+ Two sub-steps. First, each layer is placed along the **rank axis** (Y for TB,
1161
+ X for LR) at a cumulative offset. Then the **order axis** position is optimized
1162
+ iteratively: each node is pulled toward the **median** of its connected
1163
+ neighbors' centers, then a forward + backward pass enforces minimum spacing so
1164
+ nodes never overlap. `coordinateOptimizationIterations` controls how many
1165
+ align/repair sweeps run — more iterations = straighter edges, ~20–50 % of total
1166
+ time.
1167
+
1168
+ #### Sidecar value placement (`value-placement.ts`)
1169
+
1170
+ Values were excluded from the rail; here they're re-attached. Each value's
1171
+ *dominant consumer* (the non-value neighbor it shares the most edges with) is
1172
+ found, values are split onto the consumer's two flanks based on which side the
1173
+ connecting handle sits, then stacked outward by handle offset. Cheap: `< 1 %`.
1174
+
1175
+ #### Edge routing (`edge-routing.ts`)
1176
+
1177
+ Dummy chains are collapsed back into single logical edges, then each edge is
1178
+ drawn as an **orthogonal path**: exit the source handle along its facing
1179
+ direction, fold through the dummy waypoints at midpoints, enter the target
1180
+ handle. Collinear points are cleaned up. `edgeMargin` controls how far the path
1181
+ travels straight out of a handle before turning.
1182
+
1183
+ #### Component packing (`component-packing.ts`)
1184
+
1185
+ A union-find groups nodes into connected components (compound children stay with
1186
+ their parent), each component's bounding box is computed, and the boxes are laid
1187
+ side-by-side along the order axis largest-first — turning a long ribbon into a
1188
+ roughly square canvas. Toggle it off with `packComponents: false` (or the
1189
+ playground checkbox) to see each component laid out independently at the origin.
1190
+
1191
+ ### Which knob affects which phase
1192
+
1193
+ Everything you can change in the playground's **⚙ Optimizations** panel (and via
1194
+ `LayoutOptions`) maps directly onto one of the phases above:
1195
+
1196
+ | Knob / option | Phase affected | Effect |
1197
+ |----------------------------------------|---------------------------------|-----------------------------------------------------|
1198
+ | `quality: 'draft' \| 'balanced' \| 'high'` | crossing + coordinate | Bulk preset: scales both iteration counts and toggles transpose. |
1199
+ | `crossingMinimizationIterations` | crossing minimization | More sweeps → fewer crossings, more time. |
1200
+ | `skipTranspose` | crossing minimization | Drops the transpose pass → big speedup, a few more crossings. |
1201
+ | `coordinateOptimizationIterations` | coordinate assignment | More sweeps → straighter edges, more time. |
1202
+ | `nodeSpacing` / `layerSpacing` | coordinate assignment | Minimum gaps along order / rank axes. |
1203
+ | `edgeMargin` | edge routing | Straight run-out length before a path turns. |
1204
+ | `packComponents` | component packing | On/off side-by-side packing of disjoint subgraphs. |
1205
+ | `compoundPadding` | compound resolution | Inner padding of compound bounding boxes. |
1206
+ | `edgeWeights` / `kind` | layer assignment | Control vs data classification (rail vs sidecar). |
1207
+ | `onProposal` | proposals (pre-pass) | Accept/reject rotate + relocate handle suggestions. |
1208
+
1209
+ The `quality` preset is just a convenient bundle — the table below shows exactly
1210
+ what each preset sets, and any explicit option overrides it:
1211
+
1212
+ | preset | crossing iters | coordinate iters | transpose |
1213
+ |-------------|---------------:|-----------------:|-----------|
1214
+ | `draft` | 6 | 2 | skipped |
1215
+ | `balanced` | 24 | 8 | on |
1216
+ | `high` | 48 | 16 | on |
1217
+
1218
+ ### Why the rail is laid out without values
1219
+
1220
+ Including values in the rail's layer assignment would inflate the layer's
1221
+ width (every value adds a slot), which would shift the rail's center.
1222
+ Excluding values during the rail's coordinate assignment is what guarantees
1223
+ that, say, `Start → fetch → Parse → Done` stay perfectly aligned in TB even
1224
+ when fetch has five values attached to it.
1225
+
1226
+ ---
1227
+
1228
+ ## Debugging
1229
+
1230
+ ### `printLayout(result)`
1231
+
1232
+ Best first move. Prints:
1233
+
1234
+ - bounding box
1235
+ - nodes grouped by Y band (TB-style) — useful to verify layer separation
1236
+ - hierarchy (compounds with their children, recursively)
1237
+ - every edge: kind, source/target, point count
1238
+ - a list of overlapping nodes (should be empty in a healthy layout)
1239
+ - an ASCII sketch of the final positions
1240
+
1241
+ ```ts
1242
+ console.log(printLayout(layout(input), { gridWidth: 100 }));
1243
+ ```
1244
+
1245
+ ### Inspecting proposals
1246
+
1247
+ ```ts
1248
+ layout(input, {
1249
+ onProposal: (p) => {
1250
+ console.log(`[proposal] ${p.nodeId}: rotate by ${p.rotation}° — ${p.reason}`);
1251
+ return null; // observe only
1252
+ },
1253
+ });
1254
+ ```
1255
+
1256
+ ### Verifying typed-edge classification
1257
+
1258
+ A node is considered a "value" iff every incident edge has `kind: 'data'`. If
1259
+ a node you expected to be a value is appearing in the rail, double-check:
1260
+
1261
+ - Are any of its edges still `kind: 'control'` (or missing `kind`, which
1262
+ defaults to control)?
1263
+ - Did you misspell `kind`? It must be a string `'data'`, not the EdgeKind type
1264
+ imported then mis-used.
1265
+
1266
+ ### Common gotchas
1267
+
1268
+ | Symptom | Likely cause |
1269
+ |----------------------------------------------------------|----------------------------------------------------------------------|
1270
+ | Rail is not vertical/horizontal anymore | Some control edges leak between unrelated components — packComponents helps |
1271
+ | Value lands in the middle of the rail | The value still has a control edge — re-check its edge kinds |
1272
+ | Compound child sticks out of the parent box | Child has handles wider than expected — check `width`/`height` math |
1273
+ | `End` node sits to the side of the rail | Its only inbound edge is `data`, not `control` |
1274
+ | Sidecar values all stack on one side | The consumer's handle for those values is on a single side — split them |
1275
+
1276
+ ---
1277
+
371
1278
  ## Playground
372
1279
 
373
1280
  ```bash
@@ -376,32 +1283,129 @@ npm install
376
1283
  npm run dev # → http://localhost:6501
377
1284
  ```
378
1285
 
379
- The playground ships with examples covering every layout feature:
380
-
381
- - Compound · Promise.all
382
- - Floating Values (auto-rotate demo)
383
- - Compound + Values
384
- - Try / Catch (compound)
385
- - Switch / Case
386
- - Map / Reduce pipeline
387
- - HTTP middleware chain
388
- - Disjoint Components
389
- - Binary Tree
390
- - Cycle Example
391
- - ... and classics (Simple Chain, Diamond, Data Pipeline, Multi-Handle Hub)
392
-
393
- Toggle **Pack components** and **Auto-rotate handles** in the toolbar to see
394
- the corresponding features kick in.
1286
+ The playground ships with curated examples covering every layout feature:
1287
+
1288
+ | Example | What it showcases |
1289
+ |--------------------------|----------------------------------------------------|
1290
+ | Simple Chain | Bare-bones linear rail |
1291
+ | Diamond | Branch + join |
1292
+ | Data Pipeline | Realistic multi-handle nodes |
1293
+ | Multi-Handle Hub | One source fanning out to many workers |
1294
+ | Binary Tree | Tree with two outputs per parent |
1295
+ | Compound · Promise.all | Parallel children inside a compound |
1296
+ | **Floating Values** | **Mixed orientation + auto-rotate demo** |
1297
+ | Compound + Values | A compound that takes a value as a config |
1298
+ | Try / Catch (compound) | Two side-by-side compounds (happy + error path) |
1299
+ | Switch / Case | One dispatcher, many branches converging |
1300
+ | Map / Reduce pipeline | Compound + seeded reducer (sidecar value) |
1301
+ | HTTP middleware chain | Long vertical rail with three left-side configs |
1302
+ | Disjoint Components | `packComponents` packing in action |
1303
+ | **Star Hub (relocate)** | **Single node, 6 handles, 4 different sides — per-handle placement** |
1304
+ | **Pub/Sub Broker** | **Bidirectional broker: publishers ↔ broker ↔ subscribers** |
1305
+ | **Bidirectional Mesh** | **3 services with request/reply edges in both directions** |
1306
+ | **Wrong Handles Everywhere** | **Linear pipeline with scrambled handles — auto-fix corrects each one** |
1307
+ | Cycle Example | Back edge handling |
1308
+
1309
+ Toolbar toggles:
1310
+
1311
+ - **Direction** — `TB`, `LR`, `BT`, `RL`.
1312
+ - **Node / Layer spacing** — sliders.
1313
+ - **Pack components** — turns off packing of disjoint subgraphs.
1314
+ - **Auto-rotate handles** — when on, `onProposal` returns `p.proposed` so the
1315
+ engine's suggestions are applied automatically — **both** rotate and
1316
+ relocate proposals. Toggle it off on `Star Hub (relocate)`,
1317
+ `Pub/Sub Broker`, or `Wrong Handles Everywhere` to see how bad the layout
1318
+ looks without it; toggle it back on to watch every handle snap into place.
1319
+
1320
+ ### ⚙ Optimizations panel
1321
+
1322
+ Click **⚙ Optimizations** in the toolbar to open the optimization controls.
1323
+ These let you exercise each speed/quality knob live and see the effect on both
1324
+ the rendered layout and the timing read-out:
1325
+
1326
+ | Control | What it does |
1327
+ |------------------------|------------------------------------------------------------------------------|
1328
+ | **Quality** | `draft` / `balanced` (default) / `high` preset. Re-layouts on change so you can compare the visual quality and the timing. |
1329
+ | **Skip transpose** | Force-skips the per-layer transpose pass (the single largest cost in balanced mode) independently of the preset. |
1330
+ | **Crossing iter** | Numeric override for crossing-minimization iterations. Empty = use the preset's value. |
1331
+ | **Coord iter** | Numeric override for coordinate-optimization iterations. Empty = use the preset's value. |
1332
+ | **⏱ Compare presets** | Runs `draft`, `balanced`, and `high` on the current input (rep count auto-scaled to graph size) and prints a best/mean timing table with the ratio vs `balanced`. |
1333
+ | **active-opts badge** | A live read-out of the effective options, e.g. `quality=balanced · skipTranspose · xIter=4 · cIter=1`. |
1334
+
1335
+ **What to try:**
1336
+
1337
+ - Load a small example (`Simple Chain`) → all presets read ~0 ms; the layout
1338
+ is dominated by fixed setup, so the preset barely matters.
1339
+ - Build or paste a larger graph (a few hundred nodes) and hit **⏱ Compare
1340
+ presets** → `draft` is typically ~0.7–0.8× the balanced time, `high` ~1.2–1.5×.
1341
+ - Switch **Quality** between `draft` and `high` on a dense example and watch the
1342
+ number of edge crossings change while the timing tracks the preset.
1343
+
1344
+ Every numeric override wins over the preset, and **Skip transpose** stacks on
1345
+ top of any preset — so `quality=draft + skipTranspose` is the fastest possible
1346
+ configuration, while `quality=high` with explicit high iteration counts is the
1347
+ slowest / highest quality.
1348
+
1349
+ See [docs/PERFORMANCE.md](docs/PERFORMANCE.md) for the full benchmark tables and
1350
+ Web Worker / WASM / WebGPU notes.
1351
+
1352
+ ---
395
1353
 
396
1354
  ## Development
397
1355
 
398
1356
  ```bash
399
1357
  npm install
400
- npm test # 79 tests cover compound, sidecars, proposals, perf, cycles…
1358
+
1359
+ npm test # 99 tests cover compound, sidecars, rotate + relocate proposals, quality presets, perf, cycles…
401
1360
  npm run test:watch
402
1361
  npm run build # tsup bundle + tsc declaration files
1362
+
1363
+ # Playground:
1364
+ cd playground
1365
+ npm install
1366
+ npm run dev # vite on http://localhost:6501
403
1367
  ```
404
1368
 
1369
+ ### Tests of note
1370
+
1371
+ - `tests/compound.test.ts` — the original "Option A améliorée" contract:
1372
+ compound bounding box + value pull on `Test 2`.
1373
+ - `tests/value-placement.test.ts` — sidecar contract: rail alignment, sidecar
1374
+ side picking, LR direction, no-overlap.
1375
+ - `tests/proposals.test.ts` — `onProposal` lifecycle: emission, rejection,
1376
+ acceptance, partial accept, value-specific rotation suggestions.
1377
+ - `tests/diag-values.test.ts` — `printLayout` smoke test on the Floating
1378
+ Values scenario.
1379
+ - `tests/quality-presets.test.ts` — `quality` presets: `balanced` equals the
1380
+ default, `draft`/`high` produce valid non-overlapping layouts, explicit
1381
+ iteration counts override the preset, and `draft` is measurably faster.
1382
+
1383
+ ### Snapshot regression harness
1384
+
1385
+ `scripts/snapshot.ts` captures a golden master of `layout()` output for the
1386
+ whole example corpus in all four directions plus random DAGs up to 500 nodes,
1387
+ in **two modes** — `plain` (no proposals) and `proposal` (auto-rotate accepted,
1388
+ `onProposal: p => p.proposed`, like the playground default) — for 182 snapshots
1389
+ total. The plain set guards that performance work keeps the **`balanced`**
1390
+ output bit-identical; the proposal set guards the rotate + relocate handle
1391
+ placement:
1392
+
1393
+ ```bash
1394
+ npx tsx scripts/snapshot.ts capture # write baseline/ (plain + proposal)
1395
+ npx tsx scripts/snapshot.ts verify # diff current output vs baseline/
1396
+ ```
1397
+
1398
+ Two companion scripts judge handle-placement *quality* (a non-circular metric:
1399
+ edge crossings, edges through nodes, handles facing away from their edge):
1400
+
1401
+ ```bash
1402
+ npx tsx scripts/quality-metric.ts # per-example metric, proposal mode
1403
+ npx tsx scripts/quality-compare.ts # none vs rotate vs relocate
1404
+ ```
1405
+
1406
+ `scripts/profile.ts` breaks down per-phase timing and
1407
+ `scripts/benchmark-presets.ts` compares the three presets across graph sizes.
1408
+
405
1409
  ## License
406
1410
 
407
1411
  MIT