@yalehwang/plantuml-js 0.2.2 → 0.3.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.
Files changed (64) hide show
  1. package/README.md +20 -0
  2. package/dist/core.d.ts +102 -9
  3. package/dist/core.js +91 -51
  4. package/dist/foundation/layers.d.ts +25 -0
  5. package/dist/foundation/layers.js +101 -0
  6. package/dist/foundation/layout.d.ts +116 -0
  7. package/dist/foundation/layout.js +298 -0
  8. package/dist/{model.d.ts → foundation/model.d.ts} +20 -0
  9. package/dist/foundation/relation-semantics.d.ts +49 -0
  10. package/dist/foundation/relation-semantics.js +112 -0
  11. package/dist/{text-measure.d.ts → foundation/text-measure.d.ts} +2 -0
  12. package/dist/{text-measure.js → foundation/text-measure.js} +20 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.js +1 -1
  15. package/dist/layout/compound.d.ts +59 -0
  16. package/dist/layout/compound.js +1178 -0
  17. package/dist/layout/elk-graph.d.ts +36 -0
  18. package/dist/layout/elk-graph.js +220 -0
  19. package/dist/layout/elk-result.d.ts +18 -0
  20. package/dist/layout/elk-result.js +127 -0
  21. package/dist/layout/elk.d.ts +63 -33
  22. package/dist/layout/elk.js +215 -321
  23. package/dist/layout/fanout-trunk.d.ts +18 -0
  24. package/dist/layout/fanout-trunk.js +380 -0
  25. package/dist/layout/grid-router.d.ts +69 -0
  26. package/dist/layout/grid-router.js +615 -0
  27. package/dist/layout/lane-spread.d.ts +11 -0
  28. package/dist/layout/lane-spread.js +302 -0
  29. package/dist/layout/layout-metrics.d.ts +183 -0
  30. package/dist/layout/layout-metrics.js +766 -0
  31. package/dist/layout/package-endpoints.d.ts +10 -0
  32. package/dist/layout/package-endpoints.js +54 -0
  33. package/dist/layout/placement-strategy.d.ts +98 -0
  34. package/dist/layout/placement-strategy.js +26 -0
  35. package/dist/layout/strategy.d.ts +27 -0
  36. package/dist/layout/strategy.js +19 -0
  37. package/dist/layout/trunk-lanes.d.ts +29 -0
  38. package/dist/layout/trunk-lanes.js +308 -0
  39. package/dist/layout/viz.d.ts +3 -3
  40. package/dist/layout/viz.js +13 -17
  41. package/dist/parser.d.ts +27 -1
  42. package/dist/parser.js +111 -5
  43. package/dist/render/labels.d.ts +44 -0
  44. package/dist/render/labels.js +231 -0
  45. package/dist/render/legend.d.ts +15 -0
  46. package/dist/render/legend.js +42 -0
  47. package/dist/render/svg.d.ts +60 -3
  48. package/dist/render/svg.js +326 -98
  49. package/dist/render/theme.d.ts +109 -0
  50. package/dist/render/theme.js +99 -0
  51. package/dist/serialize.d.ts +32 -0
  52. package/dist/serialize.js +218 -0
  53. package/dist/transform.d.ts +55 -1
  54. package/dist/transform.js +277 -29
  55. package/package.json +4 -3
  56. package/dist/layout-metrics.d.ts +0 -42
  57. package/dist/layout-metrics.js +0 -241
  58. package/dist/layout.d.ts +0 -75
  59. package/dist/layout.js +0 -51
  60. /package/dist/{edge-router.d.ts → foundation/edge-router.d.ts} +0 -0
  61. /package/dist/{edge-router.js → foundation/edge-router.js} +0 -0
  62. /package/dist/{model.js → foundation/model.js} +0 -0
  63. /package/dist/{render-dot.d.ts → foundation/render-dot.d.ts} +0 -0
  64. /package/dist/{render-dot.js → foundation/render-dot.js} +0 -0
