@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.
- package/README.md +20 -0
- package/dist/core.d.ts +102 -9
- package/dist/core.js +91 -51
- package/dist/foundation/layers.d.ts +25 -0
- package/dist/foundation/layers.js +101 -0
- package/dist/foundation/layout.d.ts +116 -0
- package/dist/foundation/layout.js +298 -0
- package/dist/{model.d.ts → foundation/model.d.ts} +20 -0
- package/dist/foundation/relation-semantics.d.ts +49 -0
- package/dist/foundation/relation-semantics.js +112 -0
- package/dist/{text-measure.d.ts → foundation/text-measure.d.ts} +2 -0
- package/dist/{text-measure.js → foundation/text-measure.js} +20 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/layout/compound.d.ts +59 -0
- package/dist/layout/compound.js +1178 -0
- package/dist/layout/elk-graph.d.ts +36 -0
- package/dist/layout/elk-graph.js +220 -0
- package/dist/layout/elk-result.d.ts +18 -0
- package/dist/layout/elk-result.js +127 -0
- package/dist/layout/elk.d.ts +63 -33
- package/dist/layout/elk.js +215 -321
- package/dist/layout/fanout-trunk.d.ts +18 -0
- package/dist/layout/fanout-trunk.js +380 -0
- package/dist/layout/grid-router.d.ts +69 -0
- package/dist/layout/grid-router.js +615 -0
- package/dist/layout/lane-spread.d.ts +11 -0
- package/dist/layout/lane-spread.js +302 -0
- package/dist/layout/layout-metrics.d.ts +183 -0
- package/dist/layout/layout-metrics.js +766 -0
- package/dist/layout/package-endpoints.d.ts +10 -0
- package/dist/layout/package-endpoints.js +54 -0
- package/dist/layout/placement-strategy.d.ts +98 -0
- package/dist/layout/placement-strategy.js +26 -0
- package/dist/layout/strategy.d.ts +27 -0
- package/dist/layout/strategy.js +19 -0
- package/dist/layout/trunk-lanes.d.ts +29 -0
- package/dist/layout/trunk-lanes.js +308 -0
- package/dist/layout/viz.d.ts +3 -3
- package/dist/layout/viz.js +13 -17
- package/dist/parser.d.ts +27 -1
- package/dist/parser.js +111 -5
- package/dist/render/labels.d.ts +44 -0
- package/dist/render/labels.js +231 -0
- package/dist/render/legend.d.ts +15 -0
- package/dist/render/legend.js +42 -0
- package/dist/render/svg.d.ts +60 -3
- package/dist/render/svg.js +326 -98
- package/dist/render/theme.d.ts +109 -0
- package/dist/render/theme.js +99 -0
- package/dist/serialize.d.ts +32 -0
- package/dist/serialize.js +218 -0
- package/dist/transform.d.ts +55 -1
- package/dist/transform.js +277 -29
- package/package.json +4 -3
- package/dist/layout-metrics.d.ts +0 -42
- package/dist/layout-metrics.js +0 -241
- package/dist/layout.d.ts +0 -75
- package/dist/layout.js +0 -51
- /package/dist/{edge-router.d.ts → foundation/edge-router.d.ts} +0 -0
- /package/dist/{edge-router.js → foundation/edge-router.js} +0 -0
- /package/dist/{model.js → foundation/model.js} +0 -0
- /package/dist/{render-dot.d.ts → foundation/render-dot.d.ts} +0 -0
- /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
|
+
}
|