@realflow/core 0.1.0
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/dist/algorithms.d.ts +25 -0
- package/dist/algorithms.d.ts.map +1 -0
- package/dist/algorithms.js +158 -0
- package/dist/algorithms.js.map +1 -0
- package/dist/collab.d.ts +105 -0
- package/dist/collab.d.ts.map +1 -0
- package/dist/collab.js +182 -0
- package/dist/collab.js.map +1 -0
- package/dist/geometry.d.ts +33 -0
- package/dist/geometry.d.ts.map +1 -0
- package/dist/geometry.js +113 -0
- package/dist/geometry.js.map +1 -0
- package/dist/guides.d.ts +14 -0
- package/dist/guides.d.ts.map +1 -0
- package/dist/guides.js +52 -0
- package/dist/guides.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/layout-worker.d.ts +53 -0
- package/dist/layout-worker.d.ts.map +1 -0
- package/dist/layout-worker.js +65 -0
- package/dist/layout-worker.js.map +1 -0
- package/dist/layout.d.ts +97 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +644 -0
- package/dist/layout.js.map +1 -0
- package/dist/ops.d.ts +142 -0
- package/dist/ops.d.ts.map +1 -0
- package/dist/ops.js +351 -0
- package/dist/ops.js.map +1 -0
- package/dist/paths.d.ts +41 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +179 -0
- package/dist/paths.js.map +1 -0
- package/dist/routing.d.ts +36 -0
- package/dist/routing.d.ts.map +1 -0
- package/dist/routing.js +185 -0
- package/dist/routing.js.map +1 -0
- package/dist/spatial.d.ts +43 -0
- package/dist/spatial.d.ts.map +1 -0
- package/dist/spatial.js +175 -0
- package/dist/spatial.js.map +1 -0
- package/dist/store.d.ts +274 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +1610 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +207 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +6 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +25 -0
- package/dist/utils.js.map +1 -0
- package/package.json +40 -0
package/dist/layout.js
ADDED
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
const buildAdj = (nodes, edges) => {
|
|
2
|
+
const out = new Map();
|
|
3
|
+
const inc = new Map();
|
|
4
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
5
|
+
for (const n of nodes) {
|
|
6
|
+
out.set(n.id, []);
|
|
7
|
+
inc.set(n.id, []);
|
|
8
|
+
}
|
|
9
|
+
for (const e of edges) {
|
|
10
|
+
if (!ids.has(e.source) || !ids.has(e.target) || e.source === e.target)
|
|
11
|
+
continue;
|
|
12
|
+
out.get(e.source).push(e.target);
|
|
13
|
+
inc.get(e.target).push(e.source);
|
|
14
|
+
}
|
|
15
|
+
return { out, inc };
|
|
16
|
+
};
|
|
17
|
+
/** DFS-based greedy cycle breaking: back edges get reversed. */
|
|
18
|
+
const acyclicAdj = (nodes, adj) => {
|
|
19
|
+
const state = new Map(); // 0 unvisited, 1 in-stack, 2 done
|
|
20
|
+
const out = new Map();
|
|
21
|
+
const inc = new Map();
|
|
22
|
+
for (const n of nodes) {
|
|
23
|
+
out.set(n.id, []);
|
|
24
|
+
inc.set(n.id, []);
|
|
25
|
+
state.set(n.id, 0);
|
|
26
|
+
}
|
|
27
|
+
const visit = (id) => {
|
|
28
|
+
state.set(id, 1);
|
|
29
|
+
for (const t of adj.out.get(id) ?? []) {
|
|
30
|
+
if (state.get(t) === 1) {
|
|
31
|
+
// Back edge: reverse it.
|
|
32
|
+
out.get(t).push(id);
|
|
33
|
+
inc.get(id).push(t);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
out.get(id).push(t);
|
|
37
|
+
inc.get(t).push(id);
|
|
38
|
+
if (state.get(t) === 0)
|
|
39
|
+
visit(t);
|
|
40
|
+
}
|
|
41
|
+
state.set(id, 2);
|
|
42
|
+
};
|
|
43
|
+
for (const n of nodes)
|
|
44
|
+
if (state.get(n.id) === 0)
|
|
45
|
+
visit(n.id);
|
|
46
|
+
return { out, inc };
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Sugiyama-style layered layout: longest-path ranking, barycenter ordering
|
|
50
|
+
* sweeps, then size-aware coordinate assignment. Handles cycles.
|
|
51
|
+
*/
|
|
52
|
+
export const layeredLayout = (nodes, edges, opts = {}) => {
|
|
53
|
+
const positions = new Map();
|
|
54
|
+
if (nodes.length === 0)
|
|
55
|
+
return positions;
|
|
56
|
+
const { direction = 'LR', nodeGap = 48, rankGap = 96 } = opts;
|
|
57
|
+
const adj = acyclicAdj(nodes, buildAdj(nodes, edges));
|
|
58
|
+
// 1. Rank via longest path from sources (iterative DFS with memo).
|
|
59
|
+
const rank = new Map();
|
|
60
|
+
const computeRank = (id) => {
|
|
61
|
+
const cached = rank.get(id);
|
|
62
|
+
if (cached != null)
|
|
63
|
+
return cached;
|
|
64
|
+
rank.set(id, 0); // guard against residual cycles
|
|
65
|
+
let r = 0;
|
|
66
|
+
for (const p of adj.inc.get(id) ?? [])
|
|
67
|
+
r = Math.max(r, computeRank(p) + 1);
|
|
68
|
+
rank.set(id, r);
|
|
69
|
+
return r;
|
|
70
|
+
};
|
|
71
|
+
for (const n of nodes)
|
|
72
|
+
computeRank(n.id);
|
|
73
|
+
// Pull sources next to their first successor (avoids rank-0 stacking).
|
|
74
|
+
for (const n of nodes) {
|
|
75
|
+
if ((adj.inc.get(n.id) ?? []).length === 0) {
|
|
76
|
+
const succs = adj.out.get(n.id) ?? [];
|
|
77
|
+
if (succs.length > 0) {
|
|
78
|
+
let min = Infinity;
|
|
79
|
+
for (const s of succs)
|
|
80
|
+
min = Math.min(min, rank.get(s));
|
|
81
|
+
if (min - 1 > rank.get(n.id))
|
|
82
|
+
rank.set(n.id, min - 1);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// 2. Group into layers.
|
|
87
|
+
const maxRank = Math.max(...rank.values());
|
|
88
|
+
const layers = Array.from({ length: maxRank + 1 }, () => []);
|
|
89
|
+
for (const n of nodes)
|
|
90
|
+
layers[rank.get(n.id)].push(n.id);
|
|
91
|
+
// 3. Barycenter ordering sweeps.
|
|
92
|
+
const orderIndex = new Map();
|
|
93
|
+
const reindex = () => {
|
|
94
|
+
for (const layer of layers)
|
|
95
|
+
layer.forEach((id, i) => orderIndex.set(id, i));
|
|
96
|
+
};
|
|
97
|
+
reindex();
|
|
98
|
+
const sweep = (down) => {
|
|
99
|
+
const seq = down
|
|
100
|
+
? layers.slice(1).map((_, i) => i + 1)
|
|
101
|
+
: layers.slice(0, -1).map((_, i) => layers.length - 2 - i);
|
|
102
|
+
for (const li of seq) {
|
|
103
|
+
const refs = down ? adj.inc : adj.out;
|
|
104
|
+
layers[li].sort((a, b) => {
|
|
105
|
+
const bary = (id) => {
|
|
106
|
+
const neighbors = refs.get(id) ?? [];
|
|
107
|
+
if (neighbors.length === 0)
|
|
108
|
+
return orderIndex.get(id);
|
|
109
|
+
let sum = 0;
|
|
110
|
+
for (const nb of neighbors)
|
|
111
|
+
sum += orderIndex.get(nb);
|
|
112
|
+
return sum / neighbors.length;
|
|
113
|
+
};
|
|
114
|
+
return bary(a) - bary(b);
|
|
115
|
+
});
|
|
116
|
+
layers[li].forEach((id, i) => orderIndex.set(id, i));
|
|
117
|
+
}
|
|
118
|
+
reindex();
|
|
119
|
+
};
|
|
120
|
+
for (let i = 0; i < 4; i++) {
|
|
121
|
+
sweep(true);
|
|
122
|
+
sweep(false);
|
|
123
|
+
}
|
|
124
|
+
// 4. Coordinates. Main axis follows direction; cross axis stacks nodes.
|
|
125
|
+
const sizeOf = new Map(nodes.map((n) => [n.id, n]));
|
|
126
|
+
const horizontal = direction === 'LR' || direction === 'RL';
|
|
127
|
+
const mainSize = (id) => horizontal ? sizeOf.get(id).width : sizeOf.get(id).height;
|
|
128
|
+
const crossSize = (id) => horizontal ? sizeOf.get(id).height : sizeOf.get(id).width;
|
|
129
|
+
// Cross-axis placement per layer, centered around 0.
|
|
130
|
+
const crossPos = new Map();
|
|
131
|
+
for (const layer of layers) {
|
|
132
|
+
let total = 0;
|
|
133
|
+
for (const id of layer)
|
|
134
|
+
total += crossSize(id);
|
|
135
|
+
total += nodeGap * Math.max(0, layer.length - 1);
|
|
136
|
+
let cursor = -total / 2;
|
|
137
|
+
for (const id of layer) {
|
|
138
|
+
crossPos.set(id, cursor);
|
|
139
|
+
cursor += crossSize(id) + nodeGap;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Cross-axis refinement: pull nodes toward the mean of their neighbors,
|
|
143
|
+
// then resolve overlaps. A cheap substitute for Brandes-Köpf that looks
|
|
144
|
+
// good in practice.
|
|
145
|
+
for (let iter = 0; iter < 3; iter++) {
|
|
146
|
+
for (const layer of layers) {
|
|
147
|
+
const desired = layer.map((id) => {
|
|
148
|
+
const nbs = [...(adj.inc.get(id) ?? []), ...(adj.out.get(id) ?? [])];
|
|
149
|
+
if (nbs.length === 0)
|
|
150
|
+
return crossPos.get(id);
|
|
151
|
+
let sum = 0;
|
|
152
|
+
for (const nb of nbs)
|
|
153
|
+
sum += crossPos.get(nb) + crossSize(nb) / 2;
|
|
154
|
+
return sum / nbs.length - crossSize(id) / 2;
|
|
155
|
+
});
|
|
156
|
+
layer.forEach((id, i) => crossPos.set(id, desired[i]));
|
|
157
|
+
// Resolve overlaps preserving order.
|
|
158
|
+
for (let i = 1; i < layer.length; i++) {
|
|
159
|
+
const prev = layer[i - 1];
|
|
160
|
+
const minPos = crossPos.get(prev) + crossSize(prev) + nodeGap;
|
|
161
|
+
if (crossPos.get(layer[i]) < minPos)
|
|
162
|
+
crossPos.set(layer[i], minPos);
|
|
163
|
+
}
|
|
164
|
+
for (let i = layer.length - 2; i >= 0; i--) {
|
|
165
|
+
const next = layer[i + 1];
|
|
166
|
+
const maxPos = crossPos.get(next) - nodeGap - crossSize(layer[i]);
|
|
167
|
+
if (crossPos.get(layer[i]) > maxPos)
|
|
168
|
+
crossPos.set(layer[i], maxPos);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Main-axis: cumulative layer thickness.
|
|
173
|
+
const layerThickness = layers.map((layer) => layer.length > 0 ? Math.max(...layer.map(mainSize)) : 0);
|
|
174
|
+
const mainStart = [];
|
|
175
|
+
let acc = 0;
|
|
176
|
+
for (let i = 0; i < layers.length; i++) {
|
|
177
|
+
mainStart.push(acc);
|
|
178
|
+
acc += layerThickness[i] + rankGap;
|
|
179
|
+
}
|
|
180
|
+
const totalMain = acc - rankGap;
|
|
181
|
+
for (const n of nodes) {
|
|
182
|
+
const r = rank.get(n.id);
|
|
183
|
+
// Center within the layer band.
|
|
184
|
+
let main = mainStart[r] + (layerThickness[r] - mainSize(n.id)) / 2;
|
|
185
|
+
if (direction === 'RL' || direction === 'BT') {
|
|
186
|
+
main = totalMain - main - mainSize(n.id);
|
|
187
|
+
}
|
|
188
|
+
const cross = crossPos.get(n.id);
|
|
189
|
+
positions.set(n.id, horizontal ? { x: main, y: cross } : { x: cross, y: main });
|
|
190
|
+
}
|
|
191
|
+
return positions;
|
|
192
|
+
};
|
|
193
|
+
/**
|
|
194
|
+
* Tidy tree layout: children are laid out below/beside their parent, parents
|
|
195
|
+
* centered over their subtrees. Non-tree edges are ignored (BFS tree).
|
|
196
|
+
*/
|
|
197
|
+
export const treeLayout = (nodes, edges, opts = {}) => {
|
|
198
|
+
const { direction = 'TB', nodeGap = 32, rankGap = 80 } = opts;
|
|
199
|
+
const positions = new Map();
|
|
200
|
+
if (nodes.length === 0)
|
|
201
|
+
return positions;
|
|
202
|
+
const adj = buildAdj(nodes, edges);
|
|
203
|
+
const horizontal = direction === 'LR' || direction === 'RL';
|
|
204
|
+
const sizeOf = new Map(nodes.map((n) => [n.id, n]));
|
|
205
|
+
const crossSize = (id) => horizontal ? sizeOf.get(id).height : sizeOf.get(id).width;
|
|
206
|
+
const mainSize = (id) => horizontal ? sizeOf.get(id).width : sizeOf.get(id).height;
|
|
207
|
+
// Roots: indegree 0 (fall back to first node of each component).
|
|
208
|
+
const roots = nodes.filter((n) => (adj.inc.get(n.id) ?? []).length === 0).map((n) => n.id);
|
|
209
|
+
const seen = new Set(roots);
|
|
210
|
+
// BFS tree children.
|
|
211
|
+
const treeKids = new Map();
|
|
212
|
+
const queue = [...roots];
|
|
213
|
+
while (queue.length > 0) {
|
|
214
|
+
const id = queue.shift();
|
|
215
|
+
const kids = [];
|
|
216
|
+
for (const t of adj.out.get(id) ?? []) {
|
|
217
|
+
if (!seen.has(t)) {
|
|
218
|
+
seen.add(t);
|
|
219
|
+
kids.push(t);
|
|
220
|
+
queue.push(t);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
treeKids.set(id, kids);
|
|
224
|
+
}
|
|
225
|
+
// Orphans in cycles become extra roots.
|
|
226
|
+
for (const n of nodes) {
|
|
227
|
+
if (!seen.has(n.id)) {
|
|
228
|
+
roots.push(n.id);
|
|
229
|
+
seen.add(n.id);
|
|
230
|
+
const kids = [];
|
|
231
|
+
const q2 = [n.id];
|
|
232
|
+
while (q2.length > 0) {
|
|
233
|
+
const id = q2.shift();
|
|
234
|
+
const ks = [];
|
|
235
|
+
for (const t of adj.out.get(id) ?? []) {
|
|
236
|
+
if (!seen.has(t)) {
|
|
237
|
+
seen.add(t);
|
|
238
|
+
ks.push(t);
|
|
239
|
+
q2.push(t);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
treeKids.set(id, ks);
|
|
243
|
+
kids.push(...ks);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const cross = new Map();
|
|
248
|
+
const depth = new Map();
|
|
249
|
+
let cursor = 0;
|
|
250
|
+
const place = (id, d) => {
|
|
251
|
+
depth.set(id, d);
|
|
252
|
+
const kids = treeKids.get(id) ?? [];
|
|
253
|
+
if (kids.length === 0) {
|
|
254
|
+
cross.set(id, cursor);
|
|
255
|
+
const r = { min: cursor, max: cursor + crossSize(id) };
|
|
256
|
+
cursor += crossSize(id) + nodeGap;
|
|
257
|
+
return r;
|
|
258
|
+
}
|
|
259
|
+
let min = Infinity;
|
|
260
|
+
let max = -Infinity;
|
|
261
|
+
let firstMid = 0;
|
|
262
|
+
let lastMid = 0;
|
|
263
|
+
kids.forEach((kid, i) => {
|
|
264
|
+
const ext = place(kid, d + 1);
|
|
265
|
+
min = Math.min(min, ext.min);
|
|
266
|
+
max = Math.max(max, ext.max);
|
|
267
|
+
const mid = cross.get(kid) + crossSize(kid) / 2;
|
|
268
|
+
if (i === 0)
|
|
269
|
+
firstMid = mid;
|
|
270
|
+
lastMid = mid;
|
|
271
|
+
});
|
|
272
|
+
const center = (firstMid + lastMid) / 2;
|
|
273
|
+
cross.set(id, center - crossSize(id) / 2);
|
|
274
|
+
return { min: Math.min(min, cross.get(id)), max: Math.max(max, cross.get(id) + crossSize(id)) };
|
|
275
|
+
};
|
|
276
|
+
for (const root of roots)
|
|
277
|
+
place(root, 0);
|
|
278
|
+
// Main axis: per-depth thickness.
|
|
279
|
+
const maxDepth = Math.max(...depth.values());
|
|
280
|
+
const thickness = Array.from({ length: maxDepth + 1 }, () => 0);
|
|
281
|
+
for (const n of nodes) {
|
|
282
|
+
const d = depth.get(n.id);
|
|
283
|
+
thickness[d] = Math.max(thickness[d], mainSize(n.id));
|
|
284
|
+
}
|
|
285
|
+
const mainStart = [];
|
|
286
|
+
let acc = 0;
|
|
287
|
+
for (let d = 0; d <= maxDepth; d++) {
|
|
288
|
+
mainStart.push(acc);
|
|
289
|
+
acc += thickness[d] + rankGap;
|
|
290
|
+
}
|
|
291
|
+
const totalMain = acc - rankGap;
|
|
292
|
+
for (const n of nodes) {
|
|
293
|
+
const d = depth.get(n.id);
|
|
294
|
+
let main = mainStart[d] + (thickness[d] - mainSize(n.id)) / 2;
|
|
295
|
+
if (direction === 'RL' || direction === 'BT')
|
|
296
|
+
main = totalMain - main - mainSize(n.id);
|
|
297
|
+
const c = cross.get(n.id);
|
|
298
|
+
positions.set(n.id, horizontal ? { x: main, y: c } : { x: c, y: main });
|
|
299
|
+
}
|
|
300
|
+
return positions;
|
|
301
|
+
};
|
|
302
|
+
/** Fruchterman–Reingold force layout with grid-bucketed repulsion. */
|
|
303
|
+
export const forceLayout = (nodes, edges, opts = {}) => {
|
|
304
|
+
const positions = new Map();
|
|
305
|
+
const n = nodes.length;
|
|
306
|
+
if (n === 0)
|
|
307
|
+
return positions;
|
|
308
|
+
const { iterations = 250, linkDistance = 180, seed = 42 } = opts;
|
|
309
|
+
// Deterministic PRNG (mulberry32) so layouts are reproducible.
|
|
310
|
+
let s = seed >>> 0;
|
|
311
|
+
const rand = () => {
|
|
312
|
+
s |= 0;
|
|
313
|
+
s = (s + 0x6d2b79f5) | 0;
|
|
314
|
+
let t = Math.imul(s ^ (s >>> 15), 1 | s);
|
|
315
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
316
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
317
|
+
};
|
|
318
|
+
const idx = new Map(nodes.map((node, i) => [node.id, i]));
|
|
319
|
+
const px = new Float64Array(n);
|
|
320
|
+
const py = new Float64Array(n);
|
|
321
|
+
const dx = new Float64Array(n);
|
|
322
|
+
const dy = new Float64Array(n);
|
|
323
|
+
const radius = (linkDistance * Math.sqrt(n)) / 2;
|
|
324
|
+
nodes.forEach((node, i) => {
|
|
325
|
+
const init = opts.initial?.get(node.id);
|
|
326
|
+
if (init) {
|
|
327
|
+
px[i] = init.x;
|
|
328
|
+
py[i] = init.y;
|
|
329
|
+
}
|
|
330
|
+
else {
|
|
331
|
+
const angle = (i / n) * Math.PI * 2;
|
|
332
|
+
px[i] = Math.cos(angle) * radius + (rand() - 0.5) * linkDistance;
|
|
333
|
+
py[i] = Math.sin(angle) * radius + (rand() - 0.5) * linkDistance;
|
|
334
|
+
}
|
|
335
|
+
});
|
|
336
|
+
const links = [];
|
|
337
|
+
for (const e of edges) {
|
|
338
|
+
const a = idx.get(e.source);
|
|
339
|
+
const b = idx.get(e.target);
|
|
340
|
+
if (a != null && b != null && a !== b)
|
|
341
|
+
links.push([a, b]);
|
|
342
|
+
}
|
|
343
|
+
const k = linkDistance;
|
|
344
|
+
const k2 = k * k;
|
|
345
|
+
let temp = radius / 4;
|
|
346
|
+
const cool = Math.pow(0.01, 1 / iterations);
|
|
347
|
+
for (let iter = 0; iter < iterations; iter++) {
|
|
348
|
+
dx.fill(0);
|
|
349
|
+
dy.fill(0);
|
|
350
|
+
// Repulsion via uniform grid: only nearby buckets interact.
|
|
351
|
+
const cell = k * 2;
|
|
352
|
+
const grid = new Map();
|
|
353
|
+
for (let i = 0; i < n; i++) {
|
|
354
|
+
const key = `${Math.floor(px[i] / cell)}:${Math.floor(py[i] / cell)}`;
|
|
355
|
+
let bucket = grid.get(key);
|
|
356
|
+
if (!bucket) {
|
|
357
|
+
bucket = [];
|
|
358
|
+
grid.set(key, bucket);
|
|
359
|
+
}
|
|
360
|
+
bucket.push(i);
|
|
361
|
+
}
|
|
362
|
+
for (let i = 0; i < n; i++) {
|
|
363
|
+
const cx = Math.floor(px[i] / cell);
|
|
364
|
+
const cy = Math.floor(py[i] / cell);
|
|
365
|
+
for (let gx = cx - 1; gx <= cx + 1; gx++) {
|
|
366
|
+
for (let gy = cy - 1; gy <= cy + 1; gy++) {
|
|
367
|
+
const bucket = grid.get(`${gx}:${gy}`);
|
|
368
|
+
if (!bucket)
|
|
369
|
+
continue;
|
|
370
|
+
for (const j of bucket) {
|
|
371
|
+
if (j === i)
|
|
372
|
+
continue;
|
|
373
|
+
let ddx = px[i] - px[j];
|
|
374
|
+
let ddy = py[i] - py[j];
|
|
375
|
+
let d2 = ddx * ddx + ddy * ddy;
|
|
376
|
+
if (d2 < 0.01) {
|
|
377
|
+
ddx = rand() - 0.5;
|
|
378
|
+
ddy = rand() - 0.5;
|
|
379
|
+
d2 = ddx * ddx + ddy * ddy;
|
|
380
|
+
}
|
|
381
|
+
const f = k2 / d2;
|
|
382
|
+
dx[i] += ddx * f;
|
|
383
|
+
dy[i] += ddy * f;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
// Attraction along links.
|
|
389
|
+
for (const [a, b] of links) {
|
|
390
|
+
const ddx = px[a] - px[b];
|
|
391
|
+
const ddy = py[a] - py[b];
|
|
392
|
+
const d = Math.sqrt(ddx * ddx + ddy * ddy) || 0.01;
|
|
393
|
+
const f = (d * d) / k / d;
|
|
394
|
+
dx[a] -= ddx * f;
|
|
395
|
+
dy[a] -= ddy * f;
|
|
396
|
+
dx[b] += ddx * f;
|
|
397
|
+
dy[b] += ddy * f;
|
|
398
|
+
}
|
|
399
|
+
for (let i = 0; i < n; i++) {
|
|
400
|
+
const d = Math.sqrt(dx[i] * dx[i] + dy[i] * dy[i]) || 0.01;
|
|
401
|
+
const lim = Math.min(d, temp);
|
|
402
|
+
px[i] += (dx[i] / d) * lim;
|
|
403
|
+
py[i] += (dy[i] / d) * lim;
|
|
404
|
+
}
|
|
405
|
+
temp *= cool;
|
|
406
|
+
}
|
|
407
|
+
nodes.forEach((node, i) => {
|
|
408
|
+
positions.set(node.id, {
|
|
409
|
+
x: px[i] - node.width / 2,
|
|
410
|
+
y: py[i] - node.height / 2,
|
|
411
|
+
});
|
|
412
|
+
});
|
|
413
|
+
return positions;
|
|
414
|
+
};
|
|
415
|
+
/** Uniform grid placement in current order. */
|
|
416
|
+
export const gridLayout = (nodes, opts = {}) => {
|
|
417
|
+
const positions = new Map();
|
|
418
|
+
if (nodes.length === 0)
|
|
419
|
+
return positions;
|
|
420
|
+
const columns = opts.columns ?? Math.ceil(Math.sqrt(nodes.length));
|
|
421
|
+
const gap = opts.gap ?? 48;
|
|
422
|
+
const colWidth = Math.max(...nodes.map((n) => n.width)) + gap;
|
|
423
|
+
const rowHeight = Math.max(...nodes.map((n) => n.height)) + gap;
|
|
424
|
+
nodes.forEach((n, i) => {
|
|
425
|
+
positions.set(n.id, {
|
|
426
|
+
x: (i % columns) * colWidth,
|
|
427
|
+
y: Math.floor(i / columns) * rowHeight,
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
return positions;
|
|
431
|
+
};
|
|
432
|
+
/** Concentric rings by BFS depth from the roots. */
|
|
433
|
+
export const radialLayout = (nodes, edges, opts = {}) => {
|
|
434
|
+
const positions = new Map();
|
|
435
|
+
if (nodes.length === 0)
|
|
436
|
+
return positions;
|
|
437
|
+
const ringGap = opts.ringGap ?? 160;
|
|
438
|
+
const adj = buildAdj(nodes, edges);
|
|
439
|
+
const roots = nodes.filter((n) => (adj.inc.get(n.id) ?? []).length === 0).map((n) => n.id);
|
|
440
|
+
const start = roots.length > 0 ? roots : [nodes[0].id];
|
|
441
|
+
const depth = new Map();
|
|
442
|
+
const queue = [];
|
|
443
|
+
for (const r of start) {
|
|
444
|
+
depth.set(r, 0);
|
|
445
|
+
queue.push(r);
|
|
446
|
+
}
|
|
447
|
+
while (queue.length > 0) {
|
|
448
|
+
const id = queue.shift();
|
|
449
|
+
const d = depth.get(id);
|
|
450
|
+
for (const t of [...(adj.out.get(id) ?? []), ...(adj.inc.get(id) ?? [])]) {
|
|
451
|
+
if (!depth.has(t)) {
|
|
452
|
+
depth.set(t, d + 1);
|
|
453
|
+
queue.push(t);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
for (const n of nodes)
|
|
458
|
+
if (!depth.has(n.id))
|
|
459
|
+
depth.set(n.id, 1);
|
|
460
|
+
const rings = new Map();
|
|
461
|
+
for (const [id, d] of depth) {
|
|
462
|
+
let ring = rings.get(d);
|
|
463
|
+
if (!ring) {
|
|
464
|
+
ring = [];
|
|
465
|
+
rings.set(d, ring);
|
|
466
|
+
}
|
|
467
|
+
ring.push(id);
|
|
468
|
+
}
|
|
469
|
+
const sizeOf = new Map(nodes.map((n) => [n.id, n]));
|
|
470
|
+
for (const [d, ring] of rings) {
|
|
471
|
+
const r = d * ringGap;
|
|
472
|
+
ring.forEach((id, i) => {
|
|
473
|
+
const angle = (i / ring.length) * Math.PI * 2 - Math.PI / 2;
|
|
474
|
+
const node = sizeOf.get(id);
|
|
475
|
+
positions.set(id, {
|
|
476
|
+
x: Math.cos(angle) * r - node.width / 2,
|
|
477
|
+
y: Math.sin(angle) * r - node.height / 2,
|
|
478
|
+
});
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
return positions;
|
|
482
|
+
};
|
|
483
|
+
/** Compute a layout for the store's root-level nodes. */
|
|
484
|
+
export const computeLayout = (store, type, opts = {}) => {
|
|
485
|
+
const include = opts.nodes ? new Set(opts.nodes) : null;
|
|
486
|
+
const layoutNodes = [];
|
|
487
|
+
for (const node of store.getNodes()) {
|
|
488
|
+
if (node.parentId || node.hidden)
|
|
489
|
+
continue;
|
|
490
|
+
if (include && !include.has(node.id))
|
|
491
|
+
continue;
|
|
492
|
+
const size = store.nodeSize(node.id);
|
|
493
|
+
layoutNodes.push({ id: node.id, width: size.width, height: size.height });
|
|
494
|
+
}
|
|
495
|
+
const idSet = new Set(layoutNodes.map((n) => n.id));
|
|
496
|
+
// Edges attached to nested children count as edges between their root
|
|
497
|
+
// ancestors, so groups participate in the layout sensibly.
|
|
498
|
+
const rootOf = (id) => {
|
|
499
|
+
let cur = store.getNode(id);
|
|
500
|
+
let guard = 0;
|
|
501
|
+
while (cur?.parentId && guard++ < 100) {
|
|
502
|
+
const parent = store.getNode(cur.parentId);
|
|
503
|
+
if (!parent)
|
|
504
|
+
break;
|
|
505
|
+
cur = parent;
|
|
506
|
+
}
|
|
507
|
+
return cur?.id ?? id;
|
|
508
|
+
};
|
|
509
|
+
const layoutEdges = [];
|
|
510
|
+
const seen = new Set();
|
|
511
|
+
for (const e of store.getEdges()) {
|
|
512
|
+
const source = idSet.has(e.source) ? e.source : rootOf(e.source);
|
|
513
|
+
const target = idSet.has(e.target) ? e.target : rootOf(e.target);
|
|
514
|
+
if (!idSet.has(source) || !idSet.has(target) || source === target)
|
|
515
|
+
continue;
|
|
516
|
+
const key = `${source}→${target}`;
|
|
517
|
+
if (seen.has(key))
|
|
518
|
+
continue;
|
|
519
|
+
seen.add(key);
|
|
520
|
+
layoutEdges.push({ source, target });
|
|
521
|
+
}
|
|
522
|
+
switch (type) {
|
|
523
|
+
case 'layered':
|
|
524
|
+
return layeredLayout(layoutNodes, layoutEdges, opts);
|
|
525
|
+
case 'tree':
|
|
526
|
+
return treeLayout(layoutNodes, layoutEdges, opts);
|
|
527
|
+
case 'force':
|
|
528
|
+
return forceLayout(layoutNodes, layoutEdges, {
|
|
529
|
+
...opts,
|
|
530
|
+
initial: opts.initial ??
|
|
531
|
+
new Map(layoutNodes.map((n) => [n.id, store.getNode(n.id).position])),
|
|
532
|
+
});
|
|
533
|
+
case 'grid':
|
|
534
|
+
return gridLayout(layoutNodes, opts);
|
|
535
|
+
case 'radial':
|
|
536
|
+
return radialLayout(layoutNodes, layoutEdges, opts);
|
|
537
|
+
}
|
|
538
|
+
};
|
|
539
|
+
/** Apply computed positions as a single undoable transaction. */
|
|
540
|
+
export const applyLayout = (store, positions) => {
|
|
541
|
+
store.transact('layout', () => {
|
|
542
|
+
for (const [id, pos] of positions) {
|
|
543
|
+
const node = store.getNode(id);
|
|
544
|
+
if (!node)
|
|
545
|
+
continue;
|
|
546
|
+
if (node.position.x !== pos.x || node.position.y !== pos.y) {
|
|
547
|
+
store.setNodePosition(id, pos);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
};
|
|
552
|
+
/**
|
|
553
|
+
* Position only the given new nodes, leaving the existing graph untouched —
|
|
554
|
+
* so adding one node doesn't reshuffle the whole diagram. Each new node is
|
|
555
|
+
* placed next to its already-positioned neighbors (downstream of a source),
|
|
556
|
+
* then nudged to avoid overlapping existing nodes. Nodes with no positioned
|
|
557
|
+
* neighbor are dropped into free space below the current bounds.
|
|
558
|
+
*
|
|
559
|
+
* Returns the computed positions (does not mutate the store — pair with
|
|
560
|
+
* `applyLayout`).
|
|
561
|
+
*/
|
|
562
|
+
export const incrementalLayout = (store, newNodeIds, opts = {}) => {
|
|
563
|
+
const gap = opts.gap ?? 40;
|
|
564
|
+
const stepX = opts.stepX ?? 220;
|
|
565
|
+
const stepY = opts.stepY ?? 120;
|
|
566
|
+
const positions = new Map();
|
|
567
|
+
const isNew = new Set(newNodeIds);
|
|
568
|
+
// Rects of the existing (already-placed) nodes, for overlap avoidance.
|
|
569
|
+
const placed = [];
|
|
570
|
+
const bounds = store.nodesBounds(store.getNodes().filter((n) => !isNew.has(n.id)).map((n) => n.id));
|
|
571
|
+
for (const node of store.getNodes()) {
|
|
572
|
+
if (isNew.has(node.id) || node.parentId)
|
|
573
|
+
continue;
|
|
574
|
+
const s = store.nodeSize(node.id);
|
|
575
|
+
placed.push({ id: node.id, x: node.position.x, y: node.position.y, w: s.width, h: s.height });
|
|
576
|
+
}
|
|
577
|
+
const overlaps = (x, y, w, h) => {
|
|
578
|
+
for (const r of placed) {
|
|
579
|
+
if (x < r.x + r.w + gap && x + w + gap > r.x && y < r.y + r.h + gap && y + h + gap > r.y) {
|
|
580
|
+
return true;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return false;
|
|
584
|
+
};
|
|
585
|
+
let fallbackY = bounds.height > 0 ? bounds.y + bounds.height + stepY : 0;
|
|
586
|
+
const fallbackX0 = bounds.width > 0 ? bounds.x : 0;
|
|
587
|
+
let fallbackX = fallbackX0;
|
|
588
|
+
for (const id of newNodeIds) {
|
|
589
|
+
const node = store.getNode(id);
|
|
590
|
+
if (!node)
|
|
591
|
+
continue;
|
|
592
|
+
const size = store.nodeSize(id);
|
|
593
|
+
// Anchor: average of positioned neighbors, offset downstream.
|
|
594
|
+
let ax = null;
|
|
595
|
+
let ay = 0;
|
|
596
|
+
let count = 0;
|
|
597
|
+
for (const e of store.edgesOf(id)) {
|
|
598
|
+
const otherId = e.source === id ? e.target : e.source;
|
|
599
|
+
if (isNew.has(otherId))
|
|
600
|
+
continue;
|
|
601
|
+
const other = store.getNode(otherId);
|
|
602
|
+
if (!other)
|
|
603
|
+
continue;
|
|
604
|
+
const os = store.nodeSize(otherId);
|
|
605
|
+
// Downstream of a source, upstream-left of a target.
|
|
606
|
+
const dir = e.source === otherId ? 1 : -1;
|
|
607
|
+
ax = (ax ?? 0) + other.position.x + dir * (os.width + stepX - os.width);
|
|
608
|
+
ay += other.position.y;
|
|
609
|
+
count++;
|
|
610
|
+
}
|
|
611
|
+
let x;
|
|
612
|
+
let y;
|
|
613
|
+
if (ax != null && count > 0) {
|
|
614
|
+
x = ax / count;
|
|
615
|
+
y = ay / count;
|
|
616
|
+
}
|
|
617
|
+
else {
|
|
618
|
+
// No positioned neighbor: drop into free space below the graph.
|
|
619
|
+
x = fallbackX;
|
|
620
|
+
y = fallbackY;
|
|
621
|
+
fallbackX += size.width + stepX;
|
|
622
|
+
if (fallbackX > fallbackX0 + 6 * stepX) {
|
|
623
|
+
fallbackX = fallbackX0;
|
|
624
|
+
fallbackY += stepY;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
// Nudge downward until it doesn't overlap an existing node.
|
|
628
|
+
let guard = 0;
|
|
629
|
+
while (overlaps(x, y, size.width, size.height) && guard++ < 200) {
|
|
630
|
+
y += size.height + gap;
|
|
631
|
+
}
|
|
632
|
+
positions.set(id, { x, y });
|
|
633
|
+
placed.push({ id, x, y, w: size.width, h: size.height });
|
|
634
|
+
}
|
|
635
|
+
return positions;
|
|
636
|
+
};
|
|
637
|
+
/** Convenience: compute + apply + optionally fit the view. */
|
|
638
|
+
export const layout = (store, type, opts = {}) => {
|
|
639
|
+
applyLayout(store, computeLayout(store, type, opts));
|
|
640
|
+
if (opts.fitView !== false) {
|
|
641
|
+
store.fitView({ duration: opts.duration ?? 300, padding: 0.12 });
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
//# sourceMappingURL=layout.js.map
|