@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.
- package/LICENSE +201 -201
- package/README.md +1092 -122
- package/dist/algorithms/component-packing.d.ts +9 -0
- package/dist/algorithms/component-packing.d.ts.map +1 -0
- package/dist/algorithms/coordinate-assignment.d.ts +7 -0
- package/dist/algorithms/coordinate-assignment.d.ts.map +1 -0
- package/dist/algorithms/crossing-minimization.d.ts +11 -0
- package/dist/algorithms/crossing-minimization.d.ts.map +1 -0
- package/dist/algorithms/cycle-breaking.d.ts +8 -0
- package/dist/algorithms/cycle-breaking.d.ts.map +1 -0
- package/dist/algorithms/edge-routing.d.ts +17 -0
- package/dist/algorithms/edge-routing.d.ts.map +1 -0
- package/dist/algorithms/layer-assignment.d.ts +20 -0
- package/dist/algorithms/layer-assignment.d.ts.map +1 -0
- package/dist/algorithms/value-cluster.d.ts +15 -0
- package/dist/algorithms/value-cluster.d.ts.map +1 -0
- package/dist/algorithms/value-placement.d.ts +25 -0
- package/dist/algorithms/value-placement.d.ts.map +1 -0
- package/dist/debug.d.ts +20 -0
- package/dist/debug.d.ts.map +1 -0
- package/dist/graph.d.ts +50 -0
- package/dist/graph.d.ts.map +1 -0
- package/dist/incremental.d.ts +33 -0
- package/dist/incremental.d.ts.map +1 -0
- package/dist/index.d.ts +7 -176
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1159 -234
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1156 -233
- package/dist/index.mjs.map +1 -1
- package/dist/layout.d.ts +10 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/proposals.d.ts +44 -0
- package/dist/proposals.d.ts.map +1 -0
- package/dist/types.d.ts +214 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +5 -4
- package/dist/index.d.mts +0 -176
package/README.md
CHANGED
|
@@ -1,145 +1,952 @@
|
|
|
1
|
-
# nodius
|
|
1
|
+
# @nodius/layouting
|
|
2
2
|
|
|
3
|
-
A **zero-dependency**, high-performance graph layouting library for node-based
|
|
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
|
|
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**
|
|
10
|
-
- **
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
- **
|
|
14
|
-
|
|
15
|
-
- **
|
|
16
|
-
|
|
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
|
-
|
|
80
|
+
Measured on a standard dev machine (Node 24, single-threaded JS):
|
|
21
81
|
|
|
22
|
-
| Graph
|
|
23
|
-
|
|
24
|
-
| 100 nodes
|
|
25
|
-
| 200 nodes | ~
|
|
26
|
-
| 500 nodes | ~
|
|
27
|
-
|
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
38
|
-
|
|
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: '
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
id: '
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
id: '
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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: '
|
|
71
|
-
{ id: '
|
|
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
|
-
|
|
76
|
-
|
|
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
|
|
854
|
+
Compute a complete layout for the given graph. Returns positioned nodes and
|
|
855
|
+
routed edges.
|
|
84
856
|
|
|
85
|
-
```
|
|
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
|
|
865
|
+
Maintains state for incremental updates with position stability:
|
|
92
866
|
|
|
93
|
-
```
|
|
94
|
-
import { IncrementalLayout } from 'nodius
|
|
867
|
+
```ts
|
|
868
|
+
import { IncrementalLayout } from '@nodius/layouting';
|
|
95
869
|
|
|
96
870
|
const inc = new IncrementalLayout({ direction: 'LR' });
|
|
97
871
|
|
|
98
|
-
//
|
|
99
|
-
const
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
108
|
-
|
|
892
|
+
```ts
|
|
893
|
+
import { layout, printLayout } from '@nodius/layouting';
|
|
109
894
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
###
|
|
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
|
-
|
|
920
|
+
---
|
|
121
921
|
|
|
122
|
-
|
|
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
|
|
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;
|
|
140
|
-
to: string;
|
|
141
|
-
fromHandle: string;
|
|
142
|
-
toHandle: string;
|
|
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
|
-
|
|
958
|
+
### Options
|
|
152
959
|
|
|
153
|
-
```
|
|
960
|
+
```ts
|
|
154
961
|
interface LayoutOptions {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
989
|
+
### Output
|
|
165
990
|
|
|
166
|
-
```
|
|
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[]; //
|
|
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; //
|
|
186
|
-
y: number;
|
|
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[]; //
|
|
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
|
-
|
|
1028
|
+
### Proposals
|
|
200
1029
|
|
|
201
|
-
|
|
1030
|
+
```ts
|
|
1031
|
+
type LayoutProposal = RotateProposal | RelocateHandlesProposal;
|
|
202
1032
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1052
|
+
type ProposalCallback = (
|
|
1053
|
+
proposal: LayoutProposal,
|
|
1054
|
+
) => NodeInput | null | undefined | void;
|
|
1055
|
+
```
|
|
212
1056
|
|
|
213
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
248
|
-
|
|
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
|