@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
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Lane spreading. The grid router and ELK both let unrelated orthogonal edges
3
+ * share one channel centre, so several edges end up drawn on top of each other
4
+ * and read as one thicker line. This pass fans such coincident (or nearly
5
+ * coincident) interior segments out onto distinct, evenly spaced lanes around
6
+ * their common centre. Only interior segments move — the first and last
7
+ * segments stay attached to their ports — and a move is dropped when it would
8
+ * run through a node or fold an adjacent segment back on itself.
9
+ */
10
+ /** Segments closer than this on the cross axis are one lane cluster (just above PARALLEL_PROXIMITY). */
11
+ const CLUSTER_DISTANCE = 10;
12
+ /** Distance between spread lanes; strictly above [[CLUSTER_DISTANCE]] so lanes stay distinct. */
13
+ const LANE_STEP = 10;
14
+ /** Tightest lane step used when a corridor is too narrow (still above PARALLEL_PROXIMITY = 8). */
15
+ const MIN_LANE_STEP = 8.5;
16
+ /** An adjacent segment may shrink to at most this length. */
17
+ const MIN_STUB = 6;
18
+ /** Clearance between a moved segment and an unrelated node box. */
19
+ const NODE_CLEARANCE = 12;
20
+ /** Clearance between a moved segment and a package frame border it runs along. */
21
+ const FRAME_CLEARANCE = 10;
22
+ /** Spreading is repeated because moved lanes can land beside a neighbouring channel. */
23
+ const PASSES = 2;
24
+ export function spreadParallelLanes(layout) {
25
+ const polylines = layout.edges.map((e) => dropRepeats(e.points));
26
+ const owners = layout.edges.map((e) => new Set([e.source, e.target]));
27
+ const ends = layout.edges.map((e) => [e.source, e.target]);
28
+ let changed = layout.edges.some((e, i) => e.points.length !== polylines[i].length);
29
+ for (let pass = 0; pass < PASSES; pass++) {
30
+ let moved = false;
31
+ for (const horizontal of [true, false]) {
32
+ const segs = withoutMergedTrunks(collectSegments(polylines, horizontal), ends);
33
+ for (const cluster of clusterSegments(segs)) {
34
+ if (spreadCluster(cluster, polylines, owners, layout, horizontal))
35
+ moved = true;
36
+ }
37
+ }
38
+ if (!moved)
39
+ break;
40
+ changed = true;
41
+ }
42
+ if (!changed)
43
+ return layout;
44
+ return { ...layout, edges: layout.edges.map((e, i) => ({ ...e, points: polylines[i] })) };
45
+ }
46
+ /** Copy of `points` without consecutive duplicates (zero-length segments). */
47
+ function dropRepeats(points) {
48
+ const out = [];
49
+ for (const p of points) {
50
+ const last = out[out.length - 1];
51
+ if (!last || Math.abs(last.x - p.x) > 1e-6 || Math.abs(last.y - p.y) > 1e-6)
52
+ out.push({ ...p });
53
+ }
54
+ return out.length >= 2 || points.length < 2 ? out : points.map((p) => ({ ...p }));
55
+ }
56
+ function collectSegments(polylines, horizontal) {
57
+ const segs = [];
58
+ const cross = (p) => (horizontal ? p.y : p.x);
59
+ const along = (p) => (horizontal ? p.x : p.y);
60
+ polylines.forEach((pts, edge) => {
61
+ // Interior segments only: both neighbours must exist.
62
+ for (let i = 1; i + 2 < pts.length; i++) {
63
+ const a = pts[i];
64
+ const b = pts[i + 1];
65
+ if (Math.abs(cross(a) - cross(b)) > 1e-6)
66
+ continue;
67
+ const prev = pts[i - 1];
68
+ const next = pts[i + 2];
69
+ // Neighbours must be perpendicular (they absorb the shift).
70
+ if (Math.abs(along(prev) - along(a)) > 1e-6 || Math.abs(along(next) - along(b)) > 1e-6)
71
+ continue;
72
+ const c = cross(a);
73
+ const side = (p) => Math.sign(cross(p) - c);
74
+ segs.push({
75
+ edge,
76
+ at: i,
77
+ c,
78
+ lo: Math.min(along(a), along(b)),
79
+ hi: Math.max(along(a), along(b)),
80
+ turn: side(prev) + side(next),
81
+ });
82
+ }
83
+ });
84
+ return segs;
85
+ }
86
+ /**
87
+ * Drops segments lying exactly on a segment of another edge with the same source
88
+ * (or target): that overlap is a deliberately shared trunk, not a clash to fan out.
89
+ */
90
+ function withoutMergedTrunks(segs, ends) {
91
+ const merged = new Set();
92
+ const order = segs.slice().sort((a, b) => a.c - b.c);
93
+ for (let x = 0; x < order.length; x++) {
94
+ for (let y = x + 1; y < order.length && order[y].c - order[x].c < 0.5; y++) {
95
+ const s = order[x];
96
+ const t = order[y];
97
+ if (s.edge === t.edge || Math.min(s.hi, t.hi) - Math.max(s.lo, t.lo) <= 1)
98
+ continue;
99
+ const [sa, sb] = ends[s.edge];
100
+ const [ta, tb] = ends[t.edge];
101
+ if (sa === ta || sb === tb) {
102
+ merged.add(s);
103
+ merged.add(t);
104
+ }
105
+ }
106
+ }
107
+ return merged.size === 0 ? segs : segs.filter((s) => !merged.has(s));
108
+ }
109
+ /** Groups of segments (from at least two edges) that overlap within [[CLUSTER_DISTANCE]]. */
110
+ function clusterSegments(segs) {
111
+ const order = segs.map((_, i) => i).sort((a, b) => segs[a].c - segs[b].c);
112
+ const parent = segs.map((_, i) => i);
113
+ const find = (i) => {
114
+ while (parent[i] !== i)
115
+ i = parent[i] = parent[parent[i]];
116
+ return i;
117
+ };
118
+ for (let x = 0; x < order.length; x++) {
119
+ const s = segs[order[x]];
120
+ for (let y = x + 1; y < order.length; y++) {
121
+ const t = segs[order[y]];
122
+ if (t.c - s.c >= CLUSTER_DISTANCE)
123
+ break;
124
+ if (s.edge === t.edge)
125
+ continue;
126
+ if (Math.min(s.hi, t.hi) - Math.max(s.lo, t.lo) > 1)
127
+ parent[find(order[x])] = find(order[y]);
128
+ }
129
+ }
130
+ const groups = new Map();
131
+ segs.forEach((s, i) => {
132
+ const root = find(i);
133
+ const list = groups.get(root);
134
+ if (list)
135
+ list.push(s);
136
+ else
137
+ groups.set(root, [s]);
138
+ });
139
+ return [...groups.values()].filter((g) => new Set(g.map((s) => s.edge)).size > 1);
140
+ }
141
+ function spreadCluster(cluster, polylines, owners, layout, horizontal) {
142
+ const lanes = cluster
143
+ .slice()
144
+ .sort((a, b) => a.c - b.c || a.turn - b.turn || a.edge - b.edge || a.at - b.at);
145
+ const targets = fitToCorridor(separate(lanes.map((s) => s.c), LANE_STEP), lanes, layout, horizontal);
146
+ let moved = false;
147
+ lanes.forEach((seg, k) => {
148
+ const target = targets[k];
149
+ if (Math.abs(target - seg.c) < 1e-6)
150
+ return;
151
+ if (shift(polylines[seg.edge], seg.at, target, owners[seg.edge], layout, horizontal))
152
+ moved = true;
153
+ });
154
+ return moved;
155
+ }
156
+ /**
157
+ * Least-squares positions for the sorted coordinates `c` such that neighbours
158
+ * are at least `step` apart (pool-adjacent-violators on `c − step·i`).
159
+ */
160
+ function separate(c, step) {
161
+ const blocks = [];
162
+ c.forEach((v, i) => {
163
+ blocks.push({ sum: v - step * i, n: 1 });
164
+ while (blocks.length > 1) {
165
+ const b = blocks[blocks.length - 1];
166
+ const a = blocks[blocks.length - 2];
167
+ if (a.sum / a.n <= b.sum / b.n)
168
+ break;
169
+ a.sum += b.sum;
170
+ a.n += b.n;
171
+ blocks.pop();
172
+ }
173
+ });
174
+ const out = [];
175
+ for (const b of blocks)
176
+ for (let k = 0; k < b.n; k++)
177
+ out.push(b.sum / b.n + step * out.length);
178
+ return out;
179
+ }
180
+ /**
181
+ * Keep `targets` (sorted lanes) inside the free corridor around the cluster:
182
+ * the gap between the nearest unrelated boxes on either side. A corridor too
183
+ * narrow for [[LANE_STEP]] is shared at a tighter step down to [[MIN_LANE_STEP]];
184
+ * when even that does not fit the unconstrained targets are returned (the
185
+ * per-segment safety checks then decide what can move).
186
+ */
187
+ function fitToCorridor(targets, lanes, layout, horizontal) {
188
+ const n = targets.length;
189
+ const lo = Math.min(...lanes.map((s) => s.lo));
190
+ const hi = Math.max(...lanes.map((s) => s.hi));
191
+ const centre = lanes.reduce((sum, s) => sum + s.c, 0) / n;
192
+ const blocked = [];
193
+ const add = (a0, a1, c0, c1, clearance) => {
194
+ if (a1 > lo && a0 < hi)
195
+ blocked.push([c0 - clearance, c1 + clearance]);
196
+ };
197
+ for (const b of layout.nodes) {
198
+ if (horizontal)
199
+ add(b.x, b.x + b.width, b.y, b.y + b.height, NODE_CLEARANCE);
200
+ else
201
+ add(b.y, b.y + b.height, b.x, b.x + b.width, NODE_CLEARANCE);
202
+ }
203
+ for (const f of layout.packages ?? []) {
204
+ if (horizontal) {
205
+ add(f.x, f.x + f.width, f.y, f.y, FRAME_CLEARANCE);
206
+ add(f.x, f.x + f.width, f.y + f.height, f.y + f.height, FRAME_CLEARANCE);
207
+ }
208
+ else {
209
+ add(f.y, f.y + f.height, f.x, f.x, FRAME_CLEARANCE);
210
+ add(f.y, f.y + f.height, f.x + f.width, f.x + f.width, FRAME_CLEARANCE);
211
+ }
212
+ }
213
+ let floor = -Infinity;
214
+ let ceil = Infinity;
215
+ for (const [a, b] of blocked) {
216
+ if (a <= centre && centre <= b)
217
+ return targets;
218
+ if (b < centre)
219
+ floor = Math.max(floor, b);
220
+ else
221
+ ceil = Math.min(ceil, a);
222
+ }
223
+ const room = ceil - floor;
224
+ const needed = (n - 1) * LANE_STEP;
225
+ if (room >= needed) {
226
+ const first = targets[0];
227
+ const last = targets[n - 1];
228
+ const shiftBy = first < floor ? floor - first : last > ceil ? ceil - last : 0;
229
+ return targets.map((t) => t + shiftBy);
230
+ }
231
+ if (n < 2 || room / (n - 1) < MIN_LANE_STEP)
232
+ return targets;
233
+ const step = room / (n - 1);
234
+ return targets.map((_, i) => floor + i * step);
235
+ }
236
+ /** Move segment `at` of `pts` to cross-axis `target`; false (and unchanged) if that is unsafe. */
237
+ function shift(pts, at, target, owner, layout, horizontal) {
238
+ const key = horizontal ? 'y' : 'x';
239
+ const a = pts[at];
240
+ const b = pts[at + 1];
241
+ const prev = pts[at - 1];
242
+ const next = pts[at + 2];
243
+ // The segment may have moved already (shared with an earlier lane); read live coordinates.
244
+ const keepsDirection = (far, near) => {
245
+ const before = near[key] - far[key];
246
+ const after = target - far[key];
247
+ return Math.sign(before) === Math.sign(after) && Math.abs(after) >= MIN_STUB;
248
+ };
249
+ if (!keepsDirection(prev, a) || !keepsDirection(next, b))
250
+ return false;
251
+ const trial = [
252
+ { ...prev },
253
+ { ...a, [key]: target },
254
+ { ...b, [key]: target },
255
+ { ...next },
256
+ ];
257
+ for (const n of layout.nodes) {
258
+ if (owner.has(n.id))
259
+ continue;
260
+ const box = {
261
+ x0: n.x - NODE_CLEARANCE,
262
+ y0: n.y - NODE_CLEARANCE,
263
+ x1: n.x + n.width + NODE_CLEARANCE,
264
+ y1: n.y + n.height + NODE_CLEARANCE,
265
+ };
266
+ for (let i = 0; i + 1 < trial.length; i++)
267
+ if (segmentHitsBox(trial[i], trial[i + 1], box))
268
+ return false;
269
+ }
270
+ if (runsAlongFrame(trial[1], trial[2], layout, horizontal) ||
271
+ runsAlongFrame(trial[0], trial[1], layout, !horizontal) ||
272
+ runsAlongFrame(trial[2], trial[3], layout, !horizontal)) {
273
+ return false;
274
+ }
275
+ a[key] = target;
276
+ b[key] = target;
277
+ return true;
278
+ }
279
+ /** True when the moved segment would hug a package frame border it is parallel to. */
280
+ function runsAlongFrame(p, q, layout, horizontal) {
281
+ for (const f of layout.packages ?? []) {
282
+ if (horizontal) {
283
+ if (Math.max(p.x, q.x) <= f.x || Math.min(p.x, q.x) >= f.x + f.width)
284
+ continue;
285
+ if (Math.abs(p.y - f.y) < FRAME_CLEARANCE || Math.abs(p.y - (f.y + f.height)) < FRAME_CLEARANCE)
286
+ return true;
287
+ }
288
+ else {
289
+ if (Math.max(p.y, q.y) <= f.y || Math.min(p.y, q.y) >= f.y + f.height)
290
+ continue;
291
+ if (Math.abs(p.x - f.x) < FRAME_CLEARANCE || Math.abs(p.x - (f.x + f.width)) < FRAME_CLEARANCE)
292
+ return true;
293
+ }
294
+ }
295
+ return false;
296
+ }
297
+ function segmentHitsBox(p, q, box) {
298
+ return (Math.max(p.x, q.x) > box.x0 &&
299
+ Math.min(p.x, q.x) < box.x1 &&
300
+ Math.max(p.y, q.y) > box.y0 &&
301
+ Math.min(p.y, q.y) < box.y1);
302
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Package-endpoint clipping. A relation that names a package (`X --> B` with
3
+ * `package ... as X`) is laid out against a representative entity inside it,
4
+ * so the routed polyline ends on that entity's box. The author meant the
5
+ * package, so the polyline is cut where it crosses the package frame: the edge
6
+ * visibly starts/ends on the frame boundary instead of reaching into it.
7
+ */
8
+ import { PlantUmlModel } from '../foundation/model.js';
9
+ import { LayoutResult } from '../foundation/layout.js';
10
+ export declare function clipPackageEndpoints(model: PlantUmlModel, layout: LayoutResult): LayoutResult;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Package-endpoint clipping. A relation that names a package (`X --> B` with
3
+ * `package ... as X`) is laid out against a representative entity inside it,
4
+ * so the routed polyline ends on that entity's box. The author meant the
5
+ * package, so the polyline is cut where it crosses the package frame: the edge
6
+ * visibly starts/ends on the frame boundary instead of reaching into it.
7
+ */
8
+ const inside = (p, r) => p.x > r.x && p.x < r.x + r.width && p.y > r.y && p.y < r.y + r.height;
9
+ /** Point where segment a→b (a inside `r`, b outside) leaves the rectangle. */
10
+ function exitPoint(a, b, r) {
11
+ let t = 1;
12
+ const dx = b.x - a.x;
13
+ const dy = b.y - a.y;
14
+ if (dx > 0)
15
+ t = Math.min(t, (r.x + r.width - a.x) / dx);
16
+ if (dx < 0)
17
+ t = Math.min(t, (r.x - a.x) / dx);
18
+ if (dy > 0)
19
+ t = Math.min(t, (r.y + r.height - a.y) / dy);
20
+ if (dy < 0)
21
+ t = Math.min(t, (r.y - a.y) / dy);
22
+ return { x: a.x + dx * t, y: a.y + dy * t };
23
+ }
24
+ /** Cut the head of `points` at the first exit from `r`; unchanged if it never leaves. */
25
+ function clipHead(points, r) {
26
+ if (points.length < 2 || !inside(points[0], r))
27
+ return points;
28
+ for (let i = 1; i < points.length; i++) {
29
+ if (!inside(points[i], r)) {
30
+ return [exitPoint(points[i - 1], points[i], r), ...points.slice(i)];
31
+ }
32
+ }
33
+ return points;
34
+ }
35
+ export function clipPackageEndpoints(model, layout) {
36
+ if (!model.relations.some((r) => r.sourcePackageId || r.targetPackageId))
37
+ return layout;
38
+ const boxes = new Map((layout.packages ?? []).map((p) => [p.id, p]));
39
+ const edges = layout.edges.map((edge) => {
40
+ const m = /^rel-(\d+)$/.exec(edge.id);
41
+ const rel = m ? model.relations[Number(m[1])] : undefined;
42
+ if (!rel)
43
+ return edge;
44
+ let points = edge.points;
45
+ const src = rel.sourcePackageId ? boxes.get(rel.sourcePackageId) : undefined;
46
+ const dst = rel.targetPackageId ? boxes.get(rel.targetPackageId) : undefined;
47
+ if (src)
48
+ points = clipHead(points, src);
49
+ if (dst)
50
+ points = clipHead([...points].reverse(), dst).reverse();
51
+ return points === edge.points ? edge : { ...edge, points };
52
+ });
53
+ return { ...layout, edges };
54
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Block placement strategy extension point for the compound layout.
3
+ *
4
+ * The compound layout lays each top-level package out as a block, then has to
5
+ * decide where the blocks go: orbit stubs, stack semantic layers, rect-pack the
6
+ * blocks, or let ELK's layered algorithm place them. Each of those is a
7
+ * [[PlacementStrategy]]; the first one to return a placement wins and
8
+ * `undefined` declines to the next. New placement behaviour is added by
9
+ * injecting strategies through `CompoundOptions.placementStrategies` (tried
10
+ * before the built-in chain) instead of editing `placeBlocks`' decision chain.
11
+ * A strategy that throws is not swallowed: the error propagates out of the
12
+ * layout.
13
+ *
14
+ * The same interface also covers the smaller "pack the boxes inside one block"
15
+ * dimension ([[BoxPlacementContext]]), so the ordered shelf pack and the
16
+ * equal-column grid are strategies too rather than branches in `layoutBlock`.
17
+ */
18
+ import type { ELK as ElkInstance, ElkNode } from 'elkjs';
19
+ import type { PlantUmlModel } from '../foundation/model.js';
20
+ import type { NodeLayout, PackageLayout, Point } from '../foundation/layout.js';
21
+ /** Channel to the frame (`gap`) and spacing between neighbouring stubs (`spacing`), px. */
22
+ export interface StubOrbit {
23
+ gap: number;
24
+ spacing: number;
25
+ /** Row/column a stub slides to: its targets' centroid (default) or the target nearest its side. */
26
+ anchor?: 'centroid' | 'nearest';
27
+ }
28
+ /** One top-level block as laid out on its own, ready to be placed. */
29
+ export interface Block {
30
+ /** Block key: package id, or entity id for a bare top-level entity. */
31
+ key: string;
32
+ width: number;
33
+ height: number;
34
+ nodes: NodeLayout[];
35
+ packages: PackageLayout[];
36
+ /** Intra-block edge polylines keyed by relation index, block-local coordinates. */
37
+ edges: Map<number, Point[]>;
38
+ /** Intra-block relations the block layout left for the grid router. */
39
+ unrouted?: number[];
40
+ }
41
+ /** Where every placed block (or box) sits, and the canvas size that needs. */
42
+ export interface Placement {
43
+ positions: Map<string, Point>;
44
+ width: number;
45
+ height: number;
46
+ }
47
+ /** Knobs a strategy may consult; `CompoundOptions` satisfies this shape. */
48
+ export interface PlacementStrategyOptions {
49
+ /** Pack every flat block tightly (very large graphs). */
50
+ dense?: boolean;
51
+ /** Dense blocks use rect packing instead of dependency-ordered rows. */
52
+ rectPackDense?: boolean;
53
+ /** Target canvas width/height (ELK `elk.aspectRatio`). */
54
+ aspectRatio?: number;
55
+ /** Raw ELK options injected into each block's root and package containers. */
56
+ elk?: Record<string, string>;
57
+ /** Block id → semantic-layer partition index. */
58
+ partitions: ReadonlyMap<string, number>;
59
+ /** Where stub nodes orbit the placed blocks; the caller resolves the default. */
60
+ stubOrbit?: StubOrbit;
61
+ }
62
+ /** What the layout tells a block placement strategy about the run it is part of. */
63
+ export interface PlacementStrategyContext {
64
+ readonly elk: ElkInstance;
65
+ readonly model: PlantUmlModel;
66
+ readonly blocks: ReadonlyMap<string, Block>;
67
+ readonly crossRelations: readonly number[];
68
+ readonly blockOfEntity: ReadonlyMap<string, string>;
69
+ readonly options: PlacementStrategyOptions;
70
+ /**
71
+ * The chain this strategy runs in. A strategy may re-enter it for a
72
+ * sub-placement (stub orbit places the main blocks first).
73
+ */
74
+ readonly strategies: readonly PlacementStrategy[];
75
+ }
76
+ /** What a box placement strategy is given: the boxes of one block, in dependency order. */
77
+ export interface BoxPlacementContext {
78
+ /** The boxes to arrange; their order is preserved when they are materialised. */
79
+ readonly boxes: readonly ElkNode[];
80
+ /** Box ids in dependency order. */
81
+ readonly order: readonly string[];
82
+ /** Target canvas width/height. */
83
+ readonly aspect: number;
84
+ /** Gap between boxes, px. */
85
+ readonly gap: number;
86
+ /** Row gap, when it differs from `gap`. */
87
+ readonly rowGap?: number;
88
+ /** Size each column to its own widest box (grid pack only). */
89
+ readonly tight?: boolean;
90
+ }
91
+ export interface PlacementStrategy<C = PlacementStrategyContext> {
92
+ /** Short identifier, used in diagnostics. */
93
+ readonly name: string;
94
+ /** Place the blocks/boxes, or return `undefined` to decline. */
95
+ place(ctx: C): Placement | undefined | Promise<Placement | undefined>;
96
+ }
97
+ /** First non-declining placement, or `undefined` when all decline. */
98
+ export declare function runPlacementStrategies<C>(strategies: readonly PlacementStrategy<C>[] | undefined, ctx: C): Promise<Placement | undefined>;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Block placement strategy extension point for the compound layout.
3
+ *
4
+ * The compound layout lays each top-level package out as a block, then has to
5
+ * decide where the blocks go: orbit stubs, stack semantic layers, rect-pack the
6
+ * blocks, or let ELK's layered algorithm place them. Each of those is a
7
+ * [[PlacementStrategy]]; the first one to return a placement wins and
8
+ * `undefined` declines to the next. New placement behaviour is added by
9
+ * injecting strategies through `CompoundOptions.placementStrategies` (tried
10
+ * before the built-in chain) instead of editing `placeBlocks`' decision chain.
11
+ * A strategy that throws is not swallowed: the error propagates out of the
12
+ * layout.
13
+ *
14
+ * The same interface also covers the smaller "pack the boxes inside one block"
15
+ * dimension ([[BoxPlacementContext]]), so the ordered shelf pack and the
16
+ * equal-column grid are strategies too rather than branches in `layoutBlock`.
17
+ */
18
+ /** First non-declining placement, or `undefined` when all decline. */
19
+ export async function runPlacementStrategies(strategies, ctx) {
20
+ for (const strategy of strategies ?? []) {
21
+ const result = await strategy.place(ctx);
22
+ if (result !== undefined)
23
+ return result;
24
+ }
25
+ return undefined;
26
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Layout strategy extension point for the ELK engine.
3
+ *
4
+ * A strategy is tried before the engine's built-in decision chain; the first
5
+ * one that returns a result wins, `undefined` declines and falls through. New
6
+ * layout strategies are added by injecting them through
7
+ * `ElkLayoutOptions.strategies` (or `createRenderer({ layoutOptions })`)
8
+ * instead of editing `ElkLayoutEngine`. A strategy that throws is not
9
+ * swallowed: the error propagates out of the render.
10
+ */
11
+ import type { PlantUmlModel } from '../foundation/model.js';
12
+ import type { LayoutResult } from '../foundation/layout.js';
13
+ /** What the engine tells a strategy about the run it is part of. */
14
+ export interface LayoutStrategyContext {
15
+ /** Semantic layer order resolved for the model. */
16
+ layers: string[];
17
+ /** Layer partition index per entity id. */
18
+ partitions: Map<string, number>;
19
+ }
20
+ export interface LayoutStrategy {
21
+ /** Short identifier, used in diagnostics. */
22
+ readonly name: string;
23
+ /** Lay the model out, or return `undefined` to decline. */
24
+ layout(model: PlantUmlModel, ctx: LayoutStrategyContext): Promise<LayoutResult | undefined>;
25
+ }
26
+ /** First non-declining strategy result, or `undefined` when all decline. */
27
+ export declare function runStrategies(strategies: readonly LayoutStrategy[] | undefined, model: PlantUmlModel, ctx: LayoutStrategyContext): Promise<LayoutResult | undefined>;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Layout strategy extension point for the ELK engine.
3
+ *
4
+ * A strategy is tried before the engine's built-in decision chain; the first
5
+ * one that returns a result wins, `undefined` declines and falls through. New
6
+ * layout strategies are added by injecting them through
7
+ * `ElkLayoutOptions.strategies` (or `createRenderer({ layoutOptions })`)
8
+ * instead of editing `ElkLayoutEngine`. A strategy that throws is not
9
+ * swallowed: the error propagates out of the render.
10
+ */
11
+ /** First non-declining strategy result, or `undefined` when all decline. */
12
+ export async function runStrategies(strategies, model, ctx) {
13
+ for (const strategy of strategies ?? []) {
14
+ const result = await strategy.layout(model, ctx);
15
+ if (result !== undefined)
16
+ return result;
17
+ }
18
+ return undefined;
19
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Trunk lanes. A router that has to dodge a cluster of nodes can leave an edge
3
+ * that overshoots — out sideways, down, back across, down again — and stacked
4
+ * edges doing this draw the concentric rectangles of a coil. When the two end
5
+ * segments run the same axis, the edge is re-drawn as a Z: leave the source,
6
+ * cross over on a lane between the rows (the gap between the two end points),
7
+ * and enter the target. It runs after lane spreading and fan-out merging, so only
8
+ * what those passes left as an overshoot is touched. A Z replaces a detour only when it is shorter, passes
9
+ * no unrelated node or package frame, and adds at most [[EXTRA_CROSSINGS]] crossings.
10
+ */
11
+ import { LayoutResult } from '../foundation/layout.js';
12
+ export declare function straightenTrunkDetours(layout: LayoutResult): LayoutResult;
13
+ /**
14
+ * Lift segments that run flush along the side of a node the edge belongs to —
15
+ * hard to tell from the box outline — a few pixels off that side. The segment
16
+ * moves outwards; when it was the first or last one, the original end point is
17
+ * kept and joined by a short stub. The nearest lift that clears every node and
18
+ * every parallel neighbour edge is used; none, and the segment stays.
19
+ */
20
+ export declare function liftFlushSegments(layout: LayoutResult): LayoutResult;
21
+ /**
22
+ * Un-coil: an edge whose route wraps round other nodes (the U of a trunk that
23
+ * circles a cluster — stacked, the concentric rectangles of a coil) is routed
24
+ * again on the lattice with cheaper crossings, against every other edge as a
25
+ * fixed wire. The new route is kept when it wraps fewer nodes, adds at most
26
+ * [[UNCOIL_EXTRA_CROSSINGS]] crossings and opens no new comb at either end.
27
+ * Runs after fan-out merging, so a hub's trunk has been settled by then.
28
+ */
29
+ export declare function uncoilWraps(layout: LayoutResult): LayoutResult;