@archcode-io/engine 0.2.0-preview.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/CHANGELOG.md +31 -0
- package/LICENSE +202 -0
- package/README.md +88 -0
- package/dist/src/capacity.d.ts +67 -0
- package/dist/src/capacity.js +227 -0
- package/dist/src/check.d.ts +21 -0
- package/dist/src/check.js +192 -0
- package/dist/src/compiled.d.ts +61 -0
- package/dist/src/compiled.js +91 -0
- package/dist/src/cst.d.ts +76 -0
- package/dist/src/cst.js +1 -0
- package/dist/src/diagnostics.d.ts +9 -0
- package/dist/src/diagnostics.js +1 -0
- package/dist/src/edit.d.ts +48 -0
- package/dist/src/edit.js +442 -0
- package/dist/src/index.d.ts +25 -0
- package/dist/src/index.js +24 -0
- package/dist/src/layout/balance.d.ts +34 -0
- package/dist/src/layout/balance.js +327 -0
- package/dist/src/layout/elk.d.ts +64 -0
- package/dist/src/layout/elk.js +267 -0
- package/dist/src/layout/host.d.ts +23 -0
- package/dist/src/layout/host.js +23 -0
- package/dist/src/layout/label.d.ts +49 -0
- package/dist/src/layout/label.js +113 -0
- package/dist/src/layout/measure.d.ts +11 -0
- package/dist/src/layout/measure.js +27 -0
- package/dist/src/layout/ortho.d.ts +54 -0
- package/dist/src/layout/ortho.js +206 -0
- package/dist/src/layout/route.d.ts +57 -0
- package/dist/src/layout/route.js +230 -0
- package/dist/src/lens.d.ts +83 -0
- package/dist/src/lens.js +377 -0
- package/dist/src/lexer.d.ts +7 -0
- package/dist/src/lexer.js +135 -0
- package/dist/src/model.d.ts +63 -0
- package/dist/src/model.js +114 -0
- package/dist/src/parser.d.ts +7 -0
- package/dist/src/parser.js +305 -0
- package/dist/src/render/svg.d.ts +56 -0
- package/dist/src/render/svg.js +289 -0
- package/dist/src/serialize.d.ts +10 -0
- package/dist/src/serialize.js +12 -0
- package/dist/src/tokens.d.ts +26 -0
- package/dist/src/tokens.js +15 -0
- package/dist/src/vocab.d.ts +38 -0
- package/dist/src/vocab.js +58 -0
- package/package.json +61 -0
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-phase layout — how the reference decks are actually drawn.
|
|
3
|
+
*
|
|
4
|
+
* One layered layout over the whole tree (ELK `INCLUDE_CHILDREN`) stretches
|
|
5
|
+
* every system across every layer of the picture: tall empty frames, 10–15 %
|
|
6
|
+
* ink, aspect 0.4–0.8. Measured on the six reference architectures nothing in
|
|
7
|
+
* ELK's option space moves that by more than one point. What does is doing what
|
|
8
|
+
* a person does: lay out each system on its own, then arrange the systems.
|
|
9
|
+
*
|
|
10
|
+
* 1. every top-level frame is laid out separately, top-down or left-to-right —
|
|
11
|
+
* whichever lands nearer the target aspect;
|
|
12
|
+
* 2. the top level is laid out again with frames as opaque nodes carrying
|
|
13
|
+
* fixed ports on the border facing each partner (people first, downstream
|
|
14
|
+
* third parties last);
|
|
15
|
+
* 3. the leg from a port to the card inside is routed around the frame's other
|
|
16
|
+
* members and its title text.
|
|
17
|
+
*
|
|
18
|
+
* Deterministic, ~150 ms on the references, and it scores 53 against 39 for the
|
|
19
|
+
* single pass (aspect 1.3–2.2, no edge through a card).
|
|
20
|
+
*/
|
|
21
|
+
import { footerOf } from '../lens.js';
|
|
22
|
+
import { layoutLayered } from './elk.js';
|
|
23
|
+
import { elkInstance } from './host.js';
|
|
24
|
+
import { nodeBox, textWidth } from './measure.js';
|
|
25
|
+
import { makeGrid, astar, stub, simplify, markUsed, routeSimple } from './route.js';
|
|
26
|
+
const bandOf = (n) => (n.kind === 'actor' ? 0 : n.kind === 'external' ? 2 : 1);
|
|
27
|
+
const cx = (b) => b.x + b.w / 2, cy = (b) => b.y + b.h / 2;
|
|
28
|
+
const idsUnder = (n) => { const out = [n.id]; const w = (k) => { for (const c of k.children) {
|
|
29
|
+
out.push(c.id);
|
|
30
|
+
w(c);
|
|
31
|
+
} }; w(n); return out; };
|
|
32
|
+
const underId = (id, f) => id !== f && (id.startsWith(f + '.') || id.startsWith(f + '/'));
|
|
33
|
+
const TITLE_H = 34;
|
|
34
|
+
const WRAP = (t) => ({
|
|
35
|
+
'elk.partitioning.activate': 'false', 'elk.layered.wrapping.strategy': 'MULTI_EDGE', 'elk.aspectRatio': String(t),
|
|
36
|
+
'elk.layered.wrapping.additionalEdgeSpacing': '20', 'elk.layered.wrapping.multiEdge.improveCuts': 'true',
|
|
37
|
+
});
|
|
38
|
+
/** Lay one frame out on its own and normalise it to (0, 0). */
|
|
39
|
+
async function frameItem(n, edges, dir, extra) {
|
|
40
|
+
const sub = await layoutLayered({ roots: [n], edges }, { root: { 'elk.direction': dir, ...extra }, boundary: extra });
|
|
41
|
+
const frame = sub.nodes.find(k => k.id === n.id);
|
|
42
|
+
const ox = frame.x, oy = frame.y;
|
|
43
|
+
for (const k of sub.nodes) {
|
|
44
|
+
k.x -= ox;
|
|
45
|
+
k.y -= oy;
|
|
46
|
+
}
|
|
47
|
+
for (const e of sub.edges) {
|
|
48
|
+
e.points = e.points.map(p => ({ x: p.x - ox, y: p.y - oy }));
|
|
49
|
+
if (e.labelPos)
|
|
50
|
+
e.labelPos = { x: e.labelPos.x - ox, y: e.labelPos.y - oy };
|
|
51
|
+
}
|
|
52
|
+
return { node: n, id: n.id, kind: n.kind, band: 1, w: frame.w, h: frame.h, sub, members: sub.nodes, x: 0, y: 0 };
|
|
53
|
+
}
|
|
54
|
+
/** Whichever of DOWN / RIGHT lands the frame nearer the target aspect (the outer rows add height). */
|
|
55
|
+
async function autoDir(n, edges, target) {
|
|
56
|
+
let best = null;
|
|
57
|
+
for (const dir of ['DOWN', 'RIGHT']) {
|
|
58
|
+
const l = await layoutLayered({ roots: [n], edges }, { root: { 'elk.direction': dir } });
|
|
59
|
+
const f = l.nodes.find(k => k.id === n.id);
|
|
60
|
+
const err = Math.abs(Math.log2((f.w / f.h) / (target * 1.3)));
|
|
61
|
+
if (!best || err < best.err)
|
|
62
|
+
best = { dir, err };
|
|
63
|
+
}
|
|
64
|
+
return best.dir;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Third parties are drawn at the bottom — unless they feed the system (called
|
|
68
|
+
* by a person, or only calling in and never called): those read as upstream
|
|
69
|
+
* and belong in the top row, or every call from them loops back up the page.
|
|
70
|
+
*/
|
|
71
|
+
function smartBands(items, g) {
|
|
72
|
+
const itemOf = new Map();
|
|
73
|
+
for (const it of items) {
|
|
74
|
+
itemOf.set(it.id, it);
|
|
75
|
+
for (const m of it.members)
|
|
76
|
+
itemOf.set(m.id, it);
|
|
77
|
+
}
|
|
78
|
+
for (const it of items) {
|
|
79
|
+
if (it.kind !== 'external')
|
|
80
|
+
continue;
|
|
81
|
+
let fromActor = false, into = 0, outof = 0;
|
|
82
|
+
for (const e of g.edges) {
|
|
83
|
+
const A = itemOf.get(e.from), B = itemOf.get(e.to);
|
|
84
|
+
if (B === it && A?.band === 0)
|
|
85
|
+
fromActor = true;
|
|
86
|
+
if (B === it && A?.band === 1)
|
|
87
|
+
into++;
|
|
88
|
+
if (A === it && B?.band === 1)
|
|
89
|
+
outof++;
|
|
90
|
+
}
|
|
91
|
+
if (fromActor || (outof > 0 && into === 0))
|
|
92
|
+
it.band = 0;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Is `side` of leaf `n` clear up to the border of its frame (no sibling in the way)? */
|
|
96
|
+
function sideFree(n, side, siblings) {
|
|
97
|
+
for (const s of siblings) {
|
|
98
|
+
if (s.id === n.id)
|
|
99
|
+
continue;
|
|
100
|
+
const xo = s.x < n.x + n.w && s.x + s.w > n.x, yo = s.y < n.y + n.h && s.y + s.h > n.y;
|
|
101
|
+
if (side === 'top' && xo && s.y + s.h <= n.y + 1)
|
|
102
|
+
return false;
|
|
103
|
+
if (side === 'bottom' && xo && s.y >= n.y + n.h - 1)
|
|
104
|
+
return false;
|
|
105
|
+
if (side === 'left' && yo && s.x + s.w <= n.x + 1)
|
|
106
|
+
return false;
|
|
107
|
+
if (side === 'right' && yo && s.x >= n.x + n.w - 1)
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
/** Obstacles for a leg inside a frame: every other card whole, nested frames' title strips. */
|
|
113
|
+
export function obstaclesFor(nodes, a, b) {
|
|
114
|
+
const obs = [];
|
|
115
|
+
for (const n of nodes) {
|
|
116
|
+
if (n.id === a.id || n.id === b.id)
|
|
117
|
+
continue;
|
|
118
|
+
if (!n.isBoundary) {
|
|
119
|
+
obs.push(n);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const holds = underId(a.id, n.id) || underId(b.id, n.id);
|
|
123
|
+
if (holds) {
|
|
124
|
+
obs.push({ x: n.x, y: n.y, w: Math.min(n.w, 16 + textWidth(n.label, 13) * 1.08 + 10 + textWidth(`[${n.kind}]`, 9.5) + 16), h: TITLE_H });
|
|
125
|
+
const footer = footerOf(n);
|
|
126
|
+
if (footer)
|
|
127
|
+
obs.push({ x: n.x, y: n.y + n.h - footer - 6, w: n.w, h: footer + 6 }); // the agent chips along the bottom
|
|
128
|
+
}
|
|
129
|
+
else
|
|
130
|
+
obs.push(n);
|
|
131
|
+
}
|
|
132
|
+
return obs;
|
|
133
|
+
}
|
|
134
|
+
const TOP_OPTS = {
|
|
135
|
+
'elk.algorithm': 'layered', 'elk.direction': 'DOWN', 'elk.edgeRouting': 'ORTHOGONAL',
|
|
136
|
+
'elk.partitioning.activate': 'true',
|
|
137
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': '72', 'elk.spacing.nodeNode': '40',
|
|
138
|
+
'elk.spacing.edgeNode': '18', 'elk.spacing.edgeEdge': '14', 'elk.layered.spacing.edgeNodeBetweenLayers': '24',
|
|
139
|
+
'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF', 'elk.layered.nodePlacement.favorStraightEdges': 'true',
|
|
140
|
+
'elk.layered.considerModelOrder.strategy': 'NODES_AND_EDGES',
|
|
141
|
+
'elk.spacing.labelNode': '8', 'elk.spacing.edgeLabel': '6',
|
|
142
|
+
};
|
|
143
|
+
export async function layoutTwoPhase(g, opts = {}) {
|
|
144
|
+
const target = opts.target ?? 1.6;
|
|
145
|
+
const dirOpt = opts.dir ?? 'auto';
|
|
146
|
+
// ---- phase 1: every top-level thing becomes an item ----
|
|
147
|
+
const items = [];
|
|
148
|
+
for (const n of g.roots) {
|
|
149
|
+
if (!n.children.length) {
|
|
150
|
+
const box = nodeBox(n.label, n.tech, n.kind, n.meta, n.agents);
|
|
151
|
+
items.push({ node: n, id: n.id, kind: n.kind, band: bandOf(n), w: box.w, h: box.h, sub: null, members: [], x: 0, y: 0 });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const ids = new Set(idsUnder(n));
|
|
155
|
+
const edges = g.edges.filter(e => ids.has(e.from) && ids.has(e.to));
|
|
156
|
+
const dir = dirOpt === 'auto' ? await autoDir(n, edges, target) : dirOpt;
|
|
157
|
+
items.push(await frameItem(n, edges, dir, opts.wrap ? WRAP(target * 1.3) : {}));
|
|
158
|
+
}
|
|
159
|
+
if (!items.length)
|
|
160
|
+
return { nodes: [], edges: [], width: 0, height: 0 };
|
|
161
|
+
smartBands(items, g);
|
|
162
|
+
const itemOf = new Map();
|
|
163
|
+
for (const it of items) {
|
|
164
|
+
itemOf.set(it.id, it);
|
|
165
|
+
for (const m of it.members)
|
|
166
|
+
itemOf.set(m.id, it);
|
|
167
|
+
}
|
|
168
|
+
const cross = g.edges.map((e, i) => ({ e, i })).filter(({ e }) => itemOf.get(e.from) && itemOf.get(e.to) && itemOf.get(e.from) !== itemOf.get(e.to));
|
|
169
|
+
const elk = await elkInstance();
|
|
170
|
+
const nodeOf = (it) => ({
|
|
171
|
+
id: it.id, width: it.w, height: it.h,
|
|
172
|
+
layoutOptions: {
|
|
173
|
+
'elk.partitioning.partition': String(it.band),
|
|
174
|
+
// people first, downstream third parties last; an upstream third party sits in
|
|
175
|
+
// the top partition but must not be FIRST — ELK refuses an edge between two FIRST nodes
|
|
176
|
+
...(it.kind === 'actor' ? { 'elk.layered.layering.layerConstraint': 'FIRST' } : {}),
|
|
177
|
+
...(it.band === 2 && it.kind === 'external' ? { 'elk.layered.layering.layerConstraint': 'LAST' } : {}),
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
// ---- pass 1: no ports — learn who ends up above whom ----
|
|
181
|
+
const r1 = await elk.layout({
|
|
182
|
+
id: 'root', layoutOptions: TOP_OPTS, children: items.map(nodeOf),
|
|
183
|
+
edges: cross.map(({ e, i }) => ({ id: `e${i}`, sources: [itemOf.get(e.from).id], targets: [itemOf.get(e.to).id] })),
|
|
184
|
+
});
|
|
185
|
+
const pos1 = new Map(r1.children.map((c) => [c.id, c]));
|
|
186
|
+
// ---- pass 2: fixed ports at the inner endpoint's coordinate on the facing border ----
|
|
187
|
+
const ports = new Map(items.map(it => [it.id, []]));
|
|
188
|
+
const portCount = new Map();
|
|
189
|
+
const ensurePort = (it, leafId, side) => {
|
|
190
|
+
const list = ports.get(it.id);
|
|
191
|
+
const k = `${it.id}::${leafId}::${side}`;
|
|
192
|
+
const n = portCount.get(k) ?? 0;
|
|
193
|
+
portCount.set(k, n + 1);
|
|
194
|
+
const id = `${k}::${n}`;
|
|
195
|
+
// several edges on one side of one card: spread them 12px apart, deterministically by edge order
|
|
196
|
+
const spread = n === 0 ? 0 : (n % 2 ? 1 : -1) * 12 * Math.ceil(n / 2);
|
|
197
|
+
const horizontal = side === 'left' || side === 'right';
|
|
198
|
+
if (!it.sub) {
|
|
199
|
+
list.push({ id, side, x: (side === 'left' ? 0 : side === 'right' ? it.w : it.w / 2) + (horizontal ? 0 : spread),
|
|
200
|
+
y: (side === 'top' ? 0 : side === 'bottom' ? it.h : it.h / 2) + (horizontal ? spread : 0) });
|
|
201
|
+
return id;
|
|
202
|
+
}
|
|
203
|
+
const leaf = it.members.find(l => l.id === leafId);
|
|
204
|
+
// a top port under the frame's title text would have to snake around it: start right of the title
|
|
205
|
+
const titleW = 16 + textWidth(it.node.label, 13) * 1.08 + 10 + textWidth(`[${it.kind}]`, 9.5) + 24;
|
|
206
|
+
list.push({ id, side,
|
|
207
|
+
x: side === 'left' ? 0 : side === 'right' ? it.w : (side === 'top' ? Math.max(cx(leaf), titleW) : cx(leaf)) + spread,
|
|
208
|
+
y: side === 'top' ? 0 : side === 'bottom' ? it.h : cy(leaf) + spread });
|
|
209
|
+
return id;
|
|
210
|
+
};
|
|
211
|
+
const sideFor = (from, to) => {
|
|
212
|
+
const A = pos1.get(from.id), B = pos1.get(to.id);
|
|
213
|
+
if (A.y + A.height <= B.y)
|
|
214
|
+
return ['bottom', 'top'];
|
|
215
|
+
if (B.y + B.height <= A.y)
|
|
216
|
+
return ['top', 'bottom'];
|
|
217
|
+
return A.x < B.x ? ['right', 'left'] : ['left', 'right'];
|
|
218
|
+
};
|
|
219
|
+
/** The wanted side, unless a sibling blocks it — then the free side facing the partner. */
|
|
220
|
+
const freeSide = (it, leafId, want, other) => {
|
|
221
|
+
if (!it.sub)
|
|
222
|
+
return want;
|
|
223
|
+
const leaf = it.members.find(l => l.id === leafId);
|
|
224
|
+
const sib = it.members.filter(n => n.id !== leafId && !leafId.startsWith(n.id + '.') && !n.id.startsWith(leafId + '.'));
|
|
225
|
+
if (sideFree(leaf, want, sib))
|
|
226
|
+
return want;
|
|
227
|
+
const P = pos1.get(it.id), Q = pos1.get(other.id);
|
|
228
|
+
const alt = want === 'top' || want === 'bottom'
|
|
229
|
+
? (Q.x + Q.width / 2 < P.x + P.width / 2 ? ['left', 'right'] : ['right', 'left'])
|
|
230
|
+
: (Q.y + Q.height / 2 < P.y + P.height / 2 ? ['top', 'bottom'] : ['bottom', 'top']);
|
|
231
|
+
for (const s of alt)
|
|
232
|
+
if (sideFree(leaf, s, sib))
|
|
233
|
+
return s;
|
|
234
|
+
return want;
|
|
235
|
+
};
|
|
236
|
+
const edges2 = cross.map(({ e, i }) => {
|
|
237
|
+
const A = itemOf.get(e.from), B = itemOf.get(e.to);
|
|
238
|
+
const [wa, wb] = sideFor(A, B);
|
|
239
|
+
const sa = freeSide(A, e.from, wa, B), sb = freeSide(B, e.to, wb, A);
|
|
240
|
+
return { id: `e${i}`, sources: [ensurePort(A, e.from, sa)], targets: [ensurePort(B, e.to, sb)],
|
|
241
|
+
labels: e.label ? [{ text: e.label, width: textWidth(e.label, 10) + 8, height: 14 }] : [] };
|
|
242
|
+
});
|
|
243
|
+
const SIDE = { top: 'NORTH', bottom: 'SOUTH', left: 'WEST', right: 'EAST' };
|
|
244
|
+
const r2 = await elk.layout({
|
|
245
|
+
id: 'root', layoutOptions: TOP_OPTS, edges: edges2,
|
|
246
|
+
children: items.map(it => ({ ...nodeOf(it),
|
|
247
|
+
layoutOptions: { ...nodeOf(it).layoutOptions, 'elk.portConstraints': 'FIXED_POS' },
|
|
248
|
+
ports: ports.get(it.id).map(p => ({ id: p.id, width: 1, height: 1, x: p.x - 0.5, y: p.y - 0.5, layoutOptions: { 'elk.port.side': SIDE[p.side] } })) })),
|
|
249
|
+
});
|
|
250
|
+
// ---- place ----
|
|
251
|
+
const out = { nodes: [], edges: [], width: 0, height: 0 };
|
|
252
|
+
const pos = new Map(r2.children.map((c) => [c.id, c]));
|
|
253
|
+
for (const it of items) {
|
|
254
|
+
const c = pos.get(it.id);
|
|
255
|
+
it.x = c.x;
|
|
256
|
+
it.y = c.y;
|
|
257
|
+
if (!it.sub) {
|
|
258
|
+
const n = it.node;
|
|
259
|
+
out.nodes.push({ id: n.id, kind: n.kind, label: n.label, tech: n.tech, meta: n.meta, ref: n.ref, phase: n.phase, load: n.load, over: n.over, agents: n.agents, stage: n.stage,
|
|
260
|
+
transit: n.transit, external: n.external, x: c.x, y: c.y, w: it.w, h: it.h, depth: 0, isBoundary: false });
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
for (const k of it.sub.nodes)
|
|
264
|
+
out.nodes.push({ ...k, x: k.x + c.x, y: k.y + c.y });
|
|
265
|
+
for (const e of it.sub.edges)
|
|
266
|
+
out.edges.push({ ...e, points: e.points.map(p => ({ x: p.x + c.x, y: p.y + c.y })),
|
|
267
|
+
labelPos: e.labelPos ? { x: e.labelPos.x + c.x, y: e.labelPos.y + c.y } : undefined });
|
|
268
|
+
}
|
|
269
|
+
const byId = new Map(out.nodes.map(n => [n.id, n]));
|
|
270
|
+
const used = new Map();
|
|
271
|
+
/** Inner leg: from the border port to the leaf, around the frame's other members. */
|
|
272
|
+
const leg = (it, leafId, p) => {
|
|
273
|
+
const n = byId.get(leafId);
|
|
274
|
+
const frame = byId.get(it.id);
|
|
275
|
+
const obs = obstaclesFor(out.nodes.filter(k => it.members.some(m => m.id === k.id)), n, n);
|
|
276
|
+
const inFrame = (q) => q.x >= frame.x - 1 && q.x <= frame.x + frame.w + 1 && q.y >= frame.y - 1 && q.y <= frame.y + frame.h + 1;
|
|
277
|
+
const inside = (q, r) => q.x > r.x + 0.5 && q.x < r.x + r.w - 0.5 && q.y > r.y + 0.5 && q.y < r.y + r.h - 0.5;
|
|
278
|
+
const blocked = (q) => !inFrame(q) || obs.some(r => inside(q, r)) || inside(q, n);
|
|
279
|
+
let best = null;
|
|
280
|
+
for (const side of ['top', 'bottom', 'left', 'right']) {
|
|
281
|
+
const st = stub(n, side);
|
|
282
|
+
if (blocked(st.out))
|
|
283
|
+
continue;
|
|
284
|
+
const grid = makeGrid([...obs, n], [p, st.out]);
|
|
285
|
+
const path = astar(grid, p, st.out, blocked, new Map(used));
|
|
286
|
+
if (!path)
|
|
287
|
+
continue;
|
|
288
|
+
let cost = 0;
|
|
289
|
+
for (let k = 1; k < path.length; k++)
|
|
290
|
+
cost += Math.abs(path[k].x - path[k - 1].x) + Math.abs(path[k].y - path[k - 1].y) + 70;
|
|
291
|
+
if (!best || cost < best.cost)
|
|
292
|
+
best = { cost, pts: [...path, st.on], grid };
|
|
293
|
+
}
|
|
294
|
+
if (!best) {
|
|
295
|
+
const st = stub(n, p.y <= n.y ? 'top' : p.y >= n.y + n.h ? 'bottom' : p.x < n.x ? 'left' : 'right');
|
|
296
|
+
return [p, { x: st.on.x, y: p.y }, st.on];
|
|
297
|
+
}
|
|
298
|
+
markUsed(best.grid, best.pts, used);
|
|
299
|
+
return best.pts;
|
|
300
|
+
};
|
|
301
|
+
for (const e2 of r2.edges ?? []) {
|
|
302
|
+
const src = g.edges[+String(e2.id).slice(1)];
|
|
303
|
+
const sec = e2.sections?.[0];
|
|
304
|
+
if (!sec)
|
|
305
|
+
continue;
|
|
306
|
+
const pts = [sec.startPoint, ...(sec.bendPoints ?? []), sec.endPoint];
|
|
307
|
+
const A = itemOf.get(src.from), B = itemOf.get(src.to);
|
|
308
|
+
const head = A.sub ? leg(A, src.from, pts[0]).reverse() : [pts[0]];
|
|
309
|
+
const tail = B.sub ? leg(B, src.to, pts[pts.length - 1]) : [pts[pts.length - 1]];
|
|
310
|
+
const lbl = e2.labels?.[0];
|
|
311
|
+
const edge = { from: src.from, to: src.to, label: src.label, verb: src.verb, derived: src.derived, dashed: src.dashed,
|
|
312
|
+
points: simplify([...head.slice(0, -1), ...pts, ...tail.slice(1)]),
|
|
313
|
+
labelPos: lbl && lbl.x !== undefined ? { x: lbl.x, y: lbl.y } : undefined };
|
|
314
|
+
out.edges.push(edge);
|
|
315
|
+
}
|
|
316
|
+
// an edge the top pass could not route (should not happen) still gets a line
|
|
317
|
+
const routed = new Set(out.edges.map(e => `${e.from}>${e.to}>${e.verb}>${e.label ?? ''}`));
|
|
318
|
+
for (const e of g.edges) {
|
|
319
|
+
const a = byId.get(e.from), b = byId.get(e.to);
|
|
320
|
+
if (!a || !b || routed.has(`${e.from}>${e.to}>${e.verb}>${e.label ?? ''}`))
|
|
321
|
+
continue;
|
|
322
|
+
out.edges.push({ from: e.from, to: e.to, label: e.label, verb: e.verb, derived: e.derived, dashed: e.dashed, points: routeSimple(a, b) });
|
|
323
|
+
}
|
|
324
|
+
out.width = Math.max(0, ...out.nodes.map(n => n.x + n.w));
|
|
325
|
+
out.height = Math.max(0, ...out.nodes.map(n => n.y + n.h));
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type RenderGraph } from '../lens.js';
|
|
2
|
+
export interface Positioned {
|
|
3
|
+
id: string;
|
|
4
|
+
kind: string;
|
|
5
|
+
label: string;
|
|
6
|
+
tech?: string;
|
|
7
|
+
meta?: string;
|
|
8
|
+
ref?: string;
|
|
9
|
+
phase?: string;
|
|
10
|
+
load?: string;
|
|
11
|
+
over?: boolean;
|
|
12
|
+
agents?: string[];
|
|
13
|
+
stage?: string;
|
|
14
|
+
transit: boolean;
|
|
15
|
+
external: boolean;
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
w: number;
|
|
19
|
+
h: number;
|
|
20
|
+
depth: number;
|
|
21
|
+
isBoundary: boolean;
|
|
22
|
+
}
|
|
23
|
+
export interface RoutedEdge {
|
|
24
|
+
from: string;
|
|
25
|
+
to: string;
|
|
26
|
+
label?: string;
|
|
27
|
+
verb: string;
|
|
28
|
+
derived: boolean;
|
|
29
|
+
dashed: boolean;
|
|
30
|
+
points: {
|
|
31
|
+
x: number;
|
|
32
|
+
y: number;
|
|
33
|
+
}[];
|
|
34
|
+
labelPos?: {
|
|
35
|
+
x: number;
|
|
36
|
+
y: number;
|
|
37
|
+
};
|
|
38
|
+
/** Where along the route (0…1 by length) the label sits when nothing else placed it — a person dragged it there. */
|
|
39
|
+
labelAt?: number;
|
|
40
|
+
/** The label on the other side of the line (below / left) — a person put it there. */
|
|
41
|
+
labelFlip?: boolean;
|
|
42
|
+
}
|
|
43
|
+
export interface Layout {
|
|
44
|
+
nodes: Positioned[];
|
|
45
|
+
edges: RoutedEdge[];
|
|
46
|
+
width: number;
|
|
47
|
+
height: number;
|
|
48
|
+
}
|
|
49
|
+
export interface LayoutOptions {
|
|
50
|
+
/** ELK options merged over the root defaults — the layout experiments live here. */
|
|
51
|
+
root?: Record<string, string>;
|
|
52
|
+
boundary?: Record<string, string>;
|
|
53
|
+
}
|
|
54
|
+
/** The single-pass layered layout — used inside frames by the two-phase layout, and as an escape hatch. */
|
|
55
|
+
export declare function layoutLayered(g: RenderGraph, opts?: LayoutOptions): Promise<Layout>;
|
|
56
|
+
import { type BalanceOptions } from './balance.js';
|
|
57
|
+
/**
|
|
58
|
+
* The default layout: two-phase (frames on their own, then the top level with
|
|
59
|
+
* ports) — see balance.ts for why. `strategy: 'layered'` gives the old single
|
|
60
|
+
* pass.
|
|
61
|
+
*/
|
|
62
|
+
export declare function layout(g: RenderGraph, opts?: LayoutOptions & BalanceOptions & {
|
|
63
|
+
strategy?: 'two-phase' | 'layered';
|
|
64
|
+
}): Promise<Layout>;
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { footerOf, AGENT_MIN_W } from '../lens.js';
|
|
2
|
+
import { elkInstance } from './host.js';
|
|
3
|
+
import { nodeBox, textWidth } from './measure.js';
|
|
4
|
+
/**
|
|
5
|
+
* Reading order: people at the top, your systems in the middle, third parties
|
|
6
|
+
* at the bottom. This is how every hand-drawn C4 context diagram is arranged,
|
|
7
|
+
* and ELK will not discover it on its own.
|
|
8
|
+
*/
|
|
9
|
+
function partitionOf(n) {
|
|
10
|
+
if (n.kind === 'actor')
|
|
11
|
+
return 0;
|
|
12
|
+
if (n.kind === 'external')
|
|
13
|
+
return 2;
|
|
14
|
+
return 1; // transit is deliberately NOT pinned: pinning it manufactures
|
|
15
|
+
// back-edges that then have to crawl around the whole diagram
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Top to bottom, the way a C4 picture is read: people on top, the system in
|
|
19
|
+
* the middle, third parties at the bottom. A left-to-right layering of the
|
|
20
|
+
* same graphs came out 4–7 times wider than tall — unreadable at "fit" on a
|
|
21
|
+
* landscape screen; top-down lands near 0.6–0.7 and the datastore clusters
|
|
22
|
+
* already stack downwards. Spacing is deliberately tight: the six reference
|
|
23
|
+
* architectures measured 12–15 % ink at the wider values.
|
|
24
|
+
*/
|
|
25
|
+
const ROOT_OPTS = {
|
|
26
|
+
'elk.algorithm': 'layered',
|
|
27
|
+
'elk.direction': 'DOWN',
|
|
28
|
+
'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
|
|
29
|
+
'elk.edgeRouting': 'ORTHOGONAL',
|
|
30
|
+
'elk.partitioning.activate': 'true',
|
|
31
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': '56',
|
|
32
|
+
'elk.spacing.nodeNode': '28',
|
|
33
|
+
'elk.spacing.edgeNode': '18',
|
|
34
|
+
'elk.spacing.edgeEdge': '14',
|
|
35
|
+
'elk.layered.spacing.edgeNodeBetweenLayers': '22',
|
|
36
|
+
'elk.layered.nodePlacement.strategy': 'BRANDES_KOEPF',
|
|
37
|
+
'elk.layered.crossingMinimization.strategy': 'LAYER_SWEEP',
|
|
38
|
+
'elk.layered.thoroughness': '70',
|
|
39
|
+
'elk.layered.cycleBreaking.strategy': 'GREEDY',
|
|
40
|
+
'elk.layered.nodePlacement.favorStraightEdges': 'true',
|
|
41
|
+
// NOTE: elk.layered.wrapping is incompatible with partitioning — enabling it
|
|
42
|
+
// throws inside ELK. Narrowing wide diagrams needs a different lever.
|
|
43
|
+
'elk.aspectRatio': '1.7',
|
|
44
|
+
'elk.spacing.labelNode': '8',
|
|
45
|
+
'elk.spacing.edgeLabel': '6',
|
|
46
|
+
};
|
|
47
|
+
const BOUNDARY_OPTS = {
|
|
48
|
+
'elk.padding': '[top=48,left=26,bottom=26,right=26]',
|
|
49
|
+
'elk.spacing.nodeNode': '26',
|
|
50
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': '50',
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* A store belongs beside the service that owns it.
|
|
54
|
+
*
|
|
55
|
+
* In a plain left-to-right layered graph a datastore lands in the next column,
|
|
56
|
+
* far from its service and often past three unrelated boxes. Putting the owner
|
|
57
|
+
* and its stores into an invisible group keeps them adjacent and makes the
|
|
58
|
+
* `writes` edge short. (A strict "directly below" would need the group laid out
|
|
59
|
+
* separately, which ELK refuses to mix with INCLUDE_CHILDREN.)
|
|
60
|
+
*/
|
|
61
|
+
const CLUSTER_OPTS = {
|
|
62
|
+
'elk.algorithm': 'layered',
|
|
63
|
+
'elk.direction': 'DOWN',
|
|
64
|
+
'elk.padding': '[top=0,left=0,bottom=0,right=0]',
|
|
65
|
+
'elk.spacing.nodeNode': '26',
|
|
66
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': '30',
|
|
67
|
+
};
|
|
68
|
+
const STORAGE = new Set(['datastore', 'cache']);
|
|
69
|
+
/** Title strip height: a meta line that cannot sit beside the title in a 300px frame wraps under it. */
|
|
70
|
+
function headOf(n) {
|
|
71
|
+
if (!n.meta)
|
|
72
|
+
return 48;
|
|
73
|
+
const need = 16 + textWidth(n.label, 13) * 1.08 + 10 + textWidth(`[${n.kind}]`, 9.5) + 24 + textWidth(n.meta, 10) * 1.2 + 14;
|
|
74
|
+
return need > 300 ? 62 : 48;
|
|
75
|
+
}
|
|
76
|
+
/** The single-pass layered layout — used inside frames by the two-phase layout, and as an escape hatch. */
|
|
77
|
+
export async function layoutLayered(g, opts = {}) {
|
|
78
|
+
const rootOpts = { ...ROOT_OPTS, ...(opts.root ?? {}) };
|
|
79
|
+
const boundaryOpts = { ...BOUNDARY_OPTS, ...(opts.boundary ?? {}) };
|
|
80
|
+
const elk = await elkInstance();
|
|
81
|
+
// ---- index the source tree ----
|
|
82
|
+
const meta = new Map();
|
|
83
|
+
const parentOf = new Map();
|
|
84
|
+
const index = (ns, parent) => {
|
|
85
|
+
for (const n of ns) {
|
|
86
|
+
meta.set(n.id, n);
|
|
87
|
+
parentOf.set(n.id, parent);
|
|
88
|
+
index(n.children, n.id);
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
index(g.roots);
|
|
92
|
+
// ---- pull each store under the single service that owns it ----
|
|
93
|
+
const clusterOf = new Map(); // storeId -> ownerId
|
|
94
|
+
{
|
|
95
|
+
const writers = new Map();
|
|
96
|
+
for (const e of g.edges) {
|
|
97
|
+
const to = meta.get(e.to);
|
|
98
|
+
if (!to || !STORAGE.has(to.kind))
|
|
99
|
+
continue;
|
|
100
|
+
(writers.get(e.to) ?? writers.set(e.to, new Set()).get(e.to)).add(e.from);
|
|
101
|
+
}
|
|
102
|
+
for (const [id, n] of meta) {
|
|
103
|
+
if (!STORAGE.has(n.kind))
|
|
104
|
+
continue;
|
|
105
|
+
const declared = n.ownedBy;
|
|
106
|
+
const inferred = writers.get(id)?.size === 1 ? [...writers.get(id)][0] : undefined;
|
|
107
|
+
const owner = declared ?? inferred;
|
|
108
|
+
if (!owner || !meta.has(owner))
|
|
109
|
+
continue;
|
|
110
|
+
if (parentOf.get(owner) !== parentOf.get(id))
|
|
111
|
+
continue; // same container only
|
|
112
|
+
// An owner that is itself a frame (a service opened to its components)
|
|
113
|
+
// cannot host a cluster: the store would be cut out of its parent and
|
|
114
|
+
// never placed anywhere. It stays a plain neighbour instead.
|
|
115
|
+
if (meta.get(owner).children.length)
|
|
116
|
+
continue;
|
|
117
|
+
clusterOf.set(id, owner);
|
|
118
|
+
}
|
|
119
|
+
// The cluster becomes a real level of the hierarchy, so the owner→store
|
|
120
|
+
// edge lands inside it: that single edge is what makes ELK stack the store
|
|
121
|
+
// beneath its service instead of parking it in the next column.
|
|
122
|
+
const owners = new Set(clusterOf.values());
|
|
123
|
+
for (const owner of owners) {
|
|
124
|
+
const cid = `__cluster__${owner}`;
|
|
125
|
+
parentOf.set(cid, parentOf.get(owner)); // once per owner, before rebinding
|
|
126
|
+
parentOf.set(owner, cid);
|
|
127
|
+
}
|
|
128
|
+
for (const [store, owner] of clusterOf)
|
|
129
|
+
parentOf.set(store, `__cluster__${owner}`);
|
|
130
|
+
}
|
|
131
|
+
const chain = (id) => {
|
|
132
|
+
const out = [];
|
|
133
|
+
let cur = id;
|
|
134
|
+
while (cur !== undefined) {
|
|
135
|
+
out.unshift(cur);
|
|
136
|
+
cur = parentOf.get(cur);
|
|
137
|
+
}
|
|
138
|
+
return out;
|
|
139
|
+
};
|
|
140
|
+
/**
|
|
141
|
+
* Attach every edge to the lowest container that holds both endpoints.
|
|
142
|
+
* ELK reports edge coordinates relative to the edge's own container, so
|
|
143
|
+
* placing edges by hand is what makes the offsets predictable — reading
|
|
144
|
+
* them from a flat root list gives mixed coordinate frames.
|
|
145
|
+
*/
|
|
146
|
+
const lca = (a, b) => {
|
|
147
|
+
const ca = chain(a), cb = chain(b);
|
|
148
|
+
let last;
|
|
149
|
+
for (let i = 0; i < Math.min(ca.length, cb.length) - 0; i++) {
|
|
150
|
+
if (ca[i] === cb[i] && (i < ca.length - 1) && (i < cb.length - 1))
|
|
151
|
+
last = ca[i];
|
|
152
|
+
else
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
return last;
|
|
156
|
+
};
|
|
157
|
+
const edgesByContainer = new Map();
|
|
158
|
+
g.edges.forEach((e, i) => {
|
|
159
|
+
const c = lca(e.from, e.to) ?? '__root__';
|
|
160
|
+
const arr = edgesByContainer.get(c) ?? [];
|
|
161
|
+
arr.push({
|
|
162
|
+
id: `e${i}`, sources: [e.from], targets: [e.to],
|
|
163
|
+
labels: e.label ? [{ text: e.label, width: textWidth(e.label, 10) + 8, height: 14 }] : [],
|
|
164
|
+
});
|
|
165
|
+
edgesByContainer.set(c, arr);
|
|
166
|
+
});
|
|
167
|
+
const plain = (n) => {
|
|
168
|
+
const boundary = n.children.length > 0;
|
|
169
|
+
const box = nodeBox(n.label, n.tech, n.kind, n.meta, n.agents);
|
|
170
|
+
const part = partitionOf(n);
|
|
171
|
+
return {
|
|
172
|
+
id: n.id,
|
|
173
|
+
...(boundary ? {} : { width: box.w, height: box.h }),
|
|
174
|
+
layoutOptions: {
|
|
175
|
+
...(boundary ? boundaryOpts : {}),
|
|
176
|
+
// a frame with agent chips along its bottom needs the footer row below its members
|
|
177
|
+
...(boundary && (footerOf(n) || headOf(n) > 48) ? { 'elk.padding': `[top=${headOf(n)},left=26,bottom=${26 + footerOf(n)},right=26]` } : {}),
|
|
178
|
+
// the footer was sized for a frame at least this wide; hold the frame open to it
|
|
179
|
+
// (ELK transposes the graph for DOWN/UP and forgets to transpose this option — so we do)
|
|
180
|
+
...(boundary && footerOf(n) ? { 'elk.nodeSize.constraints': 'MINIMUM_SIZE', 'elk.nodeSize.minimum': /DOWN|UP/.test(rootOpts['elk.direction'] ?? '') ? `(0, ${AGENT_MIN_W})` : `(${AGENT_MIN_W}, 0)` } : {}),
|
|
181
|
+
...(part !== undefined && parentOf.get(n.id) === undefined ? { 'elk.partitioning.partition': String(part) } : {}),
|
|
182
|
+
// people share the first row and third parties the last one, whatever
|
|
183
|
+
// the edges would prefer — the picture is read top to bottom
|
|
184
|
+
...(parentOf.get(n.id) === undefined && n.kind === 'actor' ? { 'elk.layered.layering.layerConstraint': 'FIRST' } : {}),
|
|
185
|
+
...(parentOf.get(n.id) === undefined && n.kind === 'external' ? { 'elk.layered.layering.layerConstraint': 'LAST' } : {}),
|
|
186
|
+
},
|
|
187
|
+
children: n.children.filter(c => !clusterOf.has(c.id)).map(toElk),
|
|
188
|
+
edges: edgesByContainer.get(n.id) ?? [],
|
|
189
|
+
labels: boundary ? [{ text: n.label, width: textWidth(n.label, 13), height: 18 }] : [],
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
const toElk = (n) => {
|
|
193
|
+
const owned = [...clusterOf.entries()].filter(([, o]) => o === n.id).map(([s]) => s);
|
|
194
|
+
if (!owned.length || n.children.length)
|
|
195
|
+
return plain(n);
|
|
196
|
+
return {
|
|
197
|
+
id: `__cluster__${n.id}`,
|
|
198
|
+
layoutOptions: CLUSTER_OPTS,
|
|
199
|
+
children: [plain(n), ...owned.map(id => plain(meta.get(id)))],
|
|
200
|
+
edges: edgesByContainer.get(`__cluster__${n.id}`) ?? [],
|
|
201
|
+
labels: [],
|
|
202
|
+
};
|
|
203
|
+
};
|
|
204
|
+
const graph = {
|
|
205
|
+
id: 'root',
|
|
206
|
+
layoutOptions: rootOpts,
|
|
207
|
+
children: g.roots.filter(n => !clusterOf.has(n.id)).map(toElk),
|
|
208
|
+
edges: edgesByContainer.get('__root__') ?? [],
|
|
209
|
+
};
|
|
210
|
+
const res = await elk.layout(graph);
|
|
211
|
+
const nodes = [];
|
|
212
|
+
const abs = new Map();
|
|
213
|
+
const walkNodes = (children, ox, oy, depth) => {
|
|
214
|
+
for (const c of children) {
|
|
215
|
+
const x = ox + (c.x ?? 0), y = oy + (c.y ?? 0);
|
|
216
|
+
abs.set(c.id, { x, y });
|
|
217
|
+
if (String(c.id).startsWith('__cluster__')) {
|
|
218
|
+
walkNodes(c.children ?? [], x, y, depth);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const m = meta.get(c.id);
|
|
222
|
+
nodes.push({
|
|
223
|
+
id: c.id, kind: m?.kind ?? '', label: m?.label ?? c.id, tech: m?.tech, meta: m?.meta, ref: m?.ref, phase: m?.phase, load: m?.load, over: m?.over, agents: m?.agents, stage: m?.stage,
|
|
224
|
+
transit: m?.transit ?? false, external: m?.external ?? false,
|
|
225
|
+
x, y, w: c.width ?? 0, h: c.height ?? 0, depth,
|
|
226
|
+
isBoundary: (c.children?.length ?? 0) > 0,
|
|
227
|
+
});
|
|
228
|
+
if (c.children?.length)
|
|
229
|
+
walkNodes(c.children, x, y, depth + 1);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
walkNodes(res.children ?? [], 0, 0, 0);
|
|
233
|
+
const edges = [];
|
|
234
|
+
const walkEdges = (n, ox, oy) => {
|
|
235
|
+
for (const e of n.edges ?? []) {
|
|
236
|
+
const src = g.edges[Number(String(e.id).slice(1))];
|
|
237
|
+
if (!src)
|
|
238
|
+
continue;
|
|
239
|
+
const sec = e.sections?.[0];
|
|
240
|
+
const pts = sec ? [sec.startPoint, ...(sec.bendPoints ?? []), sec.endPoint] : [];
|
|
241
|
+
const lbl = e.labels?.[0];
|
|
242
|
+
edges.push({
|
|
243
|
+
from: src.from, to: src.to, label: src.label, verb: src.verb,
|
|
244
|
+
derived: src.derived, dashed: src.dashed,
|
|
245
|
+
points: pts.map((p) => ({ x: p.x + ox, y: p.y + oy })),
|
|
246
|
+
labelPos: lbl ? { x: (lbl.x ?? 0) + ox, y: (lbl.y ?? 0) + oy } : undefined,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
for (const c of n.children ?? []) {
|
|
250
|
+
const a = abs.get(c.id) ?? { x: ox + (c.x ?? 0), y: oy + (c.y ?? 0) };
|
|
251
|
+
walkEdges(c, a.x, a.y);
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
walkEdges(res, 0, 0);
|
|
255
|
+
return { nodes, edges, width: res.width ?? 0, height: res.height ?? 0 };
|
|
256
|
+
}
|
|
257
|
+
import { layoutTwoPhase } from './balance.js';
|
|
258
|
+
/**
|
|
259
|
+
* The default layout: two-phase (frames on their own, then the top level with
|
|
260
|
+
* ports) — see balance.ts for why. `strategy: 'layered'` gives the old single
|
|
261
|
+
* pass.
|
|
262
|
+
*/
|
|
263
|
+
export async function layout(g, opts = {}) {
|
|
264
|
+
if (opts.strategy === 'layered' || opts.root || opts.boundary)
|
|
265
|
+
return layoutLayered(g, opts);
|
|
266
|
+
return layoutTwoPhase(g, opts);
|
|
267
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where ELK runs. The engine does not care: it asks for an instance and
|
|
3
|
+
* awaits `layout()`. By default that is the bundled ELK on the calling thread
|
|
4
|
+
* (Node, tests, a script). A browser host that would rather keep its UI
|
|
5
|
+
* responsive hands over a factory once — `useElkWorker(url)` is the usual one
|
|
6
|
+
* — and every layout after that runs in a Web Worker.
|
|
7
|
+
*
|
|
8
|
+
* The bundled ELK is loaded on first use, not on import: a page that has
|
|
9
|
+
* configured a worker never pays for the 1.4 MB of algorithms twice.
|
|
10
|
+
*/
|
|
11
|
+
export interface ElkLike {
|
|
12
|
+
layout(graph: unknown, options?: unknown): Promise<any>;
|
|
13
|
+
}
|
|
14
|
+
/** Replace how ELK instances are made. Takes effect for the next layout. */
|
|
15
|
+
export declare function configureElk(make: (() => ElkLike | Promise<ElkLike>) | null): void;
|
|
16
|
+
/** The shared ELK instance — created on first use, reused after. */
|
|
17
|
+
export declare function elkInstance(): Promise<ElkLike>;
|
|
18
|
+
/**
|
|
19
|
+
* Run ELK in a Web Worker: `url` is where the host serves `elk-worker.min.js`
|
|
20
|
+
* from elkjs. The API shim on this side is small; the algorithms load in the
|
|
21
|
+
* worker, off the UI thread.
|
|
22
|
+
*/
|
|
23
|
+
export declare function useElkWorker(url: string): Promise<void>;
|