@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,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,183 @@
1
+ /**
2
+ * Layout quality metrics. Pure functions from (model, layout) to numbers, so a
3
+ * layout strategy change can be judged by a diff in figures rather than by eye.
4
+ *
5
+ * Edge polylines are taken at face value: for the viz engine they are bezier
6
+ * control points, which is a coarse but consistent approximation. Every metric
7
+ * is deterministic — the same (model, layout) always yields the same numbers —
8
+ * so a run can be committed as `rendered/metrics.baseline.json` and diffed
9
+ * against a later run to prove an improvement or a regression.
10
+ */
11
+ import { PlantUmlModel } from '../foundation/model.js';
12
+ import { LayoutResult, EdgeLayout, Point } from '../foundation/layout.js';
13
+ export interface LayoutMetrics {
14
+ /** Proper crossings between segments of two different edges. */
15
+ edgeCrossings: number;
16
+ /** Edge segments passing through the interior of an unrelated node. */
17
+ edgeNodeIntersections: number;
18
+ /**
19
+ * Edges drawn with at least one diagonal segment (or no usable polyline at
20
+ * all) — the straight-line fallback an engine takes when its router returns
21
+ * no route. Zero means every edge is an orthogonal polyline.
22
+ */
23
+ nonOrthogonalEdgeCount: number;
24
+ /** Summed polyline length of every edge, in canvas pixels. */
25
+ totalEdgeLength: number;
26
+ /** Sum of node areas divided by canvas area (0..1). */
27
+ areaUtilization: number;
28
+ /** Canvas width / height. */
29
+ aspectRatio: number;
30
+ /** Entity text lines wider than the box they are drawn in. */
31
+ textOverflowCount: number;
32
+ /** Package label tabs that overlap content or are narrower than their text. */
33
+ labelOverlapCount: number;
34
+ /**
35
+ * Summed overlap length of *nearly* parallel segments from different edges —
36
+ * segments within [[PARALLEL_PROXIMITY]] px of each other. The orthogonal
37
+ * router's "parallel-line wall" is invisible to [[edgeCrossings]] (segments
38
+ * that merely run alongside never *cross*), so this counts that hidden
39
+ * clutter in canvas pixels.
40
+ */
41
+ overlappingEdgeLength: number;
42
+ /** Hub badges whose circle covers any of the owning node's text. */
43
+ badgeLabelOverlap: number;
44
+ /**
45
+ * Edge labels (`×N` weights and plain labels) that overlap another edge label
46
+ * or sit on top of a node box.
47
+ */
48
+ edgeLabelOverlap: number;
49
+ /** Edge labels whose rectangle intersects any node rectangle (should be 0). */
50
+ labelNodeOverlap: number;
51
+ /** Edge labels whose rectangle is crossed by a segment of an edge other than their own. */
52
+ labelEdgeOverlap: number;
53
+ /** Pairs of edge-label rectangles that intersect each other. */
54
+ labelLabelOverlap: number;
55
+ /**
56
+ * Incoming arrowheads stacked beyond a threshold on the same side of the
57
+ * same node — the "everything points at this hub" pile-up that
58
+ * [[edgeCrossings]] cannot see either.
59
+ */
60
+ hubArrowStack: number;
61
+ /**
62
+ * Area of the largest axis-aligned rectangle on the canvas that holds neither
63
+ * a node nor a package frame, divided by the canvas area (0..1). A big value
64
+ * is the "empty quadrant" a row-packed root layer leaves behind.
65
+ */
66
+ maxEmptyBlockRatio: number;
67
+ /**
68
+ * Edge segments running parallel to, and within [[EDGE_FLUSH_DISTANCE]] px
69
+ * of, a side of a node they do not end at or of a package frame border they
70
+ * do not end at — the edge then reads as part of the box outline.
71
+ */
72
+ edgeFlushCount: number;
73
+ /**
74
+ * "Combs": groups of at least [[BUNDLE_MIN_EDGES]] edges that share a source
75
+ * (or a target), run the same way, and have segments laid side by side
76
+ * within [[BUNDLE_LATERAL_DISTANCE]] px for at least [[BUNDLE_MIN_RUN]] px.
77
+ * Edges drawn exactly on top of each other (a merged trunk) are one line and
78
+ * do not form a comb. [[overlappingEdgeLength]] cannot see these: the lines
79
+ * sit just outside its proximity.
80
+ */
81
+ parallelBundleCount: number;
82
+ /**
83
+ * Edges that pass through the interior of a package frame neither endpoint
84
+ * belongs to (counted once per edge and frame) — a corridor should run in the
85
+ * gutters between frames, not across unrelated packages.
86
+ */
87
+ edgeFrameCrossings: number;
88
+ /**
89
+ * Edges whose polyline is longer than [[TRUNK_DETOUR_FACTOR]] times the
90
+ * Manhattan distance between their end points — a trunk that wanders round a
91
+ * cluster instead of running the lane between the rows.
92
+ */
93
+ trunkDetourCount: number;
94
+ /**
95
+ * Nested rectangular loops inside one package frame (edges between two nodes of the same frame): U-turns (three segments,
96
+ * the outer two running in opposite directions) whose bounding box encloses a
97
+ * node the edge does not end at (the route wraps a cluster) and lies inside
98
+ * another such U-turn's — counted once per inner loop. The concentric "coil" a trunk makes when it keeps
99
+ * circling the nodes it avoids.
100
+ */
101
+ nestedLoopCount: number;
102
+ /**
103
+ * Edge segments at least [[EDGE_NODE_FLUSH_MIN_LENGTH]] px long running
104
+ * parallel to a side of the node they end at, within
105
+ * [[EDGE_NODE_FLUSH_DISTANCE]] px — the line hugs the box it belongs to and
106
+ * is hard to tell from its outline.
107
+ */
108
+ edgeNodeFlush: number;
109
+ /**
110
+ * Smallest node-area ÷ frame-area over the package frames holding at least
111
+ * [[FRAME_FILL_MIN_NODES]] nodes (1 when no frame qualifies): low means a
112
+ * stretched, mostly empty frame.
113
+ */
114
+ frameFillRatio: number;
115
+ /**
116
+ * Smallest distance from any edge point to a canvas side (the shorter canvas
117
+ * side when there are no edges): a line hugging the canvas edge is easily
118
+ * cropped.
119
+ */
120
+ edgeCanvasMargin: number;
121
+ /**
122
+ * Layout-engine wall time in milliseconds (layout only: no transforms, SVG
123
+ * or rasterisation). Present only when the caller passes the measured time.
124
+ */
125
+ layoutMs?: number;
126
+ }
127
+ /**
128
+ * A named, pure measurement over a layout. The metrics registry is the single
129
+ * source of truth for which measurements [[computeLayoutMetrics]] reports:
130
+ * adding a metric means registering a `Metric`, not editing the function's
131
+ * return literal.
132
+ */
133
+ export interface Metric {
134
+ /** Key the value is exposed under on [[LayoutMetrics]]. */
135
+ readonly name: string;
136
+ compute(model: PlantUmlModel, layout: LayoutResult): number;
137
+ }
138
+ /** Register a metric; the next [[computeLayoutMetrics]] call reports it. */
139
+ export declare function registerMetric(metric: Metric): void;
140
+ /** Remove a registered metric by name; returns whether one was removed. */
141
+ export declare function unregisterMetric(name: string): boolean;
142
+ /** Names of the currently registered metrics, in evaluation order. */
143
+ export declare function listMetrics(): readonly string[];
144
+ /**
145
+ * Computes every registered metric. The registry drives the assembly, so a new
146
+ * measurement participates without touching this function; [[LayoutMetrics]]
147
+ * remains the strong, hand-written shape the registered names must cover.
148
+ * `layoutMs` is not a measurement of the layout — it is the caller's wall-time
149
+ * reading — so it is attached directly rather than registered.
150
+ */
151
+ export declare function computeLayoutMetrics(model: PlantUmlModel, layout: LayoutResult, layoutMs?: number): LayoutMetrics;
152
+ /** Polyline length ÷ Manhattan distance above which an edge counts as a detour. */
153
+ export declare const TRUNK_DETOUR_FACTOR = 2;
154
+ /** Distance (px) and run (px) for [[LayoutMetrics.edgeNodeFlush]]. */
155
+ export declare const EDGE_NODE_FLUSH_DISTANCE = 4;
156
+ export declare const EDGE_NODE_FLUSH_MIN_LENGTH = 40;
157
+ /** Frames with at least this many nodes count for [[LayoutMetrics.frameFillRatio]]. */
158
+ export declare const FRAME_FILL_MIN_NODES = 6;
159
+ /** For each polyline, the number of crossings it takes part in (each crossing counts for both edges). */
160
+ export declare function perEdgeCrossings(polylines: Point[][]): number[];
161
+ /** Lateral distance (px) within which two parallel segments count as overlapping. */
162
+ export declare const PARALLEL_PROXIMITY = 8;
163
+ /** Edge-to-box distance (px) below which a parallel segment counts as flush. */
164
+ export declare const EDGE_FLUSH_DISTANCE = 6;
165
+ /**
166
+ * Rasterises nodes and package frames onto a coarse grid and returns the
167
+ * largest all-empty rectangle (maximal-rectangle-in-histogram) over the canvas.
168
+ */
169
+ export declare function computeMaxEmptyBlockRatio(layout: LayoutResult): number;
170
+ /** Edges per comb needed to count it. */
171
+ export declare const BUNDLE_MIN_EDGES = 5;
172
+ /** Lateral distance (px) within which two same-way segments sit "side by side". */
173
+ export declare const BUNDLE_LATERAL_DISTANCE = 16;
174
+ /** Shared run (px) two side-by-side segments need to count. */
175
+ export declare const BUNDLE_MIN_RUN = 120;
176
+ /** One comb: the shared end node and the ids of the edges laid side by side. */
177
+ export interface ParallelBundle {
178
+ end: 'source' | 'target';
179
+ node: string;
180
+ edges: string[];
181
+ }
182
+ /** Lists source-sharing and target-sharing combs; see [[LayoutMetrics.parallelBundleCount]]. */
183
+ export declare function findParallelBundles(edges: EdgeLayout[]): ParallelBundle[];