@nodius/layouting 0.1.0 → 0.1.1
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 +280 -126
- 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 +7 -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 +904 -149
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +901 -148
- 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 +31 -0
- package/dist/proposals.d.ts.map +1 -0
- package/dist/types.d.ts +155 -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,267 @@
|
|
|
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 environments,
|
|
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
9
|
|
|
7
10
|
## Features
|
|
8
11
|
|
|
9
|
-
- **Zero runtime dependencies**
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
15
|
-
- **
|
|
16
|
-
- **
|
|
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
|
|
17
25
|
|
|
18
26
|
## Performance
|
|
19
27
|
|
|
20
|
-
|
|
28
|
+
Measured on a standard dev machine (Node 22, vitest run):
|
|
21
29
|
|
|
22
|
-
| Graph
|
|
23
|
-
|
|
24
|
-
| 100 nodes, 737 edges (dense)
|
|
25
|
-
| 200 nodes
|
|
26
|
-
| 500 nodes
|
|
27
|
-
| 1000 nodes
|
|
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|
|
|
28
36
|
|
|
29
37
|
## Installation
|
|
30
38
|
|
|
31
39
|
```bash
|
|
32
|
-
npm install nodius
|
|
40
|
+
npm install @nodius/layouting
|
|
33
41
|
```
|
|
34
42
|
|
|
35
|
-
## Quick
|
|
43
|
+
## Quick start
|
|
36
44
|
|
|
37
|
-
```
|
|
38
|
-
import { layout } from 'nodius
|
|
45
|
+
```ts
|
|
46
|
+
import { layout } from '@nodius/layouting';
|
|
39
47
|
|
|
40
48
|
const result = layout({
|
|
41
49
|
nodes: [
|
|
42
|
-
{
|
|
43
|
-
id: '
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
width: 150,
|
|
53
|
-
height: 80,
|
|
54
|
-
handles: [
|
|
55
|
-
{ id: 'in', type: 'input', position: 'top', offset: 0.5 },
|
|
56
|
-
{ id: 'out', type: 'output', position: 'bottom', offset: 0.5 },
|
|
57
|
-
{ id: 'err', type: 'output', position: 'right', offset: 0.5 },
|
|
58
|
-
],
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
id: 'sink',
|
|
62
|
-
width: 120,
|
|
63
|
-
height: 60,
|
|
64
|
-
handles: [
|
|
65
|
-
{ id: 'in', type: 'input', position: 'top', offset: 0.5 },
|
|
66
|
-
],
|
|
67
|
-
},
|
|
50
|
+
{ id: 'src', width: 120, height: 60, handles: [
|
|
51
|
+
{ id: 'out', type: 'output', position: 'bottom' },
|
|
52
|
+
]},
|
|
53
|
+
{ id: 'transform', width: 150, height: 80, handles: [
|
|
54
|
+
{ id: 'in', type: 'input', position: 'top' },
|
|
55
|
+
{ id: 'out', type: 'output', position: 'bottom' },
|
|
56
|
+
]},
|
|
57
|
+
{ id: 'sink', width: 120, height: 60, handles: [
|
|
58
|
+
{ id: 'in', type: 'input', position: 'top' },
|
|
59
|
+
]},
|
|
68
60
|
],
|
|
69
61
|
edges: [
|
|
70
|
-
{ id: 'e1', from: '
|
|
62
|
+
{ id: 'e1', from: 'src', to: 'transform', fromHandle: 'out', toHandle: 'in' },
|
|
71
63
|
{ id: 'e2', from: 'transform', to: 'sink', fromHandle: 'out', toHandle: 'in' },
|
|
72
64
|
],
|
|
65
|
+
}, { direction: 'TB' });
|
|
66
|
+
|
|
67
|
+
// result.nodes → positioned nodes with absolute handle coordinates and parentId echoed
|
|
68
|
+
// result.edges → routed edges with waypoint arrays and `kind`
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Typed edges and value nodes
|
|
72
|
+
|
|
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.
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
import { layout } from '@nodius/layouting';
|
|
82
|
+
|
|
83
|
+
const result = layout({
|
|
84
|
+
nodes: [
|
|
85
|
+
{ id: 'Start', width: 100, height: 50, handles: [
|
|
86
|
+
{ id: 'out', type: 'output', position: 'bottom' },
|
|
87
|
+
]},
|
|
88
|
+
{ id: 'fetch', width: 140, height: 70, handles: [
|
|
89
|
+
{ id: 'in', type: 'input', position: 'top' },
|
|
90
|
+
{ id: 'key', type: 'input', position: 'left', offset: 0.3 },
|
|
91
|
+
{ id: 'url', type: 'input', position: 'left', offset: 0.7 },
|
|
92
|
+
{ id: 'out', type: 'output', position: 'bottom' },
|
|
93
|
+
]},
|
|
94
|
+
{ id: 'API_KEY', width: 100, height: 40, handles: [
|
|
95
|
+
{ id: 'out', type: 'output', position: 'right' },
|
|
96
|
+
]},
|
|
97
|
+
{ id: 'BASE_URL', width: 100, height: 40, handles: [
|
|
98
|
+
{ id: 'out', type: 'output', position: 'right' },
|
|
99
|
+
]},
|
|
100
|
+
{ id: 'Done', width: 100, height: 50, handles: [
|
|
101
|
+
{ id: 'in', type: 'input', position: 'top' },
|
|
102
|
+
]},
|
|
103
|
+
],
|
|
104
|
+
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' },
|
|
107
|
+
{ id: 'd1', from: 'API_KEY', to: 'fetch', fromHandle: 'out', toHandle: 'key', kind: 'data' },
|
|
108
|
+
{ id: 'd2', from: 'BASE_URL', to: 'fetch', fromHandle: 'out', toHandle: 'url', kind: 'data' },
|
|
109
|
+
],
|
|
110
|
+
});
|
|
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
|
+
```
|
|
114
|
+
|
|
115
|
+
## Compound (nested) layout
|
|
116
|
+
|
|
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.
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
const result = layout({
|
|
123
|
+
nodes: [
|
|
124
|
+
{ id: 'Start', width: 110, height: 50, handles: [...] },
|
|
125
|
+
{ id: 'parallel', width: 220, height: 140, handles: [
|
|
126
|
+
{ id: 'in', type: 'input', position: 'top' },
|
|
127
|
+
{ id: 'out', type: 'output', position: 'bottom' },
|
|
128
|
+
]},
|
|
129
|
+
{ id: 'fetch users', parentId: 'parallel', width: 130, height: 50, handles: [...] },
|
|
130
|
+
{ id: 'fetch orders', parentId: 'parallel', width: 130, height: 50, handles: [...] },
|
|
131
|
+
{ id: 'End', width: 100, height: 50, handles: [...] },
|
|
132
|
+
],
|
|
133
|
+
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' },
|
|
136
|
+
],
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Output nodes carry `parentId` so you can reconstruct the hierarchy in your renderer.
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Rotation proposals (`onProposal`)
|
|
143
|
+
|
|
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:
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
import { layout } from '@nodius/layouting';
|
|
149
|
+
|
|
150
|
+
layout(input, {
|
|
151
|
+
direction: 'LR',
|
|
152
|
+
// Accept every proposal and use the engine-suggested rotation:
|
|
153
|
+
onProposal: (p) => p.proposed,
|
|
154
|
+
|
|
155
|
+
// Or filter: only accept rotations for nodes you own
|
|
156
|
+
// onProposal: (p) => p.nodeId.startsWith('mine_') ? p.proposed : null,
|
|
157
|
+
|
|
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
|
+
// }),
|
|
73
163
|
});
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
The engine reasons about two roles:
|
|
74
167
|
|
|
75
|
-
|
|
76
|
-
|
|
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`.
|
|
173
|
+
|
|
174
|
+
Each proposal includes the original node, the rotated proposal, the rotation
|
|
175
|
+
angle (`90` / `-90` / `180`), and a human-readable reason.
|
|
176
|
+
|
|
177
|
+
## Component packing
|
|
178
|
+
|
|
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.
|
|
182
|
+
|
|
183
|
+
```ts
|
|
184
|
+
layout(input, { packComponents: true }); // default
|
|
185
|
+
layout(input, { packComponents: false }); // long ribbon
|
|
77
186
|
```
|
|
78
187
|
|
|
79
188
|
## API
|
|
80
189
|
|
|
81
190
|
### `layout(input, options?)`
|
|
82
191
|
|
|
83
|
-
Compute a complete layout for the given graph. Returns positioned nodes and
|
|
192
|
+
Compute a complete layout for the given graph. Returns positioned nodes and
|
|
193
|
+
routed edges.
|
|
84
194
|
|
|
85
|
-
```
|
|
195
|
+
```ts
|
|
86
196
|
function layout(input: LayoutInput, options?: LayoutOptions): LayoutResult;
|
|
87
197
|
```
|
|
88
198
|
|
|
89
199
|
### `IncrementalLayout`
|
|
90
200
|
|
|
91
|
-
Maintains
|
|
201
|
+
Maintains state for incremental updates with position stability:
|
|
92
202
|
|
|
93
|
-
```
|
|
94
|
-
import { IncrementalLayout } from 'nodius
|
|
203
|
+
```ts
|
|
204
|
+
import { IncrementalLayout } from '@nodius/layouting';
|
|
95
205
|
|
|
96
206
|
const inc = new IncrementalLayout({ direction: 'LR' });
|
|
207
|
+
const r1 = inc.setGraph({ nodes: [...], edges: [...] });
|
|
208
|
+
const r2 = inc.addNodes([newNode], [newEdge]);
|
|
209
|
+
const r3 = inc.removeNodes(['stale_id']);
|
|
210
|
+
inc.addEdges([...]);
|
|
211
|
+
inc.removeEdges(['e1']);
|
|
212
|
+
inc.getResult();
|
|
213
|
+
```
|
|
97
214
|
|
|
98
|
-
|
|
99
|
-
const result1 = inc.setGraph({ nodes: [...], edges: [...] });
|
|
215
|
+
### `printLayout(result, options?)`
|
|
100
216
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
[{ id: 'new_edge', from: 'existing', to: 'new_node', fromHandle: 'out', toHandle: 'in' }]
|
|
105
|
-
);
|
|
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.
|
|
106
220
|
|
|
107
|
-
|
|
108
|
-
|
|
221
|
+
```ts
|
|
222
|
+
import { layout, printLayout } from '@nodius/layouting';
|
|
223
|
+
const r = layout(input);
|
|
224
|
+
console.log(printLayout(r));
|
|
225
|
+
```
|
|
109
226
|
|
|
110
|
-
|
|
111
|
-
inc.addEdges([...]);
|
|
112
|
-
inc.removeEdges(['edge_id']);
|
|
227
|
+
### `rotateHandles(handles, rotation)`
|
|
113
228
|
|
|
114
|
-
|
|
115
|
-
|
|
229
|
+
Utility re-exported for applications that build their own handle-rotation
|
|
230
|
+
logic in their `onProposal` callback.
|
|
231
|
+
|
|
232
|
+
```ts
|
|
233
|
+
import { rotateHandles } from '@nodius/layouting';
|
|
234
|
+
const rotated = rotateHandles(node.handles, 90);
|
|
116
235
|
```
|
|
117
236
|
|
|
118
|
-
|
|
237
|
+
## Types
|
|
119
238
|
|
|
120
|
-
|
|
239
|
+
### Input
|
|
121
240
|
|
|
122
|
-
```
|
|
241
|
+
```ts
|
|
123
242
|
interface NodeInput {
|
|
124
243
|
id: string;
|
|
125
244
|
width: number;
|
|
126
245
|
height: number;
|
|
127
246
|
handles: HandleInput[];
|
|
247
|
+
parentId?: string; // make this node a child of another
|
|
128
248
|
}
|
|
129
249
|
|
|
130
250
|
interface HandleInput {
|
|
131
251
|
id: string;
|
|
132
252
|
type: 'input' | 'output';
|
|
133
253
|
position: 'top' | 'right' | 'bottom' | 'left';
|
|
134
|
-
offset?: number; // 0
|
|
254
|
+
offset?: number; // 0..1 along the side. Default: 0.5
|
|
135
255
|
}
|
|
136
256
|
|
|
137
257
|
interface EdgeInput {
|
|
138
258
|
id: string;
|
|
139
|
-
from: string;
|
|
140
|
-
to: string;
|
|
141
|
-
fromHandle: string;
|
|
142
|
-
toHandle: string;
|
|
259
|
+
from: string;
|
|
260
|
+
to: string;
|
|
261
|
+
fromHandle: string;
|
|
262
|
+
toHandle: string;
|
|
263
|
+
kind?: 'control' | 'data'; // default: 'control'
|
|
264
|
+
weight?: number; // override (default: 1 control, 0.25 data)
|
|
143
265
|
}
|
|
144
266
|
|
|
145
267
|
interface LayoutInput {
|
|
@@ -148,22 +270,34 @@ interface LayoutInput {
|
|
|
148
270
|
}
|
|
149
271
|
```
|
|
150
272
|
|
|
151
|
-
|
|
273
|
+
### Options
|
|
152
274
|
|
|
153
|
-
```
|
|
275
|
+
```ts
|
|
154
276
|
interface LayoutOptions {
|
|
155
|
-
direction?: 'TB' | 'LR' | 'BT' | 'RL';
|
|
156
|
-
nodeSpacing?: number;
|
|
157
|
-
layerSpacing?: number;
|
|
158
|
-
crossingMinimizationIterations?: number;
|
|
159
|
-
coordinateOptimizationIterations?: number
|
|
160
|
-
edgeMargin?: number;
|
|
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
|
|
285
|
+
edgeWeights?: { control?: number; data?: number };
|
|
286
|
+
|
|
287
|
+
// Component packing
|
|
288
|
+
packComponents?: boolean; // Default: true
|
|
289
|
+
|
|
290
|
+
// Compound layout
|
|
291
|
+
compoundPadding?: number; // Default: 24
|
|
292
|
+
|
|
293
|
+
// Proposals
|
|
294
|
+
onProposal?: (p: LayoutProposal) => NodeInput | null | undefined | void;
|
|
161
295
|
}
|
|
162
296
|
```
|
|
163
297
|
|
|
164
|
-
|
|
298
|
+
### Output
|
|
165
299
|
|
|
166
|
-
```
|
|
300
|
+
```ts
|
|
167
301
|
interface LayoutResult {
|
|
168
302
|
nodes: NodeOutput[];
|
|
169
303
|
edges: EdgeOutput[];
|
|
@@ -175,15 +309,16 @@ interface NodeOutput {
|
|
|
175
309
|
y: number;
|
|
176
310
|
width: number;
|
|
177
311
|
height: number;
|
|
178
|
-
handles: HandleOutput[]; //
|
|
312
|
+
handles: HandleOutput[]; // absolute positions
|
|
313
|
+
parentId?: string; // echoed from the input
|
|
179
314
|
}
|
|
180
315
|
|
|
181
316
|
interface HandleOutput {
|
|
182
317
|
id: string;
|
|
183
318
|
type: 'input' | 'output';
|
|
184
319
|
position: 'top' | 'right' | 'bottom' | 'left';
|
|
185
|
-
x: number;
|
|
186
|
-
y: number;
|
|
320
|
+
x: number;
|
|
321
|
+
y: number;
|
|
187
322
|
}
|
|
188
323
|
|
|
189
324
|
interface EdgeOutput {
|
|
@@ -192,60 +327,79 @@ interface EdgeOutput {
|
|
|
192
327
|
to: string;
|
|
193
328
|
fromHandle: string;
|
|
194
329
|
toHandle: string;
|
|
195
|
-
points: Point[]; //
|
|
330
|
+
points: Point[]; // ordered waypoints
|
|
331
|
+
kind: 'control' | 'data';
|
|
196
332
|
}
|
|
197
|
-
```
|
|
198
333
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
5. **Edge Routing** - Orthogonal path computation through dummy node waypoints with handle-aware entry/exit directions
|
|
334
|
+
interface RotateProposal {
|
|
335
|
+
type: 'rotate';
|
|
336
|
+
nodeId: string;
|
|
337
|
+
current: NodeInput;
|
|
338
|
+
proposed: NodeInput;
|
|
339
|
+
rotation: 90 | -90 | 180;
|
|
340
|
+
reason: string;
|
|
341
|
+
}
|
|
208
342
|
|
|
209
|
-
|
|
343
|
+
type LayoutProposal = RotateProposal;
|
|
344
|
+
```
|
|
210
345
|
|
|
211
|
-
|
|
346
|
+
## Algorithm
|
|
212
347
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
348
|
+
The engine uses a modified **Sugiyama algorithm** with these phases:
|
|
349
|
+
|
|
350
|
+
1. **Proposals** — scan input nodes; emit rotation proposals when handle
|
|
351
|
+
orientation doesn't match the planned placement (rail vs. sidecar).
|
|
352
|
+
2. **Compound resolution** — group nodes by `parentId`; recursively lay out
|
|
353
|
+
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.
|
|
356
|
+
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.
|
|
360
|
+
6. **Crossing minimization on the rail** — barycenter heuristic with up/down
|
|
361
|
+
sweeps and transpose improvement; merge-sort inversion counting.
|
|
362
|
+
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.
|
|
366
|
+
9. **Edge routing** — orthogonal paths through dummy waypoints with
|
|
367
|
+
handle-aware entry/exit directions; `kind` is preserved.
|
|
368
|
+
10. **Component packing** — disjoint components packed along the order axis;
|
|
369
|
+
compound groups travel as a single block.
|
|
217
370
|
|
|
218
371
|
## Playground
|
|
219
372
|
|
|
220
|
-
A visual playground is included in the `playground/` directory:
|
|
221
|
-
|
|
222
373
|
```bash
|
|
223
374
|
cd playground
|
|
224
375
|
npm install
|
|
225
|
-
npm run dev
|
|
376
|
+
npm run dev # → http://localhost:6501
|
|
226
377
|
```
|
|
227
378
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
-
|
|
231
|
-
-
|
|
232
|
-
-
|
|
233
|
-
-
|
|
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.
|
|
234
395
|
|
|
235
396
|
## Development
|
|
236
397
|
|
|
237
398
|
```bash
|
|
238
|
-
# Install dependencies
|
|
239
399
|
npm install
|
|
240
|
-
|
|
241
|
-
# Run tests
|
|
242
|
-
npm test
|
|
243
|
-
|
|
244
|
-
# Run tests in watch mode
|
|
400
|
+
npm test # 79 tests cover compound, sidecars, proposals, perf, cycles…
|
|
245
401
|
npm run test:watch
|
|
246
|
-
|
|
247
|
-
# Build the library
|
|
248
|
-
npm run build
|
|
402
|
+
npm run build # tsup bundle + tsc declaration files
|
|
249
403
|
```
|
|
250
404
|
|
|
251
405
|
## License
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { EdgeOutput, NodeOutput, ResolvedOptions } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* Pack disjoint connected components side-by-side (in the order axis) so the
|
|
4
|
+
* overall layout is roughly square instead of a long ribbon.
|
|
5
|
+
*
|
|
6
|
+
* Compound parents are kept as atomic groups: children move with their parent.
|
|
7
|
+
*/
|
|
8
|
+
export declare function packComponents(nodes: NodeOutput[], edges: EdgeOutput[], options: ResolvedOptions): void;
|
|
9
|
+
//# sourceMappingURL=component-packing.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"component-packing.d.ts","sourceRoot":"","sources":["../../src/algorithms/component-packing.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,UAAU,CAAC;AAEnE;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,UAAU,EAAE,EACnB,KAAK,EAAE,UAAU,EAAE,EACnB,OAAO,EAAE,eAAe,GACvB,IAAI,CAqJN"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Graph } from '../graph';
|
|
2
|
+
import { ResolvedOptions } from '../types';
|
|
3
|
+
/**
|
|
4
|
+
* Assign x,y coordinates to all nodes using a median-based iterative approach.
|
|
5
|
+
*/
|
|
6
|
+
export declare function assignCoordinates(graph: Graph, layers: string[][], options: ResolvedOptions): void;
|
|
7
|
+
//# sourceMappingURL=coordinate-assignment.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"coordinate-assignment.d.ts","sourceRoot":"","sources":["../../src/algorithms/coordinate-assignment.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AACjC,OAAO,EAAmB,eAAe,EAAE,MAAM,UAAU,CAAC;AAE5D;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,MAAM,EAAE,EAAE,EAClB,OAAO,EAAE,eAAe,GACvB,IAAI,CAON"}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { Graph } from '../graph';
|
|
2
|
+
/**
|
|
3
|
+
* Minimize edge crossings using barycenter heuristic with transpose improvement.
|
|
4
|
+
*/
|
|
5
|
+
export declare function minimizeCrossings(graph: Graph, layers: string[][], iterations: number): string[][];
|
|
6
|
+
export declare function countAllCrossings(graph: Graph, layers: string[][]): number;
|
|
7
|
+
//# sourceMappingURL=crossing-minimization.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crossing-minimization.d.ts","sourceRoot":"","sources":["../../src/algorithms/crossing-minimization.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAEjC;;GAEG;AACH,wBAAgB,iBAAiB,CAC/B,KAAK,EAAE,KAAK,EACZ,MAAM,EAAE,MAAM,EAAE,EAAE,EAClB,UAAU,EAAE,MAAM,GACjB,MAAM,EAAE,EAAE,CAqEZ;AAgMD,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,GAAG,MAAM,CAM1E"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { Graph } from '../graph';
|
|
2
|
+
/**
|
|
3
|
+
* Break cycles in the graph using DFS-based back edge detection.
|
|
4
|
+
* Reverses back edges to make the graph a DAG.
|
|
5
|
+
* Returns the set of reversed edge IDs.
|
|
6
|
+
*/
|
|
7
|
+
export declare function breakCycles(graph: Graph): Set<string>;
|
|
8
|
+
//# sourceMappingURL=cycle-breaking.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cycle-breaking.d.ts","sourceRoot":"","sources":["../../src/algorithms/cycle-breaking.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAEjC;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,KAAK,GAAG,GAAG,CAAC,MAAM,CAAC,CA4DrD"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Graph } from '../graph';
|
|
2
|
+
import { Point, LayoutDirection, EdgeKind } from '../types';
|
|
3
|
+
export interface RoutedEdge {
|
|
4
|
+
id: string;
|
|
5
|
+
from: string;
|
|
6
|
+
to: string;
|
|
7
|
+
fromHandle: string;
|
|
8
|
+
toHandle: string;
|
|
9
|
+
points: Point[];
|
|
10
|
+
kind: EdgeKind;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Route all edges with orthogonal paths.
|
|
14
|
+
* Reconstructs original edges from dummy node chains.
|
|
15
|
+
*/
|
|
16
|
+
export declare function routeEdges(graph: Graph, direction: LayoutDirection, edgeMargin: number): RoutedEdge[];
|
|
17
|
+
//# sourceMappingURL=edge-routing.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"edge-routing.d.ts","sourceRoot":"","sources":["../../src/algorithms/edge-routing.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAyC,MAAM,UAAU,CAAC;AACxE,OAAO,EAAE,KAAK,EAAc,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AAExE,MAAM,WAAW,UAAU;IACzB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;IACX,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,KAAK,EAAE,CAAC;IAChB,IAAI,EAAE,QAAQ,CAAC;CAChB;AAED;;;GAGG;AACH,wBAAgB,UAAU,CACxB,KAAK,EAAE,KAAK,EACZ,SAAS,EAAE,eAAe,EAC1B,UAAU,EAAE,MAAM,GACjB,UAAU,EAAE,CASd"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Graph } from '../graph';
|
|
2
|
+
/**
|
|
3
|
+
* Two-pass layer assignment:
|
|
4
|
+
*
|
|
5
|
+
* Pass A (control rail): longest-path on the subgraph induced by control
|
|
6
|
+
* edges between non-value nodes. This defines the canonical execution
|
|
7
|
+
* timeline — value nodes do not extend it.
|
|
8
|
+
*
|
|
9
|
+
* Pass B (value pull): each value node is assigned the median layer of its
|
|
10
|
+
* non-value neighbors. This snaps floating constants/imports onto the same
|
|
11
|
+
* layer as the consumer that needs them, where they can be packed laterally.
|
|
12
|
+
*/
|
|
13
|
+
export declare function assignLayers(graph: Graph): string[][];
|
|
14
|
+
/**
|
|
15
|
+
* Insert dummy nodes only for control edges that span multiple layers.
|
|
16
|
+
* Data edges keep their layer-span; routing handles them directly because
|
|
17
|
+
* they're typically short and decorative.
|
|
18
|
+
*/
|
|
19
|
+
export declare function insertDummyNodes(graph: Graph, layers: string[][]): string[][];
|
|
20
|
+
//# sourceMappingURL=layer-assignment.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"layer-assignment.d.ts","sourceRoot":"","sources":["../../src/algorithms/layer-assignment.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAEjC;;;;;;;;;;GAUG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,KAAK,GAAG,MAAM,EAAE,EAAE,CA8GrD;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,GAAG,MAAM,EAAE,EAAE,CA0E7E"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Graph } from '../graph';
|
|
2
|
+
/**
|
|
3
|
+
* After crossing minimization places nodes onto their layers, this pass moves
|
|
4
|
+
* value nodes (data-only) so they sit immediately next to their dominant
|
|
5
|
+
* consumer/producer within the same layer.
|
|
6
|
+
*
|
|
7
|
+
* Without this pass, barycenter ordering happens to scatter values across the
|
|
8
|
+
* layer because their only incident edges are within the same layer (and so
|
|
9
|
+
* provide no cross-layer signal to the barycenter heuristic).
|
|
10
|
+
*
|
|
11
|
+
* Values are placed alternately on either side of the target so a hub
|
|
12
|
+
* consuming multiple values stays balanced.
|
|
13
|
+
*/
|
|
14
|
+
export declare function clusterValues(graph: Graph, layers: string[][]): void;
|
|
15
|
+
//# sourceMappingURL=value-cluster.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"value-cluster.d.ts","sourceRoot":"","sources":["../../src/algorithms/value-cluster.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAEjC;;;;;;;;;;;GAWG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,EAAE,GAAG,IAAI,CAuFpE"}
|