@yalehwang/plantuml-js 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +20 -0
  2. package/dist/core.d.ts +102 -9
  3. package/dist/core.js +91 -51
  4. package/dist/foundation/layers.d.ts +25 -0
  5. package/dist/foundation/layers.js +101 -0
  6. package/dist/foundation/layout.d.ts +116 -0
  7. package/dist/foundation/layout.js +298 -0
  8. package/dist/{model.d.ts → foundation/model.d.ts} +20 -0
  9. package/dist/foundation/relation-semantics.d.ts +49 -0
  10. package/dist/foundation/relation-semantics.js +112 -0
  11. package/dist/{text-measure.d.ts → foundation/text-measure.d.ts} +2 -0
  12. package/dist/{text-measure.js → foundation/text-measure.js} +20 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.js +1 -1
  15. package/dist/layout/compound.d.ts +59 -0
  16. package/dist/layout/compound.js +1178 -0
  17. package/dist/layout/elk-graph.d.ts +36 -0
  18. package/dist/layout/elk-graph.js +220 -0
  19. package/dist/layout/elk-result.d.ts +18 -0
  20. package/dist/layout/elk-result.js +127 -0
  21. package/dist/layout/elk.d.ts +63 -33
  22. package/dist/layout/elk.js +215 -321
  23. package/dist/layout/fanout-trunk.d.ts +18 -0
  24. package/dist/layout/fanout-trunk.js +380 -0
  25. package/dist/layout/grid-router.d.ts +69 -0
  26. package/dist/layout/grid-router.js +615 -0
  27. package/dist/layout/lane-spread.d.ts +11 -0
  28. package/dist/layout/lane-spread.js +302 -0
  29. package/dist/layout/layout-metrics.d.ts +183 -0
  30. package/dist/layout/layout-metrics.js +766 -0
  31. package/dist/layout/package-endpoints.d.ts +10 -0
  32. package/dist/layout/package-endpoints.js +54 -0
  33. package/dist/layout/placement-strategy.d.ts +98 -0
  34. package/dist/layout/placement-strategy.js +26 -0
  35. package/dist/layout/strategy.d.ts +27 -0
  36. package/dist/layout/strategy.js +19 -0
  37. package/dist/layout/trunk-lanes.d.ts +29 -0
  38. package/dist/layout/trunk-lanes.js +308 -0
  39. package/dist/layout/viz.d.ts +3 -3
  40. package/dist/layout/viz.js +13 -17
  41. package/dist/parser.d.ts +27 -1
  42. package/dist/parser.js +111 -5
  43. package/dist/render/labels.d.ts +44 -0
  44. package/dist/render/labels.js +231 -0
  45. package/dist/render/legend.d.ts +15 -0
  46. package/dist/render/legend.js +42 -0
  47. package/dist/render/svg.d.ts +60 -3
  48. package/dist/render/svg.js +326 -98
  49. package/dist/render/theme.d.ts +109 -0
  50. package/dist/render/theme.js +99 -0
  51. package/dist/serialize.d.ts +32 -0
  52. package/dist/serialize.js +218 -0
  53. package/dist/transform.d.ts +55 -1
  54. package/dist/transform.js +277 -29
  55. package/package.json +4 -3
  56. package/dist/layout-metrics.d.ts +0 -42
  57. package/dist/layout-metrics.js +0 -241
  58. package/dist/layout.d.ts +0 -75
  59. package/dist/layout.js +0 -51
  60. /package/dist/{edge-router.d.ts → foundation/edge-router.d.ts} +0 -0
  61. /package/dist/{edge-router.js → foundation/edge-router.js} +0 -0
  62. /package/dist/{model.js → foundation/model.js} +0 -0
  63. /package/dist/{render-dot.d.ts → foundation/render-dot.d.ts} +0 -0
  64. /package/dist/{render-dot.js → foundation/render-dot.js} +0 -0
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Model → ELK graph construction for the ELK engine: package containers,
3
+ * entity leaves, packed boxes for unconnected entities, and the layout-direction
4
+ * helpers for edges. Pure functions over the model.
5
+ */
6
+ import type { ElkNode } from 'elkjs';
7
+ import { PlantUmlModel, PlantUmlEntity, PlantUmlRelation } from '../foundation/model.js';
8
+ export declare const PACKAGE_PREFIX = "pkg::";
9
+ /** Id prefix of the invisible container that packs a package's unconnected entities. */
10
+ export declare const ISOLATED_PREFIX = "iso::";
11
+ /** Minimum frame width that keeps the package label tab readable. */
12
+ export declare function packageMinWidth(name: string): number;
13
+ /** Source endpoint of the edge *as laid out* (reversed for inheritance/implementation). */
14
+ export declare function layoutSource(relation: PlantUmlRelation): string;
15
+ /** Target endpoint of the edge *as laid out* (reversed for inheritance/implementation). */
16
+ export declare function layoutTarget(relation: PlantUmlRelation): string;
17
+ /** True when any relation connects entities that live in different containers. */
18
+ export declare function hasContainerCrossingEdge(model: PlantUmlModel): boolean;
19
+ export declare function entityLeaf(entity: PlantUmlEntity): ElkNode;
20
+ /**
21
+ * One ELK node's `layoutOptions`, weakest first: built-in defaults, then the
22
+ * caller's raw `elk` escape-hatch keys (they override our defaults), then the
23
+ * named options the caller explicitly set (they win any key collision).
24
+ */
25
+ export declare function mergeElkOptions(defaults: Record<string, string>, named: Record<string, string>, raw: Record<string, string> | undefined): Record<string, string>;
26
+ export declare function buildChildren(model: PlantUmlModel, partitions: Map<string, number>, rawElk?: Record<string, string>): ElkNode[];
27
+ export interface ElkGraphOptions {
28
+ /** Edge routing mode; omitted means the built-in ORTHOGONAL default. */
29
+ edgeRouting?: string;
30
+ mergeEdges?: boolean;
31
+ aspectRatio?: number;
32
+ /** Raw ELK options injected into the root and every package container (escape hatch). */
33
+ elk?: Record<string, string>;
34
+ }
35
+ /** The root ELK graph for one hierarchical run over the whole nested model. */
36
+ export declare function buildElkGraph(model: PlantUmlModel, layers: string[], partitions: Map<string, number>, options: ElkGraphOptions): ElkNode;
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Model → ELK graph construction for the ELK engine: package containers,
3
+ * entity leaves, packed boxes for unconnected entities, and the layout-direction
4
+ * helpers for edges. Pure functions over the model.
5
+ */
6
+ import { PACKAGE_LABEL, estimateEntitySize } from '../foundation/layout.js';
7
+ import { measureTextWidth } from '../foundation/text-measure.js';
8
+ import { relationSemantics } from '../foundation/relation-semantics.js';
9
+ export const PACKAGE_PREFIX = 'pkg::';
10
+ /** Id prefix of the invisible container that packs a package's unconnected entities. */
11
+ export const ISOLATED_PREFIX = 'iso::';
12
+ /** Unconnected entities in one container before they are packed into a grid... */
13
+ const ISOLATED_PACK_MIN = 3;
14
+ /** ...and only once laid side by side they would be wider than this (px). */
15
+ const ISOLATED_ROW_MAX_WIDTH = 2000;
16
+ /** Minimum frame width that keeps the package label tab readable. */
17
+ export function packageMinWidth(name) {
18
+ return measureTextWidth(name, PACKAGE_LABEL.fontSize) + PACKAGE_LABEL.tabPadding + 20;
19
+ }
20
+ /** Source endpoint of the edge *as laid out* (reversed for inheritance/implementation). */
21
+ export function layoutSource(relation) {
22
+ return relationSemantics(relation.type).isReversed() ? relation.target : relation.source;
23
+ }
24
+ /** Target endpoint of the edge *as laid out* (reversed for inheritance/implementation). */
25
+ export function layoutTarget(relation) {
26
+ return relationSemantics(relation.type).isReversed() ? relation.source : relation.target;
27
+ }
28
+ /** True when any relation connects entities that live in different containers. */
29
+ export function hasContainerCrossingEdge(model) {
30
+ const packageOf = new Map(model.entities.map((e) => [e.id, e.packageId]));
31
+ return model.relations.some((r) => packageOf.get(r.source) !== packageOf.get(r.target));
32
+ }
33
+ export function entityLeaf(entity) {
34
+ const size = estimateEntitySize(entity);
35
+ return { id: entity.id, width: size.width, height: size.height };
36
+ }
37
+ /** Packages with at least this many direct members are laid out top-down. */
38
+ const FLOW_DOWN_MIN_MEMBERS = 12;
39
+ /**
40
+ * One ELK node's `layoutOptions`, weakest first: built-in defaults, then the
41
+ * caller's raw `elk` escape-hatch keys (they override our defaults), then the
42
+ * named options the caller explicitly set (they win any key collision).
43
+ */
44
+ export function mergeElkOptions(defaults, named, raw) {
45
+ return raw === undefined ? { ...defaults, ...named } : { ...defaults, ...raw, ...named };
46
+ }
47
+ export function buildChildren(model, partitions, rawElk) {
48
+ const pkgNodes = new Map();
49
+ const memberCount = new Map(model.packages.map((p) => [p.id, 0]));
50
+ for (const entity of model.entities) {
51
+ if (entity.packageId !== undefined && memberCount.has(entity.packageId)) {
52
+ memberCount.set(entity.packageId, memberCount.get(entity.packageId) + 1);
53
+ }
54
+ }
55
+ for (const pkg of model.packages) {
56
+ // Padding at the top keeps the package label tab clear of the first
57
+ // entity; the minimum width keeps the tab wide enough for the label when
58
+ // a package holds only a single short-named entity.
59
+ const minWidth = packageMinWidth(pkg.name);
60
+ const minHeight = PACKAGE_LABEL.tabHeight + 26 + 10; // tab + top/bottom padding
61
+ const defaults = {
62
+ 'elk.padding': '[top=26,left=10,bottom=10,right=10]',
63
+ 'elk.nodeSize.constraints': '[MINIMUM_SIZE]',
64
+ 'elk.nodeSize.minimum': `[${minWidth.toFixed(1)}, ${minHeight.toFixed(1)}]`,
65
+ };
66
+ // A package's interior is laid out on its own (INHERIT hierarchy) and would
67
+ // fall back to ELK's default RIGHT, turning the dependency flow sideways.
68
+ // Big packages read top-down like the rest of the diagram; small ones stay compact.
69
+ if (memberCount.get(pkg.id) >= FLOW_DOWN_MIN_MEMBERS)
70
+ defaults['elk.direction'] = 'DOWN';
71
+ // The semantic-layer partition is named-option territory (layerOrder / "X Layer"
72
+ // packages), so it beats a same-key raw `elk` value.
73
+ const named = {};
74
+ const partition = partitions.get(pkg.id);
75
+ if (partition !== undefined)
76
+ named['elk.partitioning.partition'] = String(partition);
77
+ pkgNodes.set(pkg.id, {
78
+ id: PACKAGE_PREFIX + pkg.id,
79
+ children: [],
80
+ layoutOptions: mergeElkOptions(defaults, named, rawElk),
81
+ });
82
+ }
83
+ const connected = new Set();
84
+ for (const relation of model.relations) {
85
+ connected.add(relation.source);
86
+ connected.add(relation.target);
87
+ }
88
+ // Unconnected entities of one container are packed into a balanced grid by
89
+ // an invisible sub-container; left alone, the layered algorithm lines them
90
+ // all up in a single row and stretches the whole canvas.
91
+ const isolatedByOwner = new Map();
92
+ const ownerKey = (entity) => entity.packageId && pkgNodes.has(entity.packageId) ? entity.packageId : '';
93
+ for (const entity of model.entities) {
94
+ if (connected.has(entity.id))
95
+ continue;
96
+ const key = ownerKey(entity);
97
+ const list = isolatedByOwner.get(key);
98
+ if (list)
99
+ list.push(entity);
100
+ else
101
+ isolatedByOwner.set(key, [entity]);
102
+ }
103
+ const packed = new Set();
104
+ const groups = new Map();
105
+ for (const [key, list] of isolatedByOwner) {
106
+ if (list.length < ISOLATED_PACK_MIN)
107
+ continue;
108
+ const rowWidth = list.reduce((sum, e) => sum + estimateEntitySize(e).width + 40, 0);
109
+ if (rowWidth <= ISOLATED_ROW_MAX_WIDTH)
110
+ continue;
111
+ const layoutOptions = {
112
+ 'elk.algorithm': 'rectpacking',
113
+ 'elk.hierarchyHandling': 'SEPARATE_CHILDREN',
114
+ 'elk.aspectRatio': '1.6',
115
+ 'elk.spacing.nodeNode': '20',
116
+ 'elk.padding': '[top=0,left=0,bottom=0,right=0]',
117
+ };
118
+ const partition = key === '' ? undefined : partitions.get(key);
119
+ if (partition !== undefined)
120
+ layoutOptions['elk.partitioning.partition'] = String(partition);
121
+ groups.set(key, {
122
+ id: ISOLATED_PREFIX + key,
123
+ children: list.map((e) => entityLeaf(e)),
124
+ layoutOptions,
125
+ });
126
+ for (const e of list)
127
+ packed.add(e.id);
128
+ }
129
+ const top = [];
130
+ for (const [key, group] of groups) {
131
+ if (key === '')
132
+ top.push(group);
133
+ else
134
+ pkgNodes.get(key).children.push(group);
135
+ }
136
+ for (const entity of model.entities) {
137
+ if (packed.has(entity.id))
138
+ continue;
139
+ const leaf = entityLeaf(entity);
140
+ const owner = entity.packageId ? pkgNodes.get(entity.packageId) : undefined;
141
+ if (owner)
142
+ owner.children.push(leaf);
143
+ else
144
+ top.push(leaf);
145
+ }
146
+ // Link nested packages into their parents; packages without a parent (or
147
+ // whose parent is unknown) sit at the top level.
148
+ for (const pkg of model.packages) {
149
+ const node = pkgNodes.get(pkg.id);
150
+ const parent = pkg.parentId ? pkgNodes.get(pkg.parentId) : undefined;
151
+ if (parent)
152
+ parent.children.push(node);
153
+ else
154
+ top.push(node);
155
+ }
156
+ return top;
157
+ }
158
+ /** The root ELK graph for one hierarchical run over the whole nested model. */
159
+ export function buildElkGraph(model, layers, partitions, options) {
160
+ const partitionsActive = layers.length > 0;
161
+ // Memberless one-line boxes (overview / grouped / stub-only graphs) are tiny
162
+ // next to the default gaps, which leaves the canvas mostly empty.
163
+ const compact = model.packages.length === 0 && model.entities.every((e) => e.members.length === 0);
164
+ const children = buildChildren(model, partitions, options.elk);
165
+ const edges = model.relations.map((relation, i) => ({
166
+ id: `rel-${i}`,
167
+ sources: [layoutSource(relation)],
168
+ targets: [layoutTarget(relation)],
169
+ }));
170
+ const defaults = {
171
+ 'elk.algorithm': 'layered',
172
+ 'elk.direction': 'DOWN',
173
+ 'elk.edgeRouting': 'ORTHOGONAL',
174
+ 'elk.spacing.nodeNode': compact ? '12' : '20',
175
+ 'elk.layered.spacing.nodeNodeBetweenLayers': compact ? '24' : '24',
176
+ // Ports keep a fixed side per edge, so same-direction edges fan out across
177
+ // one node edge instead of scattering onto every side (or one point).
178
+ 'elk.layered.portConstraints': 'FIXED_SIDE',
179
+ // Keep wires off node sides and off each other (see layout-metrics edgeFlushCount).
180
+ 'elk.spacing.edgeNode': '12',
181
+ 'elk.spacing.edgeEdge': '10',
182
+ // Soft targets: pack disconnected components into a balanced rectangle
183
+ // instead of a long horizontal strip. (hierarchyHandling stays INHERIT
184
+ // unless cross-package edges exist — see the header comment.)
185
+ 'elk.aspectRatio': '1.6',
186
+ 'elk.separateConnectedComponents': 'true',
187
+ // INTERACTIVE placement keeps each layer's boxes centred on the widest
188
+ // partition instead of fanning them out sideways, which is what lets a
189
+ // topologically-stacked package diagram stay compact (see
190
+ // docs/layout-routing-eval.md).
191
+ 'elk.layered.nodePlacement.strategy': 'INTERACTIVE',
192
+ };
193
+ // Values that come from an explicitly-set named option: these beat a raw
194
+ // `elk` key of the same name (documented precedence).
195
+ const named = {};
196
+ if (options.edgeRouting !== undefined)
197
+ named['elk.edgeRouting'] = options.edgeRouting;
198
+ if (options.aspectRatio !== undefined)
199
+ named['elk.aspectRatio'] = String(options.aspectRatio);
200
+ if (options.mergeEdges)
201
+ named['elk.layered.mergeEdges'] = 'true';
202
+ if (partitionsActive)
203
+ named['elk.partitioning.activate'] = 'true';
204
+ if (compact) {
205
+ defaults['elk.layered.nodePlacement.strategy'] = 'NETWORK_SIMPLEX';
206
+ defaults['elk.layered.spacing.edgeEdgeBetweenLayers'] = '5';
207
+ defaults['elk.layered.spacing.edgeNodeBetweenLayers'] = '12';
208
+ }
209
+ // Every edge that crosses a container boundary (package↔package or
210
+ // package↔top-level stub) needs INCLUDE_CHILDREN to get a routing section.
211
+ if (hasContainerCrossingEdge(model)) {
212
+ defaults['elk.hierarchyHandling'] = 'INCLUDE_CHILDREN';
213
+ }
214
+ return {
215
+ id: 'root',
216
+ layoutOptions: mergeElkOptions(defaults, named, options.elk),
217
+ children,
218
+ edges,
219
+ };
220
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * ELK result → `LayoutResult` mapping for the ELK engine: flatten the nested
3
+ * node tree into absolute boxes and turn edge sections into point lists.
4
+ */
5
+ import type { ElkNode, ElkExtendedEdge } from 'elkjs';
6
+ import { PlantUmlModel } from '../foundation/model.js';
7
+ import { LayoutResult, NodeLayout, PackageLayout, Point } from '../foundation/layout.js';
8
+ /**
9
+ * INCLUDE_CHILDREN sizes a container from its content and does not honour the
10
+ * `elk.nodeSize.minimum` we give package frames, so a single short-named
11
+ * entity can leave the frame narrower than its label tab. Widen those frames
12
+ * in place (never shrink) so the tab stays readable.
13
+ */
14
+ export declare function enforcePackageMinWidth(model: PlantUmlModel, packages: PackageLayout[]): void;
15
+ export declare function flatten(node: ElkNode, offsetX: number, offsetY: number, nodes: NodeLayout[], packages: PackageLayout[], offsets: Map<string, Point>): void;
16
+ export declare function edgePoints(edge: ElkExtendedEdge, nodeById: Map<string, NodeLayout>, offsets: Map<string, Point>): Point[];
17
+ /** Map a laid-out ELK graph back to the model's `LayoutResult`. */
18
+ export declare function mapElkResult(model: PlantUmlModel, result: ElkNode): LayoutResult;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * ELK result → `LayoutResult` mapping for the ELK engine: flatten the nested
3
+ * node tree into absolute boxes and turn edge sections into point lists.
4
+ */
5
+ import { PACKAGE_PREFIX, ISOLATED_PREFIX, packageMinWidth, } from './elk-graph.js';
6
+ import { relationSemantics } from '../foundation/relation-semantics.js';
7
+ /**
8
+ * INCLUDE_CHILDREN sizes a container from its content and does not honour the
9
+ * `elk.nodeSize.minimum` we give package frames, so a single short-named
10
+ * entity can leave the frame narrower than its label tab. Widen those frames
11
+ * in place (never shrink) so the tab stays readable.
12
+ */
13
+ export function enforcePackageMinWidth(model, packages) {
14
+ const byId = new Map(packages.map((p) => [p.id, p]));
15
+ for (const pkg of model.packages) {
16
+ const box = byId.get(pkg.id);
17
+ if (!box)
18
+ continue;
19
+ const min = packageMinWidth(pkg.name);
20
+ if (box.width < min)
21
+ box.width = min;
22
+ }
23
+ }
24
+ export function flatten(node, offsetX, offsetY, nodes, packages, offsets) {
25
+ const x = (node.x ?? 0) + offsetX;
26
+ const y = (node.y ?? 0) + offsetY;
27
+ offsets.set(node.id, { x, y });
28
+ if (node.id.startsWith(PACKAGE_PREFIX)) {
29
+ packages.push({
30
+ id: node.id.slice(PACKAGE_PREFIX.length),
31
+ x,
32
+ y,
33
+ width: node.width ?? 0,
34
+ height: node.height ?? 0,
35
+ });
36
+ for (const child of node.children ?? []) {
37
+ flatten(child, x, y, nodes, packages, offsets);
38
+ }
39
+ }
40
+ else if (node.id.startsWith(ISOLATED_PREFIX)) {
41
+ for (const child of node.children ?? []) {
42
+ flatten(child, x, y, nodes, packages, offsets);
43
+ }
44
+ }
45
+ else {
46
+ nodes.push({
47
+ id: node.id,
48
+ x,
49
+ y,
50
+ width: node.width ?? 0,
51
+ height: node.height ?? 0,
52
+ });
53
+ }
54
+ }
55
+ export function edgePoints(edge, nodeById, offsets) {
56
+ const sections = edge.sections ?? [];
57
+ if (sections.length === 0) {
58
+ return straightEdge(edge, nodeById);
59
+ }
60
+ const containerOffset = offsets.get(edge.container ?? 'root') ?? {
61
+ x: 0,
62
+ y: 0,
63
+ };
64
+ const points = [];
65
+ for (const section of sections) {
66
+ for (const p of sectionPoints(section)) {
67
+ const shifted = { x: p.x + containerOffset.x, y: p.y + containerOffset.y };
68
+ const last = points[points.length - 1];
69
+ if (!last || Math.abs(last.x - shifted.x) > 0.01 || Math.abs(last.y - shifted.y) > 0.01) {
70
+ points.push(shifted);
71
+ }
72
+ }
73
+ }
74
+ return points.length >= 2 ? points : straightEdge(edge, nodeById);
75
+ }
76
+ function sectionPoints(section) {
77
+ const pts = [];
78
+ if (section.startPoint)
79
+ pts.push(section.startPoint);
80
+ for (const b of section.bendPoints ?? [])
81
+ pts.push(b);
82
+ if (section.endPoint)
83
+ pts.push(section.endPoint);
84
+ return pts;
85
+ }
86
+ function straightEdge(edge, nodeById) {
87
+ const source = nodeById.get(edge.sources?.[0] ?? '');
88
+ const target = nodeById.get(edge.targets?.[0] ?? '');
89
+ if (!source || !target)
90
+ return [];
91
+ return [
92
+ { x: source.x + source.width / 2, y: source.y + source.height / 2 },
93
+ { x: target.x + target.width / 2, y: target.y + target.height / 2 },
94
+ ];
95
+ }
96
+ /** Map a laid-out ELK graph back to the model's `LayoutResult`. */
97
+ export function mapElkResult(model, result) {
98
+ const nodes = [];
99
+ const packages = [];
100
+ const offsets = new Map([['root', { x: 0, y: 0 }]]);
101
+ for (const child of result.children ?? []) {
102
+ flatten(child, 0, 0, nodes, packages, offsets);
103
+ }
104
+ enforcePackageMinWidth(model, packages);
105
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
106
+ const laidEdges = (result.edges ?? []).map((edge, i) => {
107
+ const relation = model.relations[i];
108
+ const reversed = relation !== undefined && relationSemantics(relation.type).isReversed();
109
+ const points = edgePoints(edge, nodeById, offsets);
110
+ return {
111
+ id: edge.id ?? `rel-${i}`,
112
+ // Keep the model's direction (not the possibly-reversed layout
113
+ // direction) so the renderer's arrowheads and the overlap counter both
114
+ // see the endpoints the author wrote.
115
+ source: relation?.source ?? (edge.sources?.[0] ?? ''),
116
+ target: relation?.target ?? (edge.targets?.[0] ?? ''),
117
+ points: reversed ? points.slice().reverse() : points,
118
+ };
119
+ });
120
+ return {
121
+ nodes,
122
+ edges: laidEdges,
123
+ packages,
124
+ width: result.width ?? 0,
125
+ height: result.height ?? 0,
126
+ };
127
+ }
@@ -21,21 +21,23 @@
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 type { ElkPoint } from 'elkjs';
37
- import { PlantUmlModel } from '../model.js';
38
- import { LayoutEngine, LayoutResult } from '../layout.js';
38
+ import { PlantUmlModel } from '../foundation/model.js';
39
+ import { LayoutEngine, LayoutResult } from '../foundation/layout.js';
40
+ import { type LayoutStrategy } from './strategy.js';
39
41
  /** Edge routing strategies, evaluated in docs/layout-routing-eval.md. */
40
42
  export type EdgeRoutingMode = 'ORTHOGONAL' | 'POLYLINE' | 'SPLINES';
41
43
  /** Tuning knobs for the ELK engine. All are optional with sensible defaults. */
@@ -54,34 +56,62 @@ export interface ElkLayoutOptions {
54
56
  * into a weighted edge; default false.
55
57
  */
56
58
  mergeEdges?: boolean;
59
+ /**
60
+ * Target canvas width/height ratio (`elk.aspectRatio`), e.g. 16/9 for a
61
+ * desktop panel or 9/16 for a portrait phone. Default 1.6.
62
+ */
63
+ aspectRatio?: number;
64
+ /**
65
+ * Extra layout strategies tried, in order, before the built-in decision
66
+ * chain; the first to return a result wins (see [[./strategy]]).
67
+ */
68
+ strategies?: LayoutStrategy[];
69
+ /**
70
+ * Raw ELK options (`elk.*` keys), the escape hatch for knobs that have no
71
+ * named option here — e.g.
72
+ * `{ 'elk.layered.layering.strategy': 'COFFMAN_GRAHAM',
73
+ * 'elk.layered.layering.coffmanGraham.layerBound': '2' }` to cap how many
74
+ * nodes sit side by side per layer, which a plain `aspectRatio` cannot do.
75
+ *
76
+ * Every key is injected into the root graph *and* every package container
77
+ * node, recursively, so nested packages honour it too. Values override the
78
+ * engine's built-in defaults, but an explicitly-set named option wins a
79
+ * same-key collision (`aspectRatio` over `elk.aspectRatio`, `edgeRouting`
80
+ * over `elk.edgeRouting`, `mergeEdges` over `elk.layered.mergeEdges`).
81
+ *
82
+ * Known elkjs crash: `INCLUDE_CHILDREN` + `COFFMAN_GRAHAM` +
83
+ * `coffmanGraham.layerBound=2` + `elk.aspectRatio=1.6` throws
84
+ * `TypeError: Cannot read properties of undefined (reading 'c')`; steer clear
85
+ * of that exact combination (bound=2 with a different ratio is fine).
86
+ */
87
+ elk?: Record<string, string>;
57
88
  }
89
+ /** Clear space kept between any edge point and the canvas side. */
90
+ export declare const CANVAS_EDGE_MARGIN = 16;
91
+ /**
92
+ * Translate and grow the canvas so no edge point (or node/frame) sits closer
93
+ * than [[CANVAS_EDGE_MARGIN]] to a canvas side — a line hugging the border is
94
+ * easily cropped.
95
+ */
96
+ export declare function fitCanvas(layout: LayoutResult): LayoutResult;
58
97
  export declare class ElkLayoutEngine implements LayoutEngine {
59
98
  private readonly elk;
60
99
  private readonly options;
61
100
  constructor(options?: ElkLayoutOptions);
62
101
  layout(model: PlantUmlModel): Promise<LayoutResult>;
63
- /** Source endpoint of the edge *as laid out* (reversed for inheritance/implementation). */
64
- private layoutSource;
65
- /** Target endpoint of the edge *as laid out* (reversed for inheritance/implementation). */
66
- private layoutTarget;
67
- /** Ordered, de-duplicated list of semantic layers (dependency topology by default). */
68
- private resolveLayerOrder;
69
- /** Map each package to its partition index (layers first, unlayered last). */
70
- private assignPartitions;
71
- /** True when any relation connects entities in two *different* packages. */
72
- private hasCrossPackageEdge;
73
- private buildChildren;
74
- private entityLeaf;
102
+ private layoutRaw;
103
+ /**
104
+ * A mid-scale root layer (many top-level packages) whose single hierarchical
105
+ * run left a large hole: the packed compound blocks are tried instead.
106
+ */
107
+ private isGappyMidScale;
75
108
  /**
76
- * INCLUDE_CHILDREN sizes a container from its content and does not honour the
77
- * `elk.nodeSize.minimum` we give package frames, so a single short-named
78
- * entity can leave the frame narrower than its label tab. Widen those frames
79
- * in place (never shrink) so the tab stays readable.
109
+ * Caller-requested canvas ratio: run both strategies and keep whichever lands
110
+ * closer (in log distance) to the target. The default path never gets here.
80
111
  */
81
- private enforcePackageMinWidth;
82
- private flatten;
83
- private edgePoints;
84
- private sectionPoints;
85
- private straightEdge;
112
+ private layoutToward;
113
+ private compound;
114
+ /** One ELK run over the whole nested graph (`INCLUDE_CHILDREN`). */
115
+ private hierarchicalLayout;
86
116
  }
87
117
  export type { ElkPoint };