@yalehwang/plantuml-js 0.2.0 → 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 +9 -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
package/dist/layout/elk.js
CHANGED
|
@@ -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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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 {
|
|
38
|
-
import {
|
|
38
|
+
import { alignRows, alignIsolatedFrames } from '../foundation/layout.js';
|
|
39
|
+
import { compoundLayout } from './compound.js';
|
|
40
|
+
import { computeLayoutMetrics, computeMaxEmptyBlockRatio } from './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
|
-
*
|
|
59
|
-
*
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
/**
|
|
123
|
-
function
|
|
124
|
-
return
|
|
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,123 @@ export class ElkLayoutEngine {
|
|
|
130
148
|
this.options = options;
|
|
131
149
|
}
|
|
132
150
|
async layout(model) {
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
})
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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 finish = (raw) => fitCanvas(liftFlushSegments(
|
|
152
|
+
// stub views price crossings high on purpose: their rail edges are not un-coiled
|
|
153
|
+
(model.entities.some((e) => e.stub) ? (l) => l : uncoilWraps)(straightenTrunkDetours(mergeFanoutTrunks(model, spreadParallelLanes(clipPackageEndpoints(model, alignIsolatedFrames(model, alignRows(model, raw)))))))));
|
|
154
|
+
if (!model.entities.some((e) => e.stub))
|
|
155
|
+
return finish((await this.layoutRaw(model)).layout);
|
|
156
|
+
// Where the orbiting stubs sit decides how the router weaves their edges
|
|
157
|
+
// through the frame; the outcome is chaotic enough that a handful of
|
|
158
|
+
// orbits is tried and the least cluttered picture kept. The trials skip the
|
|
159
|
+
// router's rip-up passes (the slow part); only the winner is routed in full.
|
|
160
|
+
const first = await this.layoutRaw(model, { quick: true });
|
|
161
|
+
if (!first.orbited)
|
|
162
|
+
return finish(first.layout);
|
|
163
|
+
let bestCost = stubViewCost(model, finish(first.layout));
|
|
164
|
+
let bestOrbit;
|
|
165
|
+
for (const orbit of STUB_ORBITS) {
|
|
166
|
+
if (bestCost <= STUB_GOOD_ENOUGH)
|
|
167
|
+
break;
|
|
168
|
+
const trial = await this.layoutRaw(model, { orbit, quick: true });
|
|
169
|
+
const cost = stubViewCost(model, finish(trial.layout));
|
|
170
|
+
if (cost < bestCost) {
|
|
171
|
+
bestCost = cost;
|
|
172
|
+
bestOrbit = orbit;
|
|
241
173
|
}
|
|
242
174
|
}
|
|
243
|
-
return
|
|
175
|
+
return finish((await this.layoutRaw(model, { orbit: bestOrbit })).layout);
|
|
244
176
|
}
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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);
|
|
177
|
+
async layoutRaw(model, run = {}) {
|
|
178
|
+
const layers = resolveLayerOrder(model, this.options.layerOrder);
|
|
179
|
+
const partitions = assignPartitions(model, layers);
|
|
180
|
+
const injected = await runStrategies(this.options.strategies, model, { layers, partitions });
|
|
181
|
+
if (injected !== undefined)
|
|
182
|
+
return { layout: injected, orbited: false };
|
|
183
|
+
let flat;
|
|
184
|
+
const raw = (layout) => ({ layout, orbited: false });
|
|
185
|
+
// A large package-less graph has no blocks to pack: the compound layout
|
|
186
|
+
// degenerates into one block per entity, which is an order of magnitude
|
|
187
|
+
// slower and no better proportioned than the single layered run (measured:
|
|
188
|
+
// 789 flat nodes — 1.7s/608 crossings layered vs 40s/13625 compound). It
|
|
189
|
+
// also has no folded layer sequence, so a requested aspectRatio cannot
|
|
190
|
+
// reshape it either; take the layered run unconditionally instead.
|
|
191
|
+
if (model.packages.length === 0 && model.entities.length > COMPOUND_ENTITY_THRESHOLD) {
|
|
192
|
+
return raw(await this.hierarchicalLayout(model, layers, partitions));
|
|
275
193
|
}
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
194
|
+
// Very large graphs skip the single hierarchical run: it cannot keep the
|
|
195
|
+
// canvas proportioned, so it would only be thrown away.
|
|
196
|
+
const target = this.options.aspectRatio;
|
|
197
|
+
if (target !== undefined && target > 0)
|
|
198
|
+
return this.layoutToward(model, layers, partitions, target, run);
|
|
199
|
+
if (model.entities.length <= COMPOUND_ENTITY_THRESHOLD || model.packages.length === 0) {
|
|
200
|
+
flat = await this.hierarchicalLayout(model, layers, partitions);
|
|
201
|
+
if (isProportionate(flat) && flat.width <= MAX_WIDTH && !this.isGappyMidScale(model, flat))
|
|
202
|
+
return raw(flat);
|
|
285
203
|
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
204
|
+
const compound = await this.compound(model, layers, partitions, run);
|
|
205
|
+
const packed = compound.layout;
|
|
206
|
+
if (flat !== undefined && this.isGappyMidScale(model, flat) && isProportionate(flat) && !isColumn(packed)
|
|
207
|
+
&& computeMaxEmptyBlockRatio(packed) >= computeMaxEmptyBlockRatio(flat))
|
|
208
|
+
return raw(flat);
|
|
209
|
+
// A flat run kept only for being too wide gives way to the compound layout
|
|
210
|
+
// when that one fits the width budget without degenerating into a tall
|
|
211
|
+
// sliver; otherwise the wide-but-balanced flat run is the better picture.
|
|
212
|
+
// A flat run that degenerated into a single column (aspect under the
|
|
213
|
+
// preferred minimum) is never worth keeping over the packed blocks.
|
|
214
|
+
if (flat !== undefined && isProportionate(flat) && !isNarrowEnough(packed) && !isColumn(flat))
|
|
215
|
+
return raw(flat);
|
|
216
|
+
return compound;
|
|
291
217
|
}
|
|
292
218
|
/**
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
* entity can leave the frame narrower than its label tab. Widen those frames
|
|
296
|
-
* in place (never shrink) so the tab stays readable.
|
|
219
|
+
* A mid-scale root layer (many top-level packages) whose single hierarchical
|
|
220
|
+
* run left a large hole: the packed compound blocks are tried instead.
|
|
297
221
|
*/
|
|
298
|
-
|
|
299
|
-
const
|
|
300
|
-
|
|
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
|
-
}
|
|
222
|
+
isGappyMidScale(model, flat) {
|
|
223
|
+
const roots = model.packages.filter((p) => p.parentId === undefined).length;
|
|
224
|
+
return roots >= MID_ROOT_PACKAGES && computeMaxEmptyBlockRatio(flat) > MID_MAX_EMPTY_BLOCK;
|
|
308
225
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
});
|
|
226
|
+
/**
|
|
227
|
+
* Caller-requested canvas ratio: run both strategies and keep whichever lands
|
|
228
|
+
* closer (in log distance) to the target. The default path never gets here.
|
|
229
|
+
*/
|
|
230
|
+
async layoutToward(model, layers, partitions, target, run) {
|
|
231
|
+
const compound = await this.compound(model, layers, partitions, run);
|
|
232
|
+
const candidates = [compound.layout];
|
|
233
|
+
if (model.entities.length <= COMPOUND_ENTITY_THRESHOLD || model.packages.length === 0) {
|
|
234
|
+
candidates.push(await this.hierarchicalLayout(model, layers, partitions));
|
|
333
235
|
}
|
|
236
|
+
const miss = (l) => l.width > 0 && l.height > 0 ? Math.abs(Math.log(l.width / l.height / target)) : 0;
|
|
237
|
+
const winner = candidates.reduce((best, c) => (miss(c) < miss(best) ? c : best));
|
|
238
|
+
return { layout: winner, orbited: true };
|
|
334
239
|
}
|
|
335
|
-
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
}
|
|
240
|
+
async compound(model, layers, partitions, { orbit, quick }) {
|
|
241
|
+
const dense = model.entities.length > COMPOUND_ENTITY_THRESHOLD;
|
|
242
|
+
const run = (rectPackDense) => compoundLayout(this.elk, model, {
|
|
243
|
+
edgeRouting: this.options.edgeRouting,
|
|
244
|
+
aspectRatio: this.options.aspectRatio,
|
|
245
|
+
elk: this.options.elk,
|
|
246
|
+
dense,
|
|
247
|
+
rectPackDense,
|
|
248
|
+
partitions: layers.length > 0 ? partitions : new Map(),
|
|
249
|
+
stubOrbit: orbit,
|
|
250
|
+
ripupPasses: quick ? 0 : undefined,
|
|
251
|
+
});
|
|
252
|
+
const ordered = await run(false);
|
|
253
|
+
// Dependency-ordered rows cost canvas on very large graphs; past the budget
|
|
254
|
+
// fall back to the tighter (order-blind) rect packing.
|
|
255
|
+
if (dense && ordered.width * ordered.height > COMPOUND_AREA_BUDGET) {
|
|
256
|
+
return { layout: await run(true), orbited: true };
|
|
353
257
|
}
|
|
354
|
-
return
|
|
258
|
+
return { layout: ordered, orbited: true };
|
|
355
259
|
}
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
return
|
|
365
|
-
}
|
|
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
|
-
];
|
|
260
|
+
/** One ELK run over the whole nested graph (`INCLUDE_CHILDREN`). */
|
|
261
|
+
async hierarchicalLayout(model, layers, partitions) {
|
|
262
|
+
const graph = buildElkGraph(model, layers, partitions, {
|
|
263
|
+
edgeRouting: this.options.edgeRouting,
|
|
264
|
+
mergeEdges: this.options.mergeEdges,
|
|
265
|
+
aspectRatio: this.options.aspectRatio,
|
|
266
|
+
elk: this.options.elk,
|
|
267
|
+
});
|
|
268
|
+
return mapElkResult(model, await this.elk.layout(graph));
|
|
375
269
|
}
|
|
376
270
|
}
|
|
@@ -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;
|