@yalehwang/plantuml-js 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +20 -0
  2. package/dist/core.d.ts +106 -13
  3. package/dist/core.js +92 -52
  4. package/dist/foundation/labels.d.ts +44 -0
  5. package/dist/foundation/labels.js +231 -0
  6. package/dist/foundation/layers.d.ts +25 -0
  7. package/dist/foundation/layers.js +101 -0
  8. package/dist/foundation/layout.d.ts +116 -0
  9. package/dist/foundation/layout.js +298 -0
  10. package/dist/{model.d.ts → foundation/model.d.ts} +20 -0
  11. package/dist/foundation/relation-semantics.d.ts +49 -0
  12. package/dist/foundation/relation-semantics.js +112 -0
  13. package/dist/{text-measure.d.ts → foundation/text-measure.d.ts} +2 -0
  14. package/dist/{text-measure.js → foundation/text-measure.js} +20 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +1 -1
  17. package/dist/layout/compound.d.ts +74 -0
  18. package/dist/layout/compound.js +1204 -0
  19. package/dist/layout/elk-graph.d.ts +36 -0
  20. package/dist/layout/elk-graph.js +220 -0
  21. package/dist/layout/elk-result.d.ts +18 -0
  22. package/dist/layout/elk-result.js +127 -0
  23. package/dist/layout/elk.d.ts +80 -33
  24. package/dist/layout/elk.js +229 -320
  25. package/dist/layout/fanout-trunk.d.ts +18 -0
  26. package/dist/layout/fanout-trunk.js +380 -0
  27. package/dist/layout/grid-router.d.ts +69 -0
  28. package/dist/layout/grid-router.js +615 -0
  29. package/dist/layout/lane-spread.d.ts +11 -0
  30. package/dist/layout/lane-spread.js +302 -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/metrics/layout-metrics.d.ts +183 -0
  42. package/dist/metrics/layout-metrics.js +766 -0
  43. package/dist/parse/parser.d.ts +43 -0
  44. package/dist/{parser.js → parse/parser.js} +111 -5
  45. package/dist/parse/serialize.d.ts +32 -0
  46. package/dist/parse/serialize.js +218 -0
  47. package/dist/render/legend.d.ts +15 -0
  48. package/dist/render/legend.js +42 -0
  49. package/dist/render/svg.d.ts +60 -3
  50. package/dist/render/svg.js +326 -98
  51. package/dist/render/theme.d.ts +109 -0
  52. package/dist/render/theme.js +99 -0
  53. package/dist/{transform.d.ts → transform/transform.d.ts} +55 -1
  54. package/dist/{transform.js → transform/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/parser.d.ts +0 -17
  61. /package/dist/{edge-router.d.ts → foundation/edge-router.d.ts} +0 -0
  62. /package/dist/{edge-router.js → foundation/edge-router.js} +0 -0
  63. /package/dist/{model.js → foundation/model.js} +0 -0
  64. /package/dist/{render-dot.d.ts → layout/render-dot.d.ts} +0 -0
  65. /package/dist/{render-dot.js → layout/render-dot.js} +0 -0
@@ -21,107 +21,125 @@
21
21
  * edges share an in/out side and spread across distinct ports instead of
22
22
  * piling onto one point.
23
23
  *
24
- * Cross-package edges would otherwise come back from ELK without any routing
25
- * section (the `INHERIT` hierarchy mode leaves them unresolved), so when the
26
- * model contains edges that cross *between two different packages* the engine
27
- * switches that layout to `INCLUDE_CHILDREN` — that is what lets ELK route
28
- * those edges around unrelated boxes and keeps `edgeNodeIntersections` at zero.
24
+ * Edges that cross a container boundary (package↔package, or package↔top-level
25
+ * stub) would otherwise come back from ELK without any routing section (the
26
+ * `INHERIT` hierarchy mode leaves them unresolved, and they degrade to straight
27
+ * diagonals), so when the model contains such edges the engine switches that
28
+ * layout to `INCLUDE_CHILDREN`, which routes them around unrelated boxes.
29
29
  *
30
- * A package↔top-level edge (e.g. a single-package view whose neighbours are
31
- * top-level stubs) deliberately does *not* switch the hierarchy handling:
32
- * `INCLUDE_CHILDREN` flattens a package with disconnected children into a
33
- * single-row strip instead of a packed grid, which is exactly the failure the
34
- * Tests Layer view used to show (`package-pkg-Tests_Layer` at aspect 14.9).
30
+ * `INCLUDE_CHILDREN` is one layered run over the whole nested graph, so it can
31
+ * flatten a package into a strip or stack a big graph into a sliver. When the
32
+ * result is badly proportioned (or the graph is very large) the engine falls
33
+ * back to the compound layout in [[./compound]]: each top-level package is laid
34
+ * out as its own block, blocks are packed, and cross-block edges are routed by
35
+ * the grid router in [[./grid-router]].
35
36
  */
36
37
  import ELKDefault from 'elkjs';
37
- import { PACKAGE_LABEL, estimateEntitySize, } from '../layout.js';
38
- import { measureTextWidth } from '../text-measure.js';
38
+ import { alignRows, alignIsolatedFrames } from '../foundation/layout.js';
39
+ import { compoundLayout, DEFAULT_LARGE_GRAPH_THRESHOLD } from './compound.js';
40
+ import { computeLayoutMetrics, computeMaxEmptyBlockRatio } from '../metrics/layout-metrics.js';
41
+ import { resolveLayerOrder, assignPartitions } from '../foundation/layers.js';
42
+ import { buildElkGraph } from './elk-graph.js';
43
+ import { clipPackageEndpoints } from './package-endpoints.js';
44
+ import { mapElkResult } from './elk-result.js';
45
+ import { spreadParallelLanes } from './lane-spread.js';
46
+ import { straightenTrunkDetours, liftFlushSegments, uncoilWraps } from './trunk-lanes.js';
47
+ import { mergeFanoutTrunks } from './fanout-trunk.js';
48
+ import { runStrategies } from './strategy.js';
39
49
  // elkjs ships a `typeof import(...)` default-export type under NodeNext; pin the
40
50
  // real constructor shape so `new` type-checks and the layout result stays typed.
41
51
  const ElkConstructor = ELKDefault;
42
- const PACKAGE_PREFIX = 'pkg::';
43
- /** Relation types whose layout direction is reversed so the arrow target sits above the source. */
44
- const REVERSED_RELATION_TYPES = new Set(['inheritance', 'implementation']);
45
- /** Name convention: `"X Layer"` declares a semantic layer `X`. */
46
- const LAYER_SUFFIX = /^(.*)\s+Layer$/i;
47
- /** Resolve a package's semantic layer from its explicit field or its name. */
48
- function packageLayer(pkg) {
49
- if (pkg.layer !== undefined)
50
- return pkg.layer;
51
- const m = LAYER_SUFFIX.exec(pkg.name);
52
- if (!m)
53
- return undefined;
54
- const core = m[1].trim();
55
- return core === '' ? undefined : core;
56
- }
57
52
  /**
58
- * Order semantic layers by dependency topology instead of puml declaration
59
- * order: a layer that depends on another (`A ..> B` across layers) sits *above*
60
- * its dependency, so entry layers (Tests, Examples) stack over the base they
61
- * consume (Src) and independent layers (Templates) fall out of the sandwich.
62
- *
63
- * A Kahn topological sort places in-degree-zero layers (nothing depends on
64
- * them) first, breaking ties in declaration order for determinism. Layers left
65
- * over because of a dependency cycle are appended in declaration order, so a
66
- * cyclic graph still yields a stable (if not strictly topological) order; the
67
- * renderer marks the offending back-edges as feedback arcs.
53
+ * Entity count above which the single hierarchical ELK run is skipped in favour
54
+ * of the compound layout (per-package blocks + packing + grid routing).
68
55
  */
69
- function topologicalLayerOrder(model) {
70
- const declared = [];
71
- for (const pkg of model.packages) {
72
- const layer = packageLayer(pkg);
73
- if (layer !== undefined && !declared.includes(layer))
74
- declared.push(layer);
75
- }
76
- if (declared.length <= 1)
77
- return declared;
78
- const pkgById = new Map(model.packages.map((p) => [p.id, p]));
79
- const layerOf = new Map();
80
- for (const entity of model.entities) {
81
- const pkg = entity.packageId ? pkgById.get(entity.packageId) : undefined;
82
- layerOf.set(entity.id, pkg ? packageLayer(pkg) : undefined);
83
- }
84
- const dependsOn = new Map();
85
- const dependents = new Map();
86
- for (const layer of declared) {
87
- dependsOn.set(layer, new Set());
88
- dependents.set(layer, 0);
89
- }
90
- for (const relation of model.relations) {
91
- const source = layerOf.get(relation.source);
92
- const target = layerOf.get(relation.target);
93
- if (source === undefined || target === undefined || source === target)
94
- continue;
95
- const set = dependsOn.get(source);
96
- if (!set.has(target)) {
97
- set.add(target);
98
- dependents.set(target, (dependents.get(target) ?? 0) + 1);
99
- }
100
- }
101
- const order = [];
102
- const done = new Set();
103
- const queue = declared.filter((layer) => (dependents.get(layer) ?? 0) === 0);
104
- for (let i = 0; i < queue.length; i++) {
105
- const layer = queue[i];
106
- order.push(layer);
107
- done.add(layer);
108
- for (const dep of dependsOn.get(layer) ?? []) {
109
- const remaining = (dependents.get(dep) ?? 0) - 1;
110
- dependents.set(dep, remaining);
111
- if (remaining === 0 && !done.has(dep))
112
- queue.push(dep);
113
- }
114
- }
115
- // Cyclic remainder: keep declaration order so the output is stable.
116
- for (const layer of declared) {
117
- if (!done.has(layer))
118
- order.push(layer);
119
- }
120
- return order;
56
+ const COMPOUND_ENTITY_THRESHOLD = 250;
57
+ /** Aspect ratio band and pixel budget a single hierarchical run must stay inside. */
58
+ const MAX_ASPECT = 3;
59
+ /** Stacked semantic layers are legitimately tall; only a sliver is rejected. */
60
+ const MIN_ASPECT = 0.2;
61
+ /** Narrowest aspect a width-driven switch to the compound layout may produce. */
62
+ const PREFERRED_MIN_ASPECT = 0.6;
63
+ /** Top-level package count from which a gappy flat run yields to packed blocks. */
64
+ const MID_ROOT_PACKAGES = 8;
65
+ /** Empty-block ratio above which a mid-scale flat run is considered gappy. */
66
+ const MID_MAX_EMPTY_BLOCK = 0.15;
67
+ const MAX_PIXELS = 40e6;
68
+ /** Canvas area (px²) a dense compound layout with dependency-ordered rows may use. */
69
+ const COMPOUND_AREA_BUDGET = 90e6;
70
+ /** Canvas width beyond which a single hierarchical run is too wide to read. */
71
+ const MAX_WIDTH = 2400;
72
+ /** Alternative stub orbits tried after the default one (tuned on the archguard package views). */
73
+ const STUB_ORBITS = [
74
+ { gap: 72, spacing: 112, anchor: 'nearest' },
75
+ { gap: 88, spacing: 120, anchor: 'nearest' },
76
+ { gap: 72, spacing: 136, anchor: 'nearest' },
77
+ { gap: 48, spacing: 112, anchor: 'centroid' },
78
+ ];
79
+ /** Clutter cost under which no further stub orbit is tried. */
80
+ const STUB_GOOD_ENOUGH = 400;
81
+ /** Clutter of a stub view: border-hugging edges and parallel walls weigh most. */
82
+ function stubViewCost(model, layout) {
83
+ const m = computeLayoutMetrics(model, layout);
84
+ return (m.edgeFlushCount * 500 +
85
+ m.edgeNodeIntersections * 1000 +
86
+ m.overlappingEdgeLength +
87
+ m.edgeCrossings * 4 +
88
+ m.maxEmptyBlockRatio * 2000);
89
+ }
90
+ function isProportionate(layout) {
91
+ if (layout.height <= 0 || layout.width <= 0)
92
+ return true;
93
+ const aspect = layout.width / layout.height;
94
+ return aspect <= MAX_ASPECT && aspect >= MIN_ASPECT && layout.width * layout.height <= MAX_PIXELS;
95
+ }
96
+ /** Compound result that respects the width budget and is not a tall sliver. */
97
+ function isNarrowEnough(layout) {
98
+ if (layout.height <= 0 || layout.width <= 0)
99
+ return true;
100
+ return layout.width <= MAX_WIDTH && layout.width / layout.height >= PREFERRED_MIN_ASPECT;
121
101
  }
122
- /** Minimum frame width that keeps the package label tab readable. */
123
- function packageMinWidth(name) {
124
- return measureTextWidth(name, PACKAGE_LABEL.fontSize) + PACKAGE_LABEL.tabPadding + 20;
102
+ /** A tall single-column run: narrower than the preferred minimum aspect. */
103
+ function isColumn(layout) {
104
+ return layout.height > 0 && layout.width / layout.height < PREFERRED_MIN_ASPECT;
105
+ }
106
+ /** Clear space kept between any edge point and the canvas side. */
107
+ export const CANVAS_EDGE_MARGIN = 16;
108
+ /**
109
+ * Translate and grow the canvas so no edge point (or node/frame) sits closer
110
+ * than [[CANVAS_EDGE_MARGIN]] to a canvas side — a line hugging the border is
111
+ * easily cropped.
112
+ */
113
+ export function fitCanvas(layout) {
114
+ let minX = Infinity;
115
+ let minY = Infinity;
116
+ let maxX = -Infinity;
117
+ let maxY = -Infinity;
118
+ const take = (x, y) => {
119
+ minX = Math.min(minX, x);
120
+ minY = Math.min(minY, y);
121
+ maxX = Math.max(maxX, x);
122
+ maxY = Math.max(maxY, y);
123
+ };
124
+ for (const e of layout.edges ?? [])
125
+ for (const p of e.points)
126
+ take(p.x, p.y);
127
+ if (minX === Infinity)
128
+ return layout;
129
+ const dx = Math.max(0, CANVAS_EDGE_MARGIN - minX);
130
+ const dy = Math.max(0, CANVAS_EDGE_MARGIN - minY);
131
+ const width = Math.max(layout.width + dx, maxX + dx + CANVAS_EDGE_MARGIN);
132
+ const height = Math.max(layout.height + dy, maxY + dy + CANVAS_EDGE_MARGIN);
133
+ if (dx === 0 && dy === 0 && width === layout.width && height === layout.height)
134
+ return layout;
135
+ return {
136
+ ...layout,
137
+ nodes: layout.nodes.map((n) => ({ ...n, x: n.x + dx, y: n.y + dy })),
138
+ edges: layout.edges.map((e) => ({ ...e, points: e.points.map((p) => ({ x: p.x + dx, y: p.y + dy })) })),
139
+ packages: layout.packages?.map((p) => ({ ...p, x: p.x + dx, y: p.y + dy })),
140
+ width,
141
+ height,
142
+ };
125
143
  }
126
144
  export class ElkLayoutEngine {
127
145
  elk = new ElkConstructor();
@@ -130,247 +148,138 @@ export class ElkLayoutEngine {
130
148
  this.options = options;
131
149
  }
132
150
  async layout(model) {
133
- const layers = this.resolveLayerOrder(model);
134
- const partitions = this.assignPartitions(model, layers);
135
- const partitionsActive = layers.length > 0;
136
- const includeChildren = this.hasCrossPackageEdge(model);
137
- const children = this.buildChildren(model, partitions);
138
- const edges = model.relations.map((relation, i) => ({
139
- id: `rel-${i}`,
140
- sources: [this.layoutSource(relation)],
141
- targets: [this.layoutTarget(relation)],
142
- }));
143
- const layoutOptions = {
144
- 'elk.algorithm': 'layered',
145
- 'elk.direction': 'DOWN',
146
- 'elk.edgeRouting': this.options.edgeRouting ?? 'ORTHOGONAL',
147
- 'elk.spacing.nodeNode': '40',
148
- 'elk.layered.spacing.nodeNodeBetweenLayers': '60',
149
- // Ports keep a fixed side per edge, so same-direction edges fan out across
150
- // one node edge instead of scattering onto every side (or one point).
151
- 'elk.layered.portConstraints': 'FIXED_SIDE',
152
- // Soft targets: pack disconnected components into a balanced rectangle
153
- // instead of a long horizontal strip. (hierarchyHandling stays INHERIT
154
- // unless cross-package edges exist — see the header comment.)
155
- 'elk.aspectRatio': '1.6',
156
- 'elk.separateConnectedComponents': 'true',
157
- // INTERACTIVE placement keeps each layer's boxes centred on the widest
158
- // partition instead of fanning them out sideways, which is what lets a
159
- // topologically-stacked package diagram stay compact (see
160
- // docs/layout-routing-eval.md).
161
- 'elk.layered.nodePlacement.strategy': 'INTERACTIVE',
162
- };
163
- if (partitionsActive)
164
- layoutOptions['elk.partitioning.activate'] = 'true';
165
- if (includeChildren)
166
- layoutOptions['elk.hierarchyHandling'] = 'INCLUDE_CHILDREN';
167
- if (this.options.mergeEdges)
168
- layoutOptions['elk.layered.mergeEdges'] = 'true';
169
- const graph = {
170
- id: 'root',
171
- layoutOptions,
172
- children,
173
- edges,
174
- };
175
- const result = await this.elk.layout(graph);
176
- const nodes = [];
177
- const packages = [];
178
- const offsets = new Map([['root', { x: 0, y: 0 }]]);
179
- for (const child of result.children ?? []) {
180
- this.flatten(child, 0, 0, nodes, packages, offsets);
181
- }
182
- this.enforcePackageMinWidth(model, packages);
183
- const nodeById = new Map(nodes.map((n) => [n.id, n]));
184
- const laidEdges = (result.edges ?? []).map((edge, i) => {
185
- const relation = model.relations[i];
186
- const reversed = relation !== undefined && REVERSED_RELATION_TYPES.has(relation.type);
187
- const points = this.edgePoints(edge, nodeById, offsets);
188
- return {
189
- id: edge.id ?? `rel-${i}`,
190
- // Keep the model's direction (not the possibly-reversed layout
191
- // direction) so the renderer's arrowheads and the overlap counter both
192
- // see the endpoints the author wrote.
193
- source: relation?.source ?? (edge.sources?.[0] ?? ''),
194
- target: relation?.target ?? (edge.targets?.[0] ?? ''),
195
- points: reversed ? points.slice().reverse() : points,
196
- };
197
- });
198
- return {
199
- nodes,
200
- edges: laidEdges,
201
- packages,
202
- width: result.width ?? 0,
203
- height: result.height ?? 0,
204
- };
205
- }
206
- /** Source endpoint of the edge *as laid out* (reversed for inheritance/implementation). */
207
- layoutSource(relation) {
208
- return REVERSED_RELATION_TYPES.has(relation.type) ? relation.target : relation.source;
209
- }
210
- /** Target endpoint of the edge *as laid out* (reversed for inheritance/implementation). */
211
- layoutTarget(relation) {
212
- return REVERSED_RELATION_TYPES.has(relation.type) ? relation.source : relation.target;
213
- }
214
- /** Ordered, de-duplicated list of semantic layers (dependency topology by default). */
215
- resolveLayerOrder(model) {
216
- if (this.options.layerOrder) {
217
- return this.options.layerOrder.filter((layer, i, all) => all.indexOf(layer) === i);
218
- }
219
- return topologicalLayerOrder(model);
220
- }
221
- /** Map each package to its partition index (layers first, unlayered last). */
222
- assignPartitions(model, layers) {
223
- const partitions = new Map();
224
- if (layers.length === 0)
225
- return partitions;
226
- for (const pkg of model.packages) {
227
- const layer = packageLayer(pkg);
228
- const index = layer !== undefined ? layers.indexOf(layer) : -1;
229
- partitions.set(pkg.id, index >= 0 ? index : layers.length);
230
- }
231
- return partitions;
232
- }
233
- /** True when any relation connects entities in two *different* packages. */
234
- hasCrossPackageEdge(model) {
235
- const packageOf = new Map(model.entities.map((e) => [e.id, e.packageId]));
236
- for (const relation of model.relations) {
237
- const sourcePkg = packageOf.get(relation.source);
238
- const targetPkg = packageOf.get(relation.target);
239
- if (sourcePkg !== undefined && targetPkg !== undefined && sourcePkg !== targetPkg) {
240
- return true;
151
+ const large = this.isLargeGraph(model);
152
+ const spread = (raw) => spreadParallelLanes(clipPackageEndpoints(model, alignIsolatedFrames(model, alignRows(model, raw))));
153
+ // The edge-polishing passes are quadratic-ish in the canvas size (per-edge
154
+ // A* plus all-pairs crossing scans) and cannot be bounded by any budget on
155
+ // a 2000-node nested canvas, so a large graph keeps the raw compound
156
+ // geometry and only gets the cheap passes.
157
+ const finish = (raw) => fitCanvas(liftFlushSegments(large
158
+ ? spread(raw)
159
+ : // stub views price crossings high on purpose: their rail edges are not un-coiled
160
+ (model.entities.some((e) => e.stub) ? (l) => l : uncoilWraps)(straightenTrunkDetours(mergeFanoutTrunks(model, spread(raw))))));
161
+ if (!model.entities.some((e) => e.stub))
162
+ return finish((await this.layoutRaw(model)).layout);
163
+ // Where the orbiting stubs sit decides how the router weaves their edges
164
+ // through the frame; the outcome is chaotic enough that a handful of
165
+ // orbits is tried and the least cluttered picture kept. The trials skip the
166
+ // router's rip-up passes (the slow part); only the winner is routed in full.
167
+ const first = await this.layoutRaw(model, { quick: true });
168
+ if (!first.orbited)
169
+ return finish(first.layout);
170
+ let bestCost = stubViewCost(model, finish(first.layout));
171
+ let bestOrbit;
172
+ for (const orbit of STUB_ORBITS) {
173
+ if (bestCost <= STUB_GOOD_ENOUGH)
174
+ break;
175
+ const trial = await this.layoutRaw(model, { orbit, quick: true });
176
+ const cost = stubViewCost(model, finish(trial.layout));
177
+ if (cost < bestCost) {
178
+ bestCost = cost;
179
+ bestOrbit = orbit;
241
180
  }
242
181
  }
243
- return false;
182
+ return finish((await this.layoutRaw(model, { orbit: bestOrbit })).layout);
244
183
  }
245
- buildChildren(model, partitions) {
246
- const pkgNodes = new Map();
247
- for (const pkg of model.packages) {
248
- // Padding at the top keeps the package label tab clear of the first
249
- // entity; the minimum width keeps the tab wide enough for the label when
250
- // a package holds only a single short-named entity.
251
- const minWidth = packageMinWidth(pkg.name);
252
- const minHeight = PACKAGE_LABEL.tabHeight + 26 + 10; // tab + top/bottom padding
253
- const layoutOptions = {
254
- 'elk.padding': '[top=26,left=10,bottom=10,right=10]',
255
- 'elk.nodeSize.constraints': '[MINIMUM_SIZE]',
256
- 'elk.nodeSize.minimum': `[${minWidth.toFixed(1)}, ${minHeight.toFixed(1)}]`,
257
- };
258
- const partition = partitions.get(pkg.id);
259
- if (partition !== undefined)
260
- layoutOptions['elk.partitioning.partition'] = String(partition);
261
- pkgNodes.set(pkg.id, {
262
- id: PACKAGE_PREFIX + pkg.id,
263
- children: [],
264
- layoutOptions,
265
- });
266
- }
267
- const top = [];
268
- for (const entity of model.entities) {
269
- const leaf = this.entityLeaf(entity);
270
- const owner = entity.packageId ? pkgNodes.get(entity.packageId) : undefined;
271
- if (owner)
272
- owner.children.push(leaf);
273
- else
274
- top.push(leaf);
184
+ async layoutRaw(model, run = {}) {
185
+ const layers = resolveLayerOrder(model, this.options.layerOrder);
186
+ const partitions = assignPartitions(model, layers);
187
+ const injected = await runStrategies(this.options.strategies, model, { layers, partitions });
188
+ if (injected !== undefined)
189
+ return { layout: injected, orbited: false };
190
+ let flat;
191
+ const raw = (layout) => ({ layout, orbited: false });
192
+ // A large package-less graph has no blocks to pack: the compound layout
193
+ // degenerates into one block per entity, which is an order of magnitude
194
+ // slower and no better proportioned than the single layered run (measured:
195
+ // 789 flat nodes — 1.7s/608 crossings layered vs 40s/13625 compound). It
196
+ // also has no folded layer sequence, so a requested aspectRatio cannot
197
+ // reshape it either; take the layered run unconditionally instead.
198
+ if (model.packages.length === 0 && model.entities.length > COMPOUND_ENTITY_THRESHOLD) {
199
+ return raw(await this.hierarchicalLayout(model, layers, partitions));
275
200
  }
276
- // Link nested packages into their parents; packages without a parent (or
277
- // whose parent is unknown) sit at the top level.
278
- for (const pkg of model.packages) {
279
- const node = pkgNodes.get(pkg.id);
280
- const parent = pkg.parentId ? pkgNodes.get(pkg.parentId) : undefined;
281
- if (parent)
282
- parent.children.push(node);
283
- else
284
- top.push(node);
201
+ // Very large graphs skip the single hierarchical run: it cannot keep the
202
+ // canvas proportioned, so it would only be thrown away.
203
+ const target = this.options.aspectRatio;
204
+ if (target !== undefined && target > 0)
205
+ return this.layoutToward(model, layers, partitions, target, run);
206
+ if (model.entities.length <= COMPOUND_ENTITY_THRESHOLD || model.packages.length === 0) {
207
+ flat = await this.hierarchicalLayout(model, layers, partitions);
208
+ if (isProportionate(flat) && flat.width <= MAX_WIDTH && !this.isGappyMidScale(model, flat))
209
+ return raw(flat);
285
210
  }
286
- return top;
287
- }
288
- entityLeaf(entity) {
289
- const size = estimateEntitySize(entity);
290
- return { id: entity.id, width: size.width, height: size.height };
211
+ const compound = await this.compound(model, layers, partitions, run);
212
+ const packed = compound.layout;
213
+ if (flat !== undefined && this.isGappyMidScale(model, flat) && isProportionate(flat) && !isColumn(packed)
214
+ && computeMaxEmptyBlockRatio(packed) >= computeMaxEmptyBlockRatio(flat))
215
+ return raw(flat);
216
+ // A flat run kept only for being too wide gives way to the compound layout
217
+ // when that one fits the width budget without degenerating into a tall
218
+ // sliver; otherwise the wide-but-balanced flat run is the better picture.
219
+ // A flat run that degenerated into a single column (aspect under the
220
+ // preferred minimum) is never worth keeping over the packed blocks.
221
+ if (flat !== undefined && isProportionate(flat) && !isNarrowEnough(packed) && !isColumn(flat))
222
+ return raw(flat);
223
+ return compound;
291
224
  }
292
225
  /**
293
- * INCLUDE_CHILDREN sizes a container from its content and does not honour the
294
- * `elk.nodeSize.minimum` we give package frames, so a single short-named
295
- * entity can leave the frame narrower than its label tab. Widen those frames
296
- * in place (never shrink) so the tab stays readable.
226
+ * A mid-scale root layer (many top-level packages) whose single hierarchical
227
+ * run left a large hole: the packed compound blocks are tried instead.
297
228
  */
298
- enforcePackageMinWidth(model, packages) {
299
- const byId = new Map(packages.map((p) => [p.id, p]));
300
- for (const pkg of model.packages) {
301
- const box = byId.get(pkg.id);
302
- if (!box)
303
- continue;
304
- const min = packageMinWidth(pkg.name);
305
- if (box.width < min)
306
- box.width = min;
307
- }
229
+ isGappyMidScale(model, flat) {
230
+ const roots = model.packages.filter((p) => p.parentId === undefined).length;
231
+ return roots >= MID_ROOT_PACKAGES && computeMaxEmptyBlockRatio(flat) > MID_MAX_EMPTY_BLOCK;
308
232
  }
309
- flatten(node, offsetX, offsetY, nodes, packages, offsets) {
310
- const x = (node.x ?? 0) + offsetX;
311
- const y = (node.y ?? 0) + offsetY;
312
- offsets.set(node.id, { x, y });
313
- if (node.id.startsWith(PACKAGE_PREFIX)) {
314
- packages.push({
315
- id: node.id.slice(PACKAGE_PREFIX.length),
316
- x,
317
- y,
318
- width: node.width ?? 0,
319
- height: node.height ?? 0,
320
- });
321
- for (const child of node.children ?? []) {
322
- this.flatten(child, x, y, nodes, packages, offsets);
323
- }
324
- }
325
- else {
326
- nodes.push({
327
- id: node.id,
328
- x,
329
- y,
330
- width: node.width ?? 0,
331
- height: node.height ?? 0,
332
- });
233
+ /**
234
+ * Caller-requested canvas ratio: run both strategies and keep whichever lands
235
+ * closer (in log distance) to the target. The default path never gets here.
236
+ */
237
+ async layoutToward(model, layers, partitions, target, run) {
238
+ const compound = await this.compound(model, layers, partitions, run);
239
+ const candidates = [compound.layout];
240
+ if (model.entities.length <= COMPOUND_ENTITY_THRESHOLD || model.packages.length === 0) {
241
+ candidates.push(await this.hierarchicalLayout(model, layers, partitions));
333
242
  }
243
+ const miss = (l) => l.width > 0 && l.height > 0 ? Math.abs(Math.log(l.width / l.height / target)) : 0;
244
+ const winner = candidates.reduce((best, c) => (miss(c) < miss(best) ? c : best));
245
+ return { layout: winner, orbited: true };
334
246
  }
335
- edgePoints(edge, nodeById, offsets) {
336
- const sections = edge.sections ?? [];
337
- if (sections.length === 0) {
338
- return this.straightEdge(edge, nodeById);
339
- }
340
- const containerOffset = offsets.get(edge.container ?? 'root') ?? {
341
- x: 0,
342
- y: 0,
343
- };
344
- const points = [];
345
- for (const section of sections) {
346
- for (const p of this.sectionPoints(section)) {
347
- const shifted = { x: p.x + containerOffset.x, y: p.y + containerOffset.y };
348
- const last = points[points.length - 1];
349
- if (!last || Math.abs(last.x - shifted.x) > 0.01 || Math.abs(last.y - shifted.y) > 0.01) {
350
- points.push(shifted);
351
- }
352
- }
247
+ async compound(model, layers, partitions, { orbit, quick }) {
248
+ const dense = model.entities.length > COMPOUND_ENTITY_THRESHOLD;
249
+ const large = this.isLargeGraph(model);
250
+ const run = (rectPackDense) => compoundLayout(this.elk, model, {
251
+ edgeRouting: this.options.edgeRouting,
252
+ aspectRatio: this.options.aspectRatio,
253
+ elk: this.options.elk,
254
+ dense,
255
+ rectPackDense,
256
+ partitions: layers.length > 0 ? partitions : new Map(),
257
+ stubOrbit: orbit,
258
+ ripupPasses: quick ? 0 : undefined,
259
+ largeGraphThreshold: this.options.largeGraphThreshold,
260
+ });
261
+ const ordered = await run(false);
262
+ // Dependency-ordered rows cost canvas on very large graphs; past the budget
263
+ // fall back to the tighter (order-blind) rect packing. A large graph skips
264
+ // the retry: laying every nested block out twice doubles the dominant cost
265
+ // for a canvas-area gain that the large-graph router no longer needs.
266
+ if (!large && dense && ordered.width * ordered.height > COMPOUND_AREA_BUDGET) {
267
+ return { layout: await run(true), orbited: true };
353
268
  }
354
- return points.length >= 2 ? points : this.straightEdge(edge, nodeById);
269
+ return { layout: ordered, orbited: true };
355
270
  }
356
- sectionPoints(section) {
357
- const pts = [];
358
- if (section.startPoint)
359
- pts.push(section.startPoint);
360
- for (const b of section.bendPoints ?? [])
361
- pts.push(b);
362
- if (section.endPoint)
363
- pts.push(section.endPoint);
364
- return pts;
271
+ /** Entity count above which the cheap large-graph pipeline is used. */
272
+ isLargeGraph(model) {
273
+ return model.entities.length > (this.options.largeGraphThreshold ?? DEFAULT_LARGE_GRAPH_THRESHOLD);
365
274
  }
366
- straightEdge(edge, nodeById) {
367
- const source = nodeById.get(edge.sources?.[0] ?? '');
368
- const target = nodeById.get(edge.targets?.[0] ?? '');
369
- if (!source || !target)
370
- return [];
371
- return [
372
- { x: source.x + source.width / 2, y: source.y + source.height / 2 },
373
- { x: target.x + target.width / 2, y: target.y + target.height / 2 },
374
- ];
275
+ /** One ELK run over the whole nested graph (`INCLUDE_CHILDREN`). */
276
+ async hierarchicalLayout(model, layers, partitions) {
277
+ const graph = buildElkGraph(model, layers, partitions, {
278
+ edgeRouting: this.options.edgeRouting,
279
+ mergeEdges: this.options.mergeEdges,
280
+ aspectRatio: this.options.aspectRatio,
281
+ elk: this.options.elk,
282
+ });
283
+ return mapElkResult(model, await this.elk.layout(graph));
375
284
  }
376
285
  }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Fan-out trunks. A node that sends (or receives) many edges the same way gets
3
+ * a "comb" from the routers: every edge takes its own port and its own bend
4
+ * level, so a dozen nested right-angle staircases run side by side. This pass
5
+ * redraws such a group as a tree: one shared trunk leaves the node, runs to a
6
+ * common branch level, and a shared bus there fans out to short drops that end
7
+ * at each target. Edges stay orthogonal and keep their endpoints' sides; a
8
+ * group is only rewritten when the new geometry stays clear of every other node.
9
+ *
10
+ * Only edges shaped "out, across, onward" (first and third segments along the
11
+ * same direction, the second across) take part — the shape both ELK and the
12
+ * grid router give a plain fan-out. Anything else in the group is left alone.
13
+ */
14
+ import { LayoutResult } from '../foundation/layout.js';
15
+ import { PlantUmlModel } from '../foundation/model.js';
16
+ /** Edges leaving (or entering) one side that make a group worth merging. */
17
+ export declare const TRUNK_MIN_EDGES = 5;
18
+ export declare function mergeFanoutTrunks(model: PlantUmlModel, layout: LayoutResult): LayoutResult;