@@ -0,0 +1,1178 @@
1
+ /**
2
+ * Compound layout — lay packages out as independent blocks, place the blocks,
3
+ * then route the edges that cross block boundaries.
4
+ *
5
+ * A single hierarchical layered run (`INCLUDE_CHILDREN`) routes every edge but
6
+ * stacks all layers of every package into one tall strip: the 660-entity
7
+ * archguard `all-classes` graph came out 13455×79369. Here each top-level
8
+ * package is laid out on its own (packed to a balanced rectangle), the blocks
9
+ * are arranged as plain nodes — layered for small block counts, rectpacking for
10
+ * large ones — and [[routeOrthogonal]] draws the cross-block edges around the
11
+ * leaf boxes along the channels between blocks.
12
+ */
13
+ import { estimateEntitySize, PACKAGE_LABEL, } from '../foundation/layout.js';
14
+ import { measureTextWidth } from '../foundation/text-measure.js';
15
+ import { routeOrthogonal } from './grid-router.js';
16
+ import { mergeElkOptions } from './elk-graph.js';
17
+ import { relationSemantics } from '../foundation/relation-semantics.js';
18
+ import { runPlacementStrategies, } from './placement-strategy.js';
19
+ const PACKAGE_PREFIX = 'pkg::';
20
+ /** Block aspect ratio beyond which a flat block is repacked instead of layered. */
21
+ const BLOCK_MAX_ASPECT = 2.2;
22
+ /**
23
+ * Width beyond which a lopsided nested block is split into child blocks and
24
+ * rect-packed; narrower strips keep their (low-crossing) layered routing.
25
+ */
26
+ const NESTED_SPLIT_WIDTH = 3500;
27
+ /** Block count above which blocks are packed instead of layered. */
28
+ const PACK_BLOCK_THRESHOLD = 12;
29
+ /** Block count from which sparsely linked, layer-less roots are rect-packed too. */
30
+ const MID_PACK_BLOCK_THRESHOLD = 8;
31
+ /** Multipliers of the requested ratio tried when rect-packing blocks. */
32
+ const PACK_ASPECT_CANDIDATES = [0.6, 1, 1.5];
33
+ /** Above this many blocks the packing-candidate search is skipped. */
34
+ const MAX_CANDIDATE_BLOCKS = 30;
35
+ /** Canvas ratio band a packed candidate should stay inside to be preferred. */
36
+ const PACK_BAND_MIN = 0.7;
37
+ const PACK_BAND_MAX = 2.2;
38
+ /** Gap between blocks: wide enough for several routing lanes. */
39
+ const BLOCK_SPACING = 64;
40
+ /** Clearance ELK keeps between an edge and a node / another edge inside a block. */
41
+ const EDGE_NODE_SPACING = 12;
42
+ const EDGE_EDGE_SPACING = 10;
43
+ /** A layered block with at least this many nodes at lower density is repacked. */
44
+ const SPARSE_MIN_NODES = 30;
45
+ /** Vertical distance (px) between node centres beyond which they sit on different layers. */
46
+ const FLOW_LAYER_GAP = 24;
47
+ /** Dense graphs pack their blocks taller so the placed blocks do not stretch the canvas. */
48
+ const DENSE_SHELF_ASPECT_SCALE = 0.6;
49
+ /** Block rows aim for the canvas ratio itself: wider rows stretch the canvas past what the layers above can balance. */
50
+ const SHELF_ASPECT_SCALE = 1.25;
51
+ /**
52
+ * A repacked block wider than this is packed again at a lower ratio (see
53
+ * [[SHELF_NARROW_SCALES]]): one wide block sets the canvas width of the whole diagram.
54
+ */
55
+ const SHELF_MAX_WIDTH = 1600;
56
+ const SHELF_NARROW_SCALES = [1.0, 0.8];
57
+ /** Node gap of a repacked block: wide enough channels for several edge lanes. */
58
+ const SHELF_GAP = 72;
59
+ /** Unconnected boxes from this many up are laid out as an equal-column grid. */
60
+ const GRID_MIN_NODES = 6;
61
+ /** Canvas ratio an equal-column grid aims for, and the gap between its cells. */
62
+ const GRID_ASPECT = 1.3;
63
+ const GRID_GAP = 24;
64
+ /** Dense (very large) graphs keep the uniform grid: the looser blocks pack into a smaller canvas. */
65
+ const DENSE_GRID_GAP = 40;
66
+ const DENSE_GRID_ROW_GAP = 50;
67
+ /** Row gap, as tight as the column gap: the grid router finds its lanes between rows either way. */
68
+ const GRID_ROW_GAP = 24;
69
+ const SHELF_PADDING = { top: 26, side: 10, bottom: 10 };
70
+ const SPARSE_DENSITY = 0.16;
71
+ /** Hub rail: gap between hubs / columns, and the shortest column height before wrapping. */
72
+ const HUB_RAIL_GAP = 16;
73
+ const HUB_RAIL_MIN_HEIGHT = 400;
74
+ /** Border kept free around the whole canvas for outer routing lanes. */
75
+ const CANVAS_MARGIN = 40;
76
+ /** Node gap inside packed blocks of a dense (very large) graph; leaves one routing lane. */
77
+ const DENSE_NODE_SPACING = 36;
78
+ /** Tightest gap between neighbouring stubs on the rail, px. */
79
+ const RAIL_MIN_GAP = 24;
80
+ /** Lattice cost of crossing a wire in a view with stub rails (their fans are crossing-prone), and elsewhere. */
81
+ const STUB_CROSSING_COST = 40;
82
+ const CROSSING_COST = 12;
83
+ /** Cost per px a rail runs past the frame side it follows (in px of target distance). */
84
+ const RAIL_OVERFLOW_WEIGHT = 0.5;
85
+ export const DEFAULT_STUB_ORBIT = { gap: 48, spacing: 120, anchor: 'centroid' };
86
+ function packageMinWidth(name) {
87
+ return measureTextWidth(name, PACKAGE_LABEL.fontSize) + PACKAGE_LABEL.tabPadding + 20;
88
+ }
89
+ export async function compoundLayout(elk, model, options) {
90
+ const pkgById = new Map(model.packages.map((p) => [p.id, p]));
91
+ const topOfPackage = new Map();
92
+ const topPackageOf = (id) => {
93
+ const cached = topOfPackage.get(id);
94
+ if (cached !== undefined)
95
+ return cached;
96
+ let cur = pkgById.get(id);
97
+ const seen = new Set([cur.id]);
98
+ while (cur.parentId !== undefined && pkgById.has(cur.parentId) && !seen.has(cur.parentId)) {
99
+ cur = pkgById.get(cur.parentId);
100
+ seen.add(cur.id);
101
+ }
102
+ topOfPackage.set(id, cur.id);
103
+ return cur.id;
104
+ };
105
+ /** Block key of an entity: its top-level package, or itself when it has none. */
106
+ const blockOfEntity = new Map();
107
+ for (const entity of model.entities) {
108
+ const pkgId = entity.packageId !== undefined && pkgById.has(entity.packageId) ? entity.packageId : undefined;
109
+ blockOfEntity.set(entity.id, pkgId !== undefined ? PACKAGE_PREFIX + topPackageOf(pkgId) : entity.id);
110
+ }
111
+ // Group model parts by block.
112
+ const entitiesByBlock = new Map();
113
+ for (const entity of model.entities) {
114
+ const key = blockOfEntity.get(entity.id);
115
+ const list = entitiesByBlock.get(key);
116
+ if (list)
117
+ list.push(entity);
118
+ else
119
+ entitiesByBlock.set(key, [entity]);
120
+ }
121
+ const packagesByBlock = new Map();
122
+ for (const pkg of model.packages) {
123
+ const key = PACKAGE_PREFIX + topPackageOf(pkg.id);
124
+ const list = packagesByBlock.get(key);
125
+ if (list)
126
+ list.push(pkg);
127
+ else
128
+ packagesByBlock.set(key, [pkg]);
129
+ }
130
+ const blockKeys = [];
131
+ for (const pkg of model.packages) {
132
+ if (topPackageOf(pkg.id) === pkg.id)
133
+ blockKeys.push(PACKAGE_PREFIX + pkg.id);
134
+ }
135
+ for (const entity of model.entities) {
136
+ const key = blockOfEntity.get(entity.id);
137
+ if (!key.startsWith(PACKAGE_PREFIX) || !packagesByBlock.has(key)) {
138
+ if (!blockKeys.includes(key))
139
+ blockKeys.push(key);
140
+ }
141
+ }
142
+ const relationsByBlock = new Map();
143
+ const crossRelations = [];
144
+ model.relations.forEach((relation, index) => {
145
+ const sb = blockOfEntity.get(relation.source);
146
+ const tb = blockOfEntity.get(relation.target);
147
+ if (sb === undefined || tb === undefined)
148
+ return;
149
+ if (sb === tb) {
150
+ const list = relationsByBlock.get(sb);
151
+ if (list)
152
+ list.push(index);
153
+ else
154
+ relationsByBlock.set(sb, [index]);
155
+ }
156
+ else {
157
+ crossRelations.push(index);
158
+ }
159
+ });
160
+ // 1. Lay each block out on its own.
161
+ const blocks = new Map();
162
+ for (const key of blockKeys) {
163
+ const entities = entitiesByBlock.get(key) ?? [];
164
+ const packages = packagesByBlock.get(key) ?? [];
165
+ blocks.set(key, await layoutBlock(elk, model, key, entities, packages, relationsByBlock.get(key) ?? [], options));
166
+ }
167
+ // 2. Place the blocks.
168
+ const placed = await placeBlocks(elk, model, blocks, crossRelations, blockOfEntity, options);
169
+ // 3. Absolute geometry.
170
+ const nodes = [];
171
+ const packagesOut = [];
172
+ const edgePoints = new Map();
173
+ for (const key of blockKeys) {
174
+ const block = blocks.get(key);
175
+ const at = placed.positions.get(key) ?? { x: CANVAS_MARGIN, y: CANVAS_MARGIN };
176
+ for (const n of block.nodes)
177
+ nodes.push({ ...n, x: n.x + at.x, y: n.y + at.y });
178
+ for (const p of block.packages)
179
+ packagesOut.push({ ...p, x: p.x + at.x, y: p.y + at.y });
180
+ for (const index of block.unrouted ?? [])
181
+ crossRelations.push(index);
182
+ for (const [index, points] of block.edges) {
183
+ edgePoints.set(index, points.map((p) => ({ x: p.x + at.x, y: p.y + at.y })));
184
+ }
185
+ }
186
+ // 4. Route the edges that cross blocks.
187
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
188
+ const requests = [];
189
+ for (const index of crossRelations) {
190
+ const relation = model.relations[index];
191
+ const source = nodeById.get(relation.source);
192
+ const target = nodeById.get(relation.target);
193
+ if (source && target)
194
+ requests.push({ id: String(index), source, target });
195
+ }
196
+ const drawn = [...edgePoints].map(([index, points]) => ({
197
+ source: model.relations[index].source,
198
+ target: model.relations[index].target,
199
+ points,
200
+ }));
201
+ const routed = routeOrthogonal(requests, nodes, {
202
+ width: placed.width,
203
+ height: placed.height,
204
+ frames: packagesOut,
205
+ existing: drawn,
206
+ ripupPasses: options.ripupPasses,
207
+ crossingCost: model.entities.some((e) => e.stub) ? STUB_CROSSING_COST : CROSSING_COST,
208
+ });
209
+ for (const [id, points] of routed)
210
+ edgePoints.set(Number(id), points);
211
+ const edges = model.relations.map((relation, index) => ({
212
+ id: `rel-${index}`,
213
+ source: relation.source,
214
+ target: relation.target,
215
+ points: edgePoints.get(index) ?? [],
216
+ }));
217
+ return { nodes, edges, packages: packagesOut, width: placed.width, height: placed.height };
218
+ }
219
+ /** ELK-layout one block (a top-level package subtree, or a single bare entity). */
220
+ async function layoutBlock(elk, model, key, entities, packages, relationIndexes, options) {
221
+ if (!key.startsWith(PACKAGE_PREFIX) || packages.length === 0) {
222
+ const entity = entities[0];
223
+ const size = estimateEntitySize(entity);
224
+ return {
225
+ key,
226
+ width: size.width,
227
+ height: size.height,
228
+ nodes: [{ id: entity.id, x: 0, y: 0, width: size.width, height: size.height }],
229
+ packages: [],
230
+ edges: new Map(),
231
+ };
232
+ }
233
+ const topId = key.slice(PACKAGE_PREFIX.length);
234
+ const pkgNodes = new Map();
235
+ for (const pkg of packages) {
236
+ if (pkg.id === topId)
237
+ continue;
238
+ pkgNodes.set(pkg.id, {
239
+ id: PACKAGE_PREFIX + pkg.id,
240
+ children: [],
241
+ layoutOptions: mergeElkOptions({
242
+ 'elk.padding': '[top=26,left=10,bottom=10,right=10]',
243
+ 'elk.nodeSize.constraints': '[MINIMUM_SIZE]',
244
+ 'elk.nodeSize.minimum': `[${packageMinWidth(pkg.name).toFixed(1)}, ${PACKAGE_LABEL.tabHeight + 36}]`,
245
+ }, {}, options.elk),
246
+ });
247
+ }
248
+ const rootChildren = [];
249
+ const touched = new Set();
250
+ for (const r of model.relations) {
251
+ touched.add(r.source);
252
+ touched.add(r.target);
253
+ }
254
+ // Summarised hubs without any drawn edge are not part of the layered graph:
255
+ // they get a rail along the package side instead of a row on top of it.
256
+ const hubs = [];
257
+ for (const entity of entities) {
258
+ const size = estimateEntitySize(entity);
259
+ if (entity.packageId === topId && (entity.usageCount ?? 0) > 0 && !touched.has(entity.id)) {
260
+ hubs.push(entity);
261
+ continue;
262
+ }
263
+ const leaf = { id: entity.id, width: size.width, height: size.height };
264
+ const owner = entity.packageId !== undefined && entity.packageId !== topId ? pkgNodes.get(entity.packageId) : undefined;
265
+ if (owner)
266
+ owner.children.push(leaf);
267
+ else
268
+ rootChildren.push(leaf);
269
+ }
270
+ for (const pkg of packages) {
271
+ if (pkg.id === topId)
272
+ continue;
273
+ const node = pkgNodes.get(pkg.id);
274
+ const parent = pkg.parentId !== undefined && pkg.parentId !== topId ? pkgNodes.get(pkg.parentId) : undefined;
275
+ if (parent)
276
+ parent.children.push(node);
277
+ else
278
+ rootChildren.push(node);
279
+ }
280
+ const edges = relationIndexes.map((index) => {
281
+ const relation = model.relations[index];
282
+ const reversed = relationSemantics(relation.type).isReversed();
283
+ return {
284
+ id: `rel-${index}`,
285
+ sources: [reversed ? relation.target : relation.source],
286
+ targets: [reversed ? relation.source : relation.target],
287
+ };
288
+ });
289
+ const pkg = packages.find((p) => p.id === topId);
290
+ const hasNested = packages.length > 1;
291
+ const defaults = {
292
+ 'elk.algorithm': 'layered',
293
+ 'elk.direction': 'DOWN',
294
+ 'elk.edgeRouting': options.edgeRouting ?? 'ORTHOGONAL',
295
+ 'elk.spacing.nodeNode': '40',
296
+ 'elk.layered.spacing.nodeNodeBetweenLayers': '60',
297
+ 'elk.layered.portConstraints': 'FIXED_SIDE',
298
+ 'elk.aspectRatio': '1.6',
299
+ 'elk.separateConnectedComponents': 'true',
300
+ 'elk.layered.nodePlacement.strategy': 'INTERACTIVE',
301
+ 'elk.spacing.edgeNode': String(EDGE_NODE_SPACING),
302
+ 'elk.spacing.edgeEdge': String(EDGE_EDGE_SPACING),
303
+ 'elk.padding': '[top=26,left=10,bottom=10,right=10]',
304
+ };
305
+ const named = {};
306
+ if (options.edgeRouting !== undefined)
307
+ named['elk.edgeRouting'] = options.edgeRouting;
308
+ if (options.aspectRatio !== undefined)
309
+ named['elk.aspectRatio'] = String(options.aspectRatio);
310
+ if (hasNested)
311
+ defaults['elk.hierarchyHandling'] = 'INCLUDE_CHILDREN';
312
+ const layoutOptions = mergeElkOptions(defaults, named, options.elk);
313
+ const graph = { id: 'block', layoutOptions, children: rootChildren, edges };
314
+ let result = await elk.layout(graph);
315
+ // Dependency order of the plain layered run (layer by layer, left to right):
316
+ // a repack keeps it so dependents stay above what they consume.
317
+ const flowOrder = flowOrderOf(result.children ?? []);
318
+ let repacked = false;
319
+ let aspect = (result.width ?? 0) / Math.max(1, result.height ?? 1);
320
+ const wide = (result.width ?? 0) > NESTED_SPLIT_WIDTH;
321
+ if (hasNested && wide && (aspect > BLOCK_MAX_ASPECT || aspect < 1 / BLOCK_MAX_ASPECT)) {
322
+ return splitNestedBlock(elk, model, key, entities, packages, relationIndexes, options);
323
+ }
324
+ let sparse = !hasNested && rootChildren.length >= SPARSE_MIN_NODES && blockDensity(rootChildren, result) < SPARSE_DENSITY;
325
+ if (sparse) {
326
+ // Fold the long layer chain into several rows before giving up on layering.
327
+ const wrapped = await elk.layout({
328
+ ...graph,
329
+ layoutOptions: { ...layoutOptions, 'elk.layered.wrapping.strategy': 'MULTI_EDGE' },
330
+ });
331
+ if (blockDensity(rootChildren, wrapped) > blockDensity(rootChildren, result)) {
332
+ result = wrapped;
333
+ sparse = blockDensity(rootChildren, result) < SPARSE_DENSITY;
334
+ }
335
+ }
336
+ const gridable = !hasNested && relationIndexes.length === 0 && rootChildren.length >= GRID_MIN_NODES;
337
+ const flowRank = new Map(flowOrder.map((id, index) => [id, index]));
338
+ const ordered = rootChildren
339
+ .slice()
340
+ .sort((a, b) => (flowRank.get(a.id) ?? 0) - (flowRank.get(b.id) ?? 0));
341
+ if (gridable) {
342
+ // Unconnected boxes have no flow to follow: an equal-column grid reads
343
+ // better than ragged shelf rows.
344
+ const placement = await runPlacementStrategies([gridPackPlacementStrategy], {
345
+ boxes: ordered,
346
+ order: flowOrder,
347
+ aspect: GRID_ASPECT,
348
+ gap: options.dense ? DENSE_GRID_GAP : GRID_GAP,
349
+ rowGap: options.dense ? DENSE_GRID_ROW_GAP : GRID_ROW_GAP,
350
+ tight: !options.dense,
351
+ });
352
+ result = elkNodeFromPlacement('block', requirePlacement(placement, 'grid packing'), ordered);
353
+ repacked = true;
354
+ }
355
+ else if (!hasNested && (options.dense || sparse || aspect > BLOCK_MAX_ASPECT || aspect < 1 / BLOCK_MAX_ASPECT)) {
356
+ // A single wide layer cannot be wrapped by the layered algorithm; pack the
357
+ // boxes into a balanced rectangle, rows in dependency order, and let the
358
+ // grid router draw the edges.
359
+ result =
360
+ options.dense && options.rectPackDense
361
+ ? await elk.layout({
362
+ id: 'block',
363
+ layoutOptions: mergeElkOptions({
364
+ 'elk.algorithm': 'rectpacking',
365
+ 'elk.aspectRatio': '1.6',
366
+ 'elk.spacing.nodeNode': String(DENSE_NODE_SPACING),
367
+ 'elk.padding': '[top=26,left=10,bottom=10,right=10]',
368
+ }, options.aspectRatio !== undefined ? { 'elk.aspectRatio': String(options.aspectRatio) } : {}, options.elk),
369
+ children: rootChildren,
370
+ })
371
+ : elkNodeFromPlacement('block', await packShelves(ordered, flowOrder, options, (h) => (hubs.length > 0 ? placeHubRail(hubs, h).width : 0)), ordered);
372
+ repacked = true;
373
+ }
374
+ const nodes = [];
375
+ const packagesOut = [];
376
+ const offsets = new Map([['block', { x: 0, y: 0 }]]);
377
+ const flatten = (node, ox, oy) => {
378
+ const x = (node.x ?? 0) + ox;
379
+ const y = (node.y ?? 0) + oy;
380
+ offsets.set(node.id, { x, y });
381
+ if (node.id.startsWith(PACKAGE_PREFIX)) {
382
+ packagesOut.push({ id: node.id.slice(PACKAGE_PREFIX.length), x, y, width: node.width ?? 0, height: node.height ?? 0 });
383
+ for (const child of node.children ?? [])
384
+ flatten(child, x, y);
385
+ }
386
+ else {
387
+ nodes.push({ id: node.id, x, y, width: node.width ?? 0, height: node.height ?? 0 });
388
+ }
389
+ };
390
+ for (const child of result.children ?? [])
391
+ flatten(child, 0, 0);
392
+ let width = Math.max(result.width ?? 0, packageMinWidth(pkg.name));
393
+ let height = Math.max(result.height ?? 0, PACKAGE_LABEL.tabHeight + 36);
394
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
395
+ const edgeMap = new Map();
396
+ if (!repacked) {
397
+ for (const edge of result.edges ?? []) {
398
+ const index = Number((edge.id ?? '').slice('rel-'.length));
399
+ const relation = model.relations[index];
400
+ const reversed = relationSemantics(relation.type).isReversed();
401
+ const points = elkEdgePoints(edge, nodeById, offsets);
402
+ edgeMap.set(index, reversed ? points.slice().reverse() : points);
403
+ }
404
+ }
405
+ if (hubs.length > 0) {
406
+ const rail = placeHubRail(hubs, height);
407
+ for (const n of nodes) {
408
+ n.x += rail.width;
409
+ }
410
+ for (const p of packagesOut) {
411
+ p.x += rail.width;
412
+ }
413
+ for (const points of edgeMap.values())
414
+ for (const pt of points)
415
+ pt.x += rail.width;
416
+ nodes.push(...rail.nodes);
417
+ width += rail.width;
418
+ height = Math.max(height, rail.height);
419
+ }
420
+ packagesOut.push({ id: topId, x: 0, y: 0, width, height });
421
+ return repacked
422
+ ? { key, width, height, nodes, packages: packagesOut, edges: edgeMap, unrouted: relationIndexes }
423
+ : { key, width, height, nodes, packages: packagesOut, edges: edgeMap };
424
+ }
425
+ /** Ids of `children` in layer order (top layer first), left to right within a layer. */
426
+ function flowOrderOf(children) {
427
+ const sorted = children
428
+ .map((c) => ({ id: c.id, cx: (c.x ?? 0) + (c.width ?? 0) / 2, cy: (c.y ?? 0) + (c.height ?? 0) / 2 }))
429
+ .sort((a, b) => a.cy - b.cy || a.cx - b.cx);
430
+ const out = [];
431
+ let layer = [];
432
+ let layerY = -Infinity;
433
+ const flush = () => {
434
+ layer.sort((a, b) => a.cx - b.cx);
435
+ for (const n of layer)
436
+ out.push(n.id);
437
+ layer = [];
438
+ };
439
+ for (const n of sorted) {
440
+ if (n.cy - layerY > FLOW_LAYER_GAP) {
441
+ flush();
442
+ layerY = n.cy;
443
+ }
444
+ layer.push(n);
445
+ }
446
+ flush();
447
+ return out;
448
+ }
449
+ /**
450
+ * Shelf-pack `children` row by row in `order`, choosing the row width whose
451
+ * result is closest to `aspect`. Unlike rect packing the row order follows the
452
+ * dependency order, so an upstream node never lands below its dependency.
453
+ */
454
+ function orderedShelfPack(children, order, aspect, gap) {
455
+ const rank = new Map(order.map((id, i) => [id, i]));
456
+ const items = children.slice().sort((a, b) => (rank.get(a.id) ?? 0) - (rank.get(b.id) ?? 0));
457
+ const { top, side, bottom } = SHELF_PADDING;
458
+ const widest = Math.max(0, ...items.map((c) => c.width ?? 0));
459
+ const total = items.reduce((sum, c) => sum + (c.width ?? 0) + gap, 0);
460
+ let best;
461
+ let bestMiss = Infinity;
462
+ for (let inner = widest; inner <= total; inner += Math.max(8, (total - widest) / 40)) {
463
+ const rows = [[]];
464
+ const rowH = [0];
465
+ let used = 0;
466
+ let width = 0;
467
+ for (const c of items) {
468
+ const w = c.width ?? 0;
469
+ if (used > 0 && used + gap + w > inner) {
470
+ rows.push([]);
471
+ rowH.push(0);
472
+ used = 0;
473
+ }
474
+ rows[rows.length - 1].push(c);
475
+ rowH[rowH.length - 1] = Math.max(rowH[rowH.length - 1], c.height ?? 0);
476
+ used += (used > 0 ? gap : 0) + w;
477
+ width = Math.max(width, used);
478
+ }
479
+ const w = width + 2 * side;
480
+ const h = top + bottom + rowH.reduce((a, b) => a + b, 0) + gap * (rows.length - 1);
481
+ const miss = Math.abs(Math.log(w / Math.max(1, h) / aspect)) + (w * h) / 1e9;
482
+ if (miss < bestMiss) {
483
+ bestMiss = miss;
484
+ best = { rows, rowH, width: w, height: h };
485
+ }
486
+ }
487
+ if (!best)
488
+ return { id: 'block', width: 2 * side, height: top + bottom, children: [] };
489
+ let y = top;
490
+ best.rows.forEach((row, r) => {
491
+ let x = side;
492
+ for (const c of row) {
493
+ c.x = x;
494
+ c.y = y + (best.rowH[r] - (c.height ?? 0)) / 2;
495
+ x += (c.width ?? 0) + gap;
496
+ }
497
+ y += best.rowH[r] + gap;
498
+ });
499
+ return { id: 'block', width: best.width, height: best.height, children: items };
500
+ }
501
+ /**
502
+ * Equal-column grid for unconnected boxes: the column width is the widest box,
503
+ * rows are as tall as their tallest box and boxes are top-left aligned, so
504
+ * columns and rows line up. The column count is the one whose block lands
505
+ * closest to `aspect`; the last row alone may be short. `tight` sizes each column
506
+ * to its own widest box and hands the narrowest boxes to the first columns; without
507
+ * it every column is as wide as the widest box overall and the flow order is kept.
508
+ */
509
+ export function gridPack(children, order, aspect, gap, rowGap = gap, tight = true) {
510
+ const rank = new Map(order.map((id, i) => [id, i]));
511
+ const byFlow = children.slice().sort((a, b) => (rank.get(a.id) ?? 0) - (rank.get(b.id) ?? 0));
512
+ // Narrowest first: filling the columns top-down in that order keeps similar
513
+ // widths together, so each column is only as wide as its own widest box.
514
+ const byWidth = tight ? byFlow.slice().sort((a, b) => (a.width ?? 0) - (b.width ?? 0)) : byFlow;
515
+ const widest = Math.max(0, ...byFlow.map((c) => c.width ?? 0));
516
+ const { top, side, bottom } = SHELF_PADDING;
517
+ const arrange = (cols) => {
518
+ // Slots fill row by row (only the last row may be short) but are handed
519
+ // out column by column.
520
+ const slots = byWidth.map((_, i) => ({ r: Math.floor(i / cols), c: i % cols }));
521
+ slots.sort((p, q) => p.c - q.c || p.r - q.r);
522
+ const cells = byWidth.map((item, i) => ({ item, ...slots[i] }));
523
+ const colW = Array.from({ length: cols }, () => 0);
524
+ const rowH = [];
525
+ for (const { item, r, c } of cells) {
526
+ colW[c] = tight ? Math.max(colW[c], item.width ?? 0) : widest;
527
+ rowH[r] = Math.max(rowH[r] ?? 0, item.height ?? 0);
528
+ }
529
+ return {
530
+ cells,
531
+ colW,
532
+ rowH,
533
+ width: 2 * side + colW.reduce((a, b) => a + b, 0) + (cols - 1) * gap,
534
+ height: top + bottom + rowH.reduce((a, b) => a + b, 0) + rowGap * (rowH.length - 1),
535
+ };
536
+ };
537
+ let best = { cols: 1, miss: Infinity };
538
+ for (let cols = 1; cols <= byWidth.length; cols++) {
539
+ const g = arrange(cols);
540
+ const miss = Math.abs(Math.log(g.width / Math.max(1, g.height) / aspect));
541
+ if (miss < best.miss - 1e-9)
542
+ best = { cols, miss };
543
+ }
544
+ const g = arrange(best.cols);
545
+ const colX = [];
546
+ let x = side;
547
+ for (const w of g.colW) {
548
+ colX.push(x);
549
+ x += w + gap;
550
+ }
551
+ const rowY = [];
552
+ let y = top;
553
+ for (const h of g.rowH) {
554
+ rowY.push(y);
555
+ y += h + rowGap;
556
+ }
557
+ for (const { item, r, c } of g.cells) {
558
+ item.x = colX[c];
559
+ item.y = rowY[r];
560
+ }
561
+ return { id: 'block', width: g.width, height: g.height, children: byFlow };
562
+ }
563
+ /** Box placement strategies: the equal-column grid and the ordered shelf pack. */
564
+ export const gridPackPlacementStrategy = {
565
+ name: 'grid-pack',
566
+ place(ctx) {
567
+ const packed = gridPack([...ctx.boxes], [...ctx.order], ctx.aspect, ctx.gap, ctx.rowGap ?? ctx.gap, ctx.tight ?? true);
568
+ return placementOfElkNode(packed);
569
+ },
570
+ };
571
+ export const orderedShelfPackPlacementStrategy = {
572
+ name: 'ordered-shelf-pack',
573
+ place(ctx) {
574
+ const packed = orderedShelfPack([...ctx.boxes], [...ctx.order], ctx.aspect, ctx.gap);
575
+ return placementOfElkNode(packed);
576
+ },
577
+ };
578
+ /** Positions of an arranged ELK node's children, plus the node's own size. */
579
+ function placementOfElkNode(node) {
580
+ const positions = new Map();
581
+ for (const child of node.children ?? []) {
582
+ positions.set(child.id, { x: child.x ?? 0, y: child.y ?? 0 });
583
+ }
584
+ return { positions, width: node.width ?? 0, height: node.height ?? 0 };
585
+ }
586
+ /** Materialise a placement as the ELK node result the block layout flattens. */
587
+ function elkNodeFromPlacement(id, placement, children) {
588
+ return {
589
+ id,
590
+ width: placement.width,
591
+ height: placement.height,
592
+ children: children.map((c) => {
593
+ const at = placement.positions.get(c.id);
594
+ return at === undefined ? { ...c } : { ...c, x: at.x, y: at.y };
595
+ }),
596
+ };
597
+ }
598
+ function requirePlacement(placement, what) {
599
+ if (placement === undefined)
600
+ throw new Error(`compound: ${what} strategy declined`);
601
+ return placement;
602
+ }
603
+ /** Ordered shelf packing of a flat block; a block that comes out too wide is repacked taller. */
604
+ async function packShelves(children, order, options, railWidth) {
605
+ const aspect = options.aspectRatio ?? 1.6;
606
+ const gap = options.dense ? DENSE_NODE_SPACING : SHELF_GAP;
607
+ const pack = async (ratio) => requirePlacement(await runPlacementStrategies([orderedShelfPackPlacementStrategy], { boxes: children, order, aspect: ratio, gap }), 'ordered shelf packing');
608
+ if (options.dense)
609
+ return pack(aspect * DENSE_SHELF_ASPECT_SCALE);
610
+ let packed = await pack(aspect * SHELF_ASPECT_SCALE);
611
+ for (const scale of SHELF_NARROW_SCALES) {
612
+ if (packed.width + railWidth(packed.height) <= SHELF_MAX_WIDTH)
613
+ break;
614
+ packed = await pack(aspect * scale);
615
+ }
616
+ return packed;
617
+ }
618
+ /** Node area ÷ block area of a layered result; low means a sparse, mostly empty block. */
619
+ function blockDensity(children, result) {
620
+ const area = (result.width ?? 0) * (result.height ?? 0);
621
+ if (area <= 0)
622
+ return 1;
623
+ return children.reduce((sum, c) => sum + (c.width ?? 0) * (c.height ?? 0), 0) / area;
624
+ }
625
+ /**
626
+ * Stack summarised hubs in column(s) on the package's left side, below the label
627
+ * tab. Columns wrap at the block height (at least [[HUB_RAIL_MIN_HEIGHT]]).
628
+ * `width` is the space the rail takes from the left edge, gap included.
629
+ */
630
+ function placeHubRail(hubs, blockHeight) {
631
+ const top = 26;
632
+ const limit = Math.max(blockHeight - 10, HUB_RAIL_MIN_HEIGHT);
633
+ const nodes = [];
634
+ let x = 10;
635
+ let y = top;
636
+ let colWidth = 0;
637
+ let bottom = top;
638
+ for (const entity of hubs) {
639
+ const size = estimateEntitySize(entity);
640
+ if (y > top && y + size.height > limit) {
641
+ x += colWidth + HUB_RAIL_GAP;
642
+ y = top;
643
+ colWidth = 0;
644
+ }
645
+ nodes.push({ id: entity.id, x, y, width: size.width, height: size.height });
646
+ colWidth = Math.max(colWidth, size.width);
647
+ y += size.height + HUB_RAIL_GAP;
648
+ bottom = Math.max(bottom, y - HUB_RAIL_GAP);
649
+ }
650
+ return { nodes, width: x + colWidth + HUB_RAIL_GAP * 2 - 10, height: bottom + 10 };
651
+ }
652
+ /**
653
+ * Pack a lopsided nested block proportionally: every child package becomes a
654
+ * block of its own (recursively split when it is lopsided too), and the child
655
+ * blocks plus the package's own entities are rect-packed into a balanced
656
+ * rectangle. Edges inside a child keep their ELK routes; edges between
657
+ * children are left for the grid router.
658
+ */
659
+ async function splitNestedBlock(elk, model, key, entities, packages, relationIndexes, options) {
660
+ const topId = key.slice(PACKAGE_PREFIX.length);
661
+ const top = packages.find((p) => p.id === topId);
662
+ const pkgById = new Map(packages.map((p) => [p.id, p]));
663
+ const childOf = new Map(); // package id → direct child of top
664
+ for (const pkg of packages) {
665
+ if (pkg.id === topId)
666
+ continue;
667
+ let cur = pkg;
668
+ while (cur.parentId !== undefined && cur.parentId !== topId && pkgById.has(cur.parentId)) {
669
+ cur = pkgById.get(cur.parentId);
670
+ }
671
+ childOf.set(pkg.id, cur.id);
672
+ }
673
+ const children = packages.filter((p) => p.parentId === topId);
674
+ const entityChild = (e) => e.packageId !== undefined ? childOf.get(e.packageId) : undefined;
675
+ const entityIds = new Map(entities.map((e) => [e.id, e]));
676
+ const subBlocks = [];
677
+ const claimed = new Set();
678
+ for (const child of children) {
679
+ const subKey = PACKAGE_PREFIX + child.id;
680
+ const subPackages = packages.filter((p) => p.id === child.id || childOf.get(p.id) === child.id);
681
+ const subEntities = entities.filter((e) => entityChild(e) === child.id);
682
+ const subRelations = relationIndexes.filter((i) => {
683
+ const r = model.relations[i];
684
+ const a = entityIds.get(r.source);
685
+ const b = entityIds.get(r.target);
686
+ return a !== undefined && b !== undefined && entityChild(a) === child.id && entityChild(b) === child.id;
687
+ });
688
+ for (const i of subRelations)
689
+ claimed.add(i);
690
+ subBlocks.push(await layoutBlock(elk, model, subKey, subEntities, subPackages, subRelations, { ...options, dense: false }));
691
+ }
692
+ const unrouted = relationIndexes.filter((i) => !claimed.has(i));
693
+ const own = entities.filter((e) => entityChild(e) === undefined);
694
+ const elkChildren = [
695
+ ...subBlocks.map((b) => ({ id: b.key, width: b.width, height: b.height })),
696
+ ...own.map((e) => {
697
+ const size = estimateEntitySize(e);
698
+ return { id: e.id, width: size.width, height: size.height };
699
+ }),
700
+ ];
701
+ // Layered placement over the child boxes keeps linked packages adjacent (few
702
+ // crossings between them); wrapping bounds the aspect ratio.
703
+ const itemOf = (id) => {
704
+ const e = entityIds.get(id);
705
+ const c = e === undefined ? undefined : entityChild(e);
706
+ return c === undefined ? id : PACKAGE_PREFIX + c;
707
+ };
708
+ const seen = new Set();
709
+ const linkEdges = [];
710
+ for (const i of unrouted) {
711
+ const a = itemOf(model.relations[i].source);
712
+ const b = itemOf(model.relations[i].target);
713
+ if (a === b || seen.has(`${a}\u0000${b}`))
714
+ continue;
715
+ seen.add(`${a}\u0000${b}`);
716
+ linkEdges.push({ id: `lnk-${linkEdges.length}`, sources: [a], targets: [b] });
717
+ }
718
+ const packed = await elk.layout({
719
+ id: 'block',
720
+ layoutOptions: mergeElkOptions({
721
+ 'elk.algorithm': 'layered',
722
+ 'elk.direction': 'DOWN',
723
+ 'elk.aspectRatio': '1.6',
724
+ 'elk.spacing.nodeNode': String(DENSE_NODE_SPACING),
725
+ 'elk.layered.spacing.nodeNodeBetweenLayers': String(BLOCK_SPACING),
726
+ 'elk.layered.wrapping.strategy': 'MULTI_EDGE',
727
+ 'elk.separateConnectedComponents': 'true',
728
+ 'elk.padding': '[top=26,left=10,bottom=10,right=10]',
729
+ }, options.aspectRatio !== undefined ? { 'elk.aspectRatio': String(options.aspectRatio) } : {}, options.elk),
730
+ children: elkChildren,
731
+ edges: linkEdges,
732
+ });
733
+ const nodes = [];
734
+ const packagesOut = [];
735
+ const edges = new Map();
736
+ for (const c of packed.children ?? []) {
737
+ const at = { x: c.x ?? 0, y: c.y ?? 0 };
738
+ const sub = subBlocks.find((b) => b.key === c.id);
739
+ if (!sub) {
740
+ nodes.push({ id: c.id, x: at.x, y: at.y, width: c.width ?? 0, height: c.height ?? 0 });
741
+ continue;
742
+ }
743
+ for (const n of sub.nodes)
744
+ nodes.push({ ...n, x: n.x + at.x, y: n.y + at.y });
745
+ for (const p of sub.packages)
746
+ packagesOut.push({ ...p, x: p.x + at.x, y: p.y + at.y });
747
+ for (const i of sub.unrouted ?? [])
748
+ unrouted.push(i);
749
+ for (const [i, pts] of sub.edges)
750
+ edges.set(i, pts.map((q) => ({ x: q.x + at.x, y: q.y + at.y })));
751
+ }
752
+ const width = Math.max(packed.width ?? 0, packageMinWidth(top.name));
753
+ const height = Math.max(packed.height ?? 0, PACKAGE_LABEL.tabHeight + 36);
754
+ packagesOut.push({ id: topId, x: 0, y: 0, width, height });
755
+ return { key, width, height, nodes, packages: packagesOut, edges, unrouted };
756
+ }
757
+ function elkEdgePoints(edge, nodeById, offsets) {
758
+ const base = offsets.get(edge.container ?? 'block') ?? { x: 0, y: 0 };
759
+ const points = [];
760
+ for (const section of edge.sections ?? []) {
761
+ for (const p of sectionPoints(section)) {
762
+ const shifted = { x: p.x + base.x, y: p.y + base.y };
763
+ const last = points[points.length - 1];
764
+ if (!last || Math.abs(last.x - shifted.x) > 0.01 || Math.abs(last.y - shifted.y) > 0.01) {
765
+ points.push(shifted);
766
+ }
767
+ }
768
+ }
769
+ if (points.length >= 2)
770
+ return points;
771
+ const source = nodeById.get(edge.sources?.[0] ?? '');
772
+ const target = nodeById.get(edge.targets?.[0] ?? '');
773
+ if (!source || !target)
774
+ return [];
775
+ return [
776
+ { x: source.x + source.width / 2, y: source.y + source.height / 2 },
777
+ { x: target.x + target.width / 2, y: target.y + target.height / 2 },
778
+ ];
779
+ }
780
+ function sectionPoints(section) {
781
+ const pts = [];
782
+ if (section.startPoint)
783
+ pts.push(section.startPoint);
784
+ for (const b of section.bendPoints ?? [])
785
+ pts.push(b);
786
+ if (section.endPoint)
787
+ pts.push(section.endPoint);
788
+ return pts;
789
+ }
790
+ /** Arrange blocks as fixed-size nodes; block-level edges bias the order. */
791
+ async function placeBlocks(elk, model, blocks, crossRelations, blockOfEntity, options) {
792
+ const strategies = [...(options.placementStrategies ?? []), ...DEFAULT_PLACEMENT_STRATEGIES];
793
+ const ctx = { elk, model, blocks, crossRelations, blockOfEntity, options, strategies };
794
+ return requirePlacement(await runPlacementStrategies(strategies, ctx), 'block placement');
795
+ }
796
+ /** Blocks as the fixed-size nodes ELK places. */
797
+ function blockChildren(ctx) {
798
+ return [...ctx.blocks.values()].map((b) => ({ id: b.key, width: b.width, height: b.height }));
799
+ }
800
+ /** Number of distinct block pairs linked by a cross-block relation. */
801
+ function blockEdgeCount(ctx) {
802
+ return new Set(ctx.crossRelations.map((i) => `${ctx.blockOfEntity.get(ctx.model.relations[i].source)}\u0000${ctx.blockOfEntity.get(ctx.model.relations[i].target)}`)).size;
803
+ }
804
+ /**
805
+ * Mid-scale root layers (a dozen-ish packages) with few block-level edges gain
806
+ * nothing from layering: it strings them into a column. Pack them instead.
807
+ */
808
+ function isSparseMidScale(ctx) {
809
+ return (ctx.blocks.size >= MID_PACK_BLOCK_THRESHOLD &&
810
+ ctx.options.partitions.size === 0 &&
811
+ blockEdgeCount(ctx) * 2 < ctx.blocks.size);
812
+ }
813
+ function shouldPackBlocks(ctx) {
814
+ return ctx.blocks.size > PACK_BLOCK_THRESHOLD || isSparseMidScale(ctx);
815
+ }
816
+ /** Block-level edges biasing ELK's block order (one edge per linked pair). */
817
+ function blockEdges(ctx) {
818
+ const seen = new Set();
819
+ const edges = [];
820
+ for (const index of ctx.crossRelations) {
821
+ const relation = ctx.model.relations[index];
822
+ const a = ctx.blockOfEntity.get(relation.source);
823
+ const b = ctx.blockOfEntity.get(relation.target);
824
+ if (!ctx.blocks.has(a) || !ctx.blocks.has(b))
825
+ continue; // an orbiting stub: placed after the main blocks
826
+ const pair = `${a}\u0000${b}`;
827
+ if (seen.has(pair))
828
+ continue;
829
+ seen.add(pair);
830
+ edges.push({ id: `blk-${edges.length}`, sources: [a], targets: [b] });
831
+ }
832
+ return edges;
833
+ }
834
+ function rectPackLayoutOptions(ctx) {
835
+ const named = {};
836
+ if (ctx.options.aspectRatio !== undefined)
837
+ named['elk.aspectRatio'] = String(ctx.options.aspectRatio);
838
+ return mergeElkOptions({
839
+ 'elk.algorithm': 'rectpacking',
840
+ 'elk.aspectRatio': '1.6',
841
+ 'elk.spacing.nodeNode': String(BLOCK_SPACING),
842
+ 'elk.padding': `[top=${CANVAS_MARGIN},left=${CANVAS_MARGIN},bottom=${CANVAS_MARGIN},right=${CANVAS_MARGIN}]`,
843
+ }, named, ctx.options.elk);
844
+ }
845
+ function layeredLayoutOptions(ctx) {
846
+ const named = {};
847
+ if (ctx.options.aspectRatio !== undefined)
848
+ named['elk.aspectRatio'] = String(ctx.options.aspectRatio);
849
+ return mergeElkOptions({
850
+ 'elk.algorithm': 'layered',
851
+ 'elk.direction': 'DOWN',
852
+ 'elk.aspectRatio': '1.6',
853
+ 'elk.edgeRouting': 'ORTHOGONAL',
854
+ 'elk.spacing.nodeNode': String(BLOCK_SPACING),
855
+ 'elk.layered.spacing.nodeNodeBetweenLayers': String(BLOCK_SPACING),
856
+ 'elk.layered.wrapping.strategy': 'MULTI_EDGE',
857
+ 'elk.separateConnectedComponents': 'true',
858
+ 'elk.padding': `[top=${CANVAS_MARGIN},left=${CANVAS_MARGIN},bottom=${CANVAS_MARGIN},right=${CANVAS_MARGIN}]`,
859
+ }, named, ctx.options.elk);
860
+ }
861
+ /** Stubs orbit the placed main blocks instead of taking part in their placement. */
862
+ export const stubOrbitPlacementStrategy = {
863
+ name: 'stub-orbit',
864
+ async place(ctx) {
865
+ const stubKeys = new Set([...ctx.blocks.keys()].filter((k) => ctx.model.entities.find((e) => e.id === k)?.stub === true));
866
+ if (stubKeys.size === 0 || stubKeys.size >= ctx.blocks.size)
867
+ return undefined;
868
+ const main = new Map([...ctx.blocks].filter(([k]) => !stubKeys.has(k)));
869
+ const strategies = ctx.strategies.filter((s) => s !== stubOrbitPlacementStrategy);
870
+ const mainPlacement = await runPlacementStrategies(strategies, { ...ctx, blocks: main, strategies });
871
+ if (mainPlacement === undefined)
872
+ return undefined;
873
+ return placeStubsNearTargets(ctx.model, ctx.blocks, stubKeys, mainPlacement, ctx.crossRelations, ctx.blockOfEntity, ctx.options.stubOrbit ?? DEFAULT_STUB_ORBIT);
874
+ },
875
+ };
876
+ /** Semantic layers as stacked bands of blocks. */
877
+ export const layerRowsPlacementStrategy = {
878
+ name: 'layer-rows',
879
+ place(ctx) {
880
+ if (ctx.options.partitions.size === 0 || shouldPackBlocks(ctx))
881
+ return undefined;
882
+ return stackLayerRows(ctx.blocks, ctx.options);
883
+ },
884
+ };
885
+ /**
886
+ * Height-sorted shelf packing of the blocks; [[packedBlocksPlacementStrategy]]
887
+ * keeps it as one candidate beside ELK's own rect-packing runs.
888
+ */
889
+ export const shelfPackBlocksPlacementStrategy = {
890
+ name: 'shelf-pack-blocks',
891
+ place(ctx) {
892
+ if (!shouldPackBlocks(ctx))
893
+ return undefined;
894
+ const target = ctx.options.aspectRatio ?? 1.6;
895
+ return placementOfElkNode(shelfPackBlocks(blockChildren(ctx), target));
896
+ },
897
+ };
898
+ /**
899
+ * Rect packing of the blocks: height-sorted shelves are tried beside ELK's own
900
+ * rectpacking at a few ratios, and the tightest canvas near the target wins.
901
+ */
902
+ export const packedBlocksPlacementStrategy = {
903
+ name: 'packed-blocks',
904
+ async place(ctx) {
905
+ if (!shouldPackBlocks(ctx))
906
+ return undefined;
907
+ const children = blockChildren(ctx);
908
+ const layoutOptions = rectPackLayoutOptions(ctx);
909
+ if (ctx.blocks.size > MAX_CANDIDATE_BLOCKS) {
910
+ // Very many blocks: a single rectpacking run (the candidates below only pay off at mid scale).
911
+ return placementOfElkNode(await ctx.elk.layout({ id: 'blocks', layoutOptions, children, edges: [] }));
912
+ }
913
+ // rectpacking only honours a target ratio loosely and can leave a lone block
914
+ // on the last row; also try height-sorted shelves and keep the tightest
915
+ // canvas whose ratio stays near the target.
916
+ const target = ctx.options.aspectRatio ?? 1.6;
917
+ const shelf = await runPlacementStrategies([shelfPackBlocksPlacementStrategy], ctx);
918
+ const candidates = shelf === undefined ? [] : [shelf];
919
+ for (const ratio of PACK_ASPECT_CANDIDATES.map((f) => target * f)) {
920
+ candidates.push(placementOfElkNode(await ctx.elk.layout({
921
+ id: 'blocks',
922
+ layoutOptions: { ...layoutOptions, 'elk.aspectRatio': String(ratio) },
923
+ children: children.map((c) => ({ ...c })),
924
+ edges: [],
925
+ })));
926
+ }
927
+ const area = (n) => n.width * n.height;
928
+ const inBand = (n) => {
929
+ const a = n.width / Math.max(1, n.height);
930
+ return a >= PACK_BAND_MIN && a <= PACK_BAND_MAX;
931
+ };
932
+ if (ctx.options.aspectRatio !== undefined) {
933
+ // An explicit caller ratio wins over compactness: closest candidate.
934
+ const miss = (n) => Math.abs(Math.log(n.width / Math.max(1, n.height) / target));
935
+ return candidates.reduce((best, c) => (miss(c) < miss(best) ? c : best));
936
+ }
937
+ const pool = candidates.some(inBand) ? candidates.filter(inBand) : candidates;
938
+ return pool.reduce((best, c) => (area(c) < area(best) ? c : best));
939
+ },
940
+ };
941
+ /** Blocks placed by ELK's layered algorithm (no partitions, few enough to layer). */
942
+ export const elkLayeredPlacementStrategy = {
943
+ name: 'elk-layered-blocks',
944
+ async place(ctx) {
945
+ if (shouldPackBlocks(ctx) || ctx.options.partitions.size > 0)
946
+ return undefined;
947
+ return placementOfElkNode(await ctx.elk.layout({
948
+ id: 'blocks',
949
+ layoutOptions: layeredLayoutOptions(ctx),
950
+ children: blockChildren(ctx),
951
+ edges: blockEdges(ctx),
952
+ }));
953
+ },
954
+ };
955
+ /** Built-in block placement chain, tried after any injected strategies. */
956
+ export const DEFAULT_PLACEMENT_STRATEGIES = [
957
+ stubOrbitPlacementStrategy,
958
+ layerRowsPlacementStrategy,
959
+ packedBlocksPlacementStrategy,
960
+ elkLayeredPlacementStrategy,
961
+ ];
962
+ /**
963
+ * Orbit stub nodes (external packages of a single-package view) around the
964
+ * placed blocks: each stub joins the side of the main bounding box nearest to
965
+ * the nodes it connects to, and slides along that side to its targets' own
966
+ * coordinate (sweeping apart where stubs would overlap). Edges then stay short
967
+ * instead of running across the whole frame to a far-away stub column.
968
+ */
969
+ function placeStubsNearTargets(model, blocks, stubKeys, main, crossRelations, blockOfEntity, orbit) {
970
+ const centre = new Map();
971
+ for (const [key, at] of main.positions) {
972
+ for (const n of blocks.get(key)?.nodes ?? []) {
973
+ centre.set(n.id, { x: at.x + n.x + n.width / 2, y: at.y + n.y + n.height / 2 });
974
+ }
975
+ }
976
+ const stubs = [];
977
+ for (const key of [...stubKeys].sort()) {
978
+ let sx = 0;
979
+ let sy = 0;
980
+ const targets = [];
981
+ for (const index of crossRelations) {
982
+ const r = model.relations[index];
983
+ const other = blockOfEntity.get(r.source) === key ? r.target : blockOfEntity.get(r.target) === key ? r.source : undefined;
984
+ const c = other === undefined ? undefined : centre.get(other);
985
+ if (c) {
986
+ sx += c.x;
987
+ sy += c.y;
988
+ targets.push(c);
989
+ }
990
+ }
991
+ const count = targets.length;
992
+ stubs.push({
993
+ key,
994
+ centroid: { x: count > 0 ? sx / count : main.width / 2, y: count > 0 ? sy / count : main.height / 2 },
995
+ targets,
996
+ });
997
+ }
998
+ // All stubs form one rail along a single side of the frame. The side is the
999
+ // one that is nearest to the stubs' targets overall; a side too short to hold
1000
+ // the whole rail only wins when the others are even shorter.
1001
+ const sides = ['right', 'left', 'top', 'bottom'];
1002
+ const depthOf = (side, c) => side === 'left' ? c.x : side === 'right' ? main.width - c.x : side === 'top' ? c.y : main.height - c.y;
1003
+ const railLength = (side) => {
1004
+ const vertical = side === 'left' || side === 'right';
1005
+ return stubs.reduce((sum, { key }) => sum + (vertical ? blocks.get(key).height : blocks.get(key).width) + RAIL_MIN_GAP, 0);
1006
+ };
1007
+ const sideCost = (side) => {
1008
+ const extent = side === 'left' || side === 'right' ? main.height : main.width;
1009
+ const overflow = Math.max(0, railLength(side) - extent);
1010
+ return stubs.reduce((sum, s) => sum + depthOf(side, s.centroid), 0) / Math.max(1, stubs.length) + overflow * RAIL_OVERFLOW_WEIGHT;
1011
+ };
1012
+ // Left/right sides are preferred on ties: side stubs keep text horizontal.
1013
+ const side = stubs.length === 0 ? 'right' : sides.reduce((a, b) => (sideCost(b) < sideCost(a) - 1e-6 ? b : a));
1014
+ const positions = new Map(main.positions);
1015
+ let minX = 0;
1016
+ let minY = 0;
1017
+ let maxX = main.width;
1018
+ let maxY = main.height;
1019
+ if (stubs.length > 0) {
1020
+ const vertical = side === 'left' || side === 'right';
1021
+ const extent = vertical ? main.height : main.width;
1022
+ const lenOf = (key) => (vertical ? blocks.get(key).height : blocks.get(key).width);
1023
+ const thickOf = (key) => (vertical ? blocks.get(key).width : blocks.get(key).height);
1024
+ const alongOf = (s) => {
1025
+ const anchor = orbit.anchor === 'nearest'
1026
+ ? s.targets.reduce((a, c) => (depthOf(side, c) < depthOf(side, a) ? c : a), s.targets[0] ?? s.centroid)
1027
+ : s.centroid;
1028
+ return vertical ? anchor.y : anchor.x;
1029
+ };
1030
+ // Ordered by where their targets lie along the side, then spaced evenly.
1031
+ const ordered = stubs
1032
+ .map((s) => ({ s, along: alongOf(s) }))
1033
+ .sort((a, b) => a.along - b.along || a.s.key.localeCompare(b.s.key));
1034
+ const lengths = ordered.reduce((sum, { s }) => sum + lenOf(s.key), 0);
1035
+ const slack = ordered.length > 1 ? (extent - lengths) / (ordered.length - 1) : 0;
1036
+ const gap = Math.max(RAIL_MIN_GAP, Math.min(orbit.spacing, slack));
1037
+ const total = lengths + gap * (ordered.length - 1);
1038
+ const meanAlong = ordered.reduce((sum, o) => sum + o.along, 0) / ordered.length;
1039
+ const start = Math.max(0, Math.min(meanAlong - total / 2, extent - total));
1040
+ let cursor = start;
1041
+ const thick = Math.max(...ordered.map(({ s }) => thickOf(s.key)));
1042
+ const offset = orbit.gap - CANVAS_MARGIN;
1043
+ for (const { s } of ordered) {
1044
+ const b = blocks.get(s.key);
1045
+ let x;
1046
+ let y;
1047
+ if (vertical) {
1048
+ y = cursor;
1049
+ x = side === 'left' ? CANVAS_MARGIN - offset - thick + (thick - b.width) : main.width - CANVAS_MARGIN + offset;
1050
+ }
1051
+ else {
1052
+ x = cursor;
1053
+ y = side === 'top' ? CANVAS_MARGIN - offset - thick + (thick - b.height) : main.height - CANVAS_MARGIN + offset;
1054
+ }
1055
+ cursor += lenOf(s.key) + gap;
1056
+ positions.set(s.key, { x, y });
1057
+ minX = Math.min(minX, x - CANVAS_MARGIN);
1058
+ minY = Math.min(minY, y - CANVAS_MARGIN);
1059
+ maxX = Math.max(maxX, x + b.width + CANVAS_MARGIN);
1060
+ maxY = Math.max(maxY, y + b.height + CANVAS_MARGIN);
1061
+ }
1062
+ }
1063
+ for (const [key, at] of positions)
1064
+ positions.set(key, { x: at.x - minX, y: at.y - minY });
1065
+ return { positions, width: maxX - minX, height: maxY - minY };
1066
+ }
1067
+ /**
1068
+ * Height-sorted shelf packing of fixed-size blocks: rows hold blocks of similar
1069
+ * height, so a row leaves little dead space beside its shorter members. The row
1070
+ * width scanned for is the one with the smallest canvas near `aspect`.
1071
+ */
1072
+ function shelfPackBlocks(children, aspect) {
1073
+ const items = children.map((c) => ({ ...c })).sort((a, b) => (b.height ?? 0) - (a.height ?? 0) || a.id.localeCompare(b.id));
1074
+ const gap = BLOCK_SPACING;
1075
+ const widest = Math.max(0, ...items.map((c) => c.width ?? 0));
1076
+ const total = items.reduce((sum, c) => sum + (c.width ?? 0) + gap, 0);
1077
+ let best;
1078
+ let bestScore = Infinity;
1079
+ const step = Math.max(8, (total - widest) / 200);
1080
+ for (let inner = widest; inner <= total + step; inner += step) {
1081
+ const placed = [];
1082
+ let x = 0;
1083
+ let y = 0;
1084
+ let rowH = 0;
1085
+ let width = 0;
1086
+ for (const c of items) {
1087
+ const w = c.width ?? 0;
1088
+ if (x > 0 && x + gap + w > inner) {
1089
+ y += rowH + gap;
1090
+ x = 0;
1091
+ rowH = 0;
1092
+ }
1093
+ if (x > 0)
1094
+ x += gap;
1095
+ placed.push({ ...c, x: CANVAS_MARGIN + x, y: CANVAS_MARGIN + y });
1096
+ x += w;
1097
+ rowH = Math.max(rowH, c.height ?? 0);
1098
+ width = Math.max(width, x);
1099
+ }
1100
+ const w = width + 2 * CANVAS_MARGIN;
1101
+ const h = y + rowH + 2 * CANVAS_MARGIN;
1102
+ const score = w * h * (1 + Math.abs(Math.log(w / h / aspect)) * 0.03);
1103
+ if (score < bestScore) {
1104
+ bestScore = score;
1105
+ best = { items: placed, width: w, height: h };
1106
+ }
1107
+ }
1108
+ return { id: 'blocks', width: best?.width ?? 0, height: best?.height ?? 0, children: best?.items ?? [] };
1109
+ }
1110
+ /** Row width beyond which a layer's blocks wrap onto a further row. */
1111
+ const MAX_ROW_WIDTH = 2000;
1112
+ /** Blocks up to this tall count as small and may share a row with other small blocks. */
1113
+ const SMALL_BLOCK_HEIGHT = 600;
1114
+ /**
1115
+ * Semantic-layer placement: every layer is a horizontal band, bands stack in
1116
+ * partition order, and the blocks of one band sit side by side (wrapping when
1117
+ * the band would grow wider than [[MAX_ROW_WIDTH]]). Each row is centred on the
1118
+ * widest one. Unlike a layered run over the blocks this cannot drag a wide
1119
+ * block sideways to sit under its parents, so the canvas stays as narrow as
1120
+ * its widest block.
1121
+ */
1122
+ function stackLayerRows(blocks, options) {
1123
+ const bands = new Map();
1124
+ for (const block of blocks.values()) {
1125
+ const partition = options.partitions.get(block.key.slice(PACKAGE_PREFIX.length));
1126
+ const band = partition ?? Number.MAX_SAFE_INTEGER;
1127
+ const list = bands.get(band);
1128
+ if (list)
1129
+ list.push(block);
1130
+ else
1131
+ bands.set(band, [block]);
1132
+ }
1133
+ const rows = [];
1134
+ for (const band of [...bands.keys()].sort((a, b) => a - b)) {
1135
+ let row = [];
1136
+ let rowWidth = 0;
1137
+ for (const block of bands.get(band)) {
1138
+ if (row.length > 0 && rowWidth + BLOCK_SPACING + block.width > MAX_ROW_WIDTH) {
1139
+ rows.push(row);
1140
+ row = [];
1141
+ rowWidth = 0;
1142
+ }
1143
+ rowWidth += (row.length > 0 ? BLOCK_SPACING : 0) + block.width;
1144
+ row.push(block);
1145
+ }
1146
+ if (row.length > 0)
1147
+ rows.push(row);
1148
+ }
1149
+ const widthOf = (row) => row.reduce((sum, b) => sum + b.width, 0) + BLOCK_SPACING * (row.length - 1);
1150
+ const inner = Math.max(0, ...rows.map(widthOf));
1151
+ // Neighbouring rows of small blocks share one row (in layer order, left to
1152
+ // right) instead of stacking into a narrow column above the big layer.
1153
+ const isSmall = (row) => row.every((b) => b.height <= SMALL_BLOCK_HEIGHT);
1154
+ for (let i = 0; i + 1 < rows.length;) {
1155
+ const merged = [...rows[i], ...rows[i + 1]];
1156
+ if (isSmall(rows[i]) && isSmall(rows[i + 1]) && widthOf(merged) <= inner) {
1157
+ rows.splice(i, 2, merged);
1158
+ }
1159
+ else {
1160
+ i++;
1161
+ }
1162
+ }
1163
+ const positions = new Map();
1164
+ let y = CANVAS_MARGIN;
1165
+ for (const row of rows) {
1166
+ let x = CANVAS_MARGIN; // rows share one left edge
1167
+ for (const block of row) {
1168
+ positions.set(block.key, { x, y });
1169
+ x += block.width + BLOCK_SPACING;
1170
+ }
1171
+ y += Math.max(...row.map((b) => b.height)) + BLOCK_SPACING;
1172
+ }
1173
+ return {
1174
+ positions,
1175
+ width: inner + CANVAS_MARGIN * 2,
1176
+ height: Math.max(0, y - BLOCK_SPACING) + CANVAS_MARGIN,
1177
+ };
1178
+ }