@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.
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 +9 -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,766 @@
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 { METRICS, PACKAGE_LABEL, displayName } from '../foundation/layout.js';
12
+ import { countEdgeBoxIntersections, countNonOrthogonalSegments, nodeRect } from '../foundation/edge-router.js';
13
+ import { measureTextWidth } from '../foundation/text-measure.js';
14
+ import { entityTextRects, hubBadgeRect, placeEdgeLabels, rectHitsOtherEdge, rectsIntersect, } from '../render/labels.js';
15
+ const registry = [];
16
+ /** Register a metric; the next [[computeLayoutMetrics]] call reports it. */
17
+ export function registerMetric(metric) {
18
+ if (registry.some((m) => m.name === metric.name)) {
19
+ throw new Error(`metric already registered: ${metric.name}`);
20
+ }
21
+ registry.push(metric);
22
+ }
23
+ /** Remove a registered metric by name; returns whether one was removed. */
24
+ export function unregisterMetric(name) {
25
+ const index = registry.findIndex((m) => m.name === name);
26
+ if (index < 0)
27
+ return false;
28
+ registry.splice(index, 1);
29
+ return true;
30
+ }
31
+ /** Names of the currently registered metrics, in evaluation order. */
32
+ export function listMetrics() {
33
+ return registry.map((m) => m.name);
34
+ }
35
+ registerMetric({
36
+ name: 'edgeCrossings',
37
+ compute: (_model, layout) => countEdgeCrossings((layout.edges ?? []).map((e) => e.points)),
38
+ });
39
+ registerMetric({
40
+ name: 'edgeNodeIntersections',
41
+ compute: (_model, layout) => countEdgeBoxIntersections(layout.edges ?? [], layout.nodes),
42
+ });
43
+ registerMetric({
44
+ name: 'nonOrthogonalEdgeCount',
45
+ compute: (_model, layout) => (layout.edges ?? []).filter((e) => e.points.length >= 2 && countNonOrthogonalSegments(e.points) > 0).length,
46
+ });
47
+ registerMetric({
48
+ name: 'totalEdgeLength',
49
+ compute: (_model, layout) => (layout.edges ?? []).reduce((sum, e) => sum + polylineLength(e.points), 0),
50
+ });
51
+ registerMetric({
52
+ name: 'areaUtilization',
53
+ compute: (_model, layout) => {
54
+ const nodeArea = layout.nodes.reduce((sum, n) => sum + n.width * n.height, 0);
55
+ const canvasArea = layout.width * layout.height;
56
+ return canvasArea > 0 ? nodeArea / canvasArea : 0;
57
+ },
58
+ });
59
+ registerMetric({
60
+ name: 'aspectRatio',
61
+ compute: (_model, layout) => (layout.height > 0 ? layout.width / layout.height : 0),
62
+ });
63
+ registerMetric({ name: 'textOverflowCount', compute: countTextOverflow });
64
+ registerMetric({ name: 'labelOverlapCount', compute: countLabelOverlap });
65
+ registerMetric({
66
+ name: 'overlappingEdgeLength',
67
+ compute: (_model, layout) => {
68
+ const edges = layout.edges ?? [];
69
+ return countOverlappingEdgeLength(edges.map((e) => e.points), edges.map((e) => [e.source, e.target]));
70
+ },
71
+ });
72
+ registerMetric({ name: 'badgeLabelOverlap', compute: countBadgeLabelOverlap });
73
+ registerMetric({ name: 'edgeLabelOverlap', compute: countEdgeLabelOverlap });
74
+ registerMetric({ name: 'labelNodeOverlap', compute: countLabelNodeOverlap });
75
+ registerMetric({ name: 'labelEdgeOverlap', compute: countLabelEdgeOverlap });
76
+ registerMetric({ name: 'labelLabelOverlap', compute: countLabelLabelOverlap });
77
+ registerMetric({
78
+ name: 'hubArrowStack',
79
+ compute: (_model, layout) => countHubArrowStack(layout.edges ?? []),
80
+ });
81
+ registerMetric({ name: 'maxEmptyBlockRatio', compute: (_model, layout) => computeMaxEmptyBlockRatio(layout) });
82
+ registerMetric({
83
+ name: 'edgeFlushCount',
84
+ compute: (_model, layout) => countEdgeFlush(layout.edges ?? [], layout),
85
+ });
86
+ registerMetric({
87
+ name: 'parallelBundleCount',
88
+ compute: (_model, layout) => countParallelBundles(layout.edges ?? []),
89
+ });
90
+ registerMetric({
91
+ name: 'edgeFrameCrossings',
92
+ compute: (model, layout) => countEdgeFrameCrossings(model, layout.edges ?? [], layout),
93
+ });
94
+ registerMetric({
95
+ name: 'trunkDetourCount',
96
+ compute: (_model, layout) => countTrunkDetours(layout.edges ?? []),
97
+ });
98
+ registerMetric({
99
+ name: 'nestedLoopCount',
100
+ compute: (_model, layout) => countNestedLoops(layout.edges ?? [], layout),
101
+ });
102
+ registerMetric({
103
+ name: 'edgeNodeFlush',
104
+ compute: (_model, layout) => countEdgeNodeFlush(layout.edges ?? [], layout),
105
+ });
106
+ registerMetric({ name: 'frameFillRatio', compute: (_model, layout) => computeFrameFillRatio(layout) });
107
+ registerMetric({
108
+ name: 'edgeCanvasMargin',
109
+ compute: (_model, layout) => computeEdgeCanvasMargin(layout.edges ?? [], layout),
110
+ });
111
+ /**
112
+ * Computes every registered metric. The registry drives the assembly, so a new
113
+ * measurement participates without touching this function; [[LayoutMetrics]]
114
+ * remains the strong, hand-written shape the registered names must cover.
115
+ * `layoutMs` is not a measurement of the layout — it is the caller's wall-time
116
+ * reading — so it is attached directly rather than registered.
117
+ */
118
+ export function computeLayoutMetrics(model, layout, layoutMs) {
119
+ const result = {};
120
+ for (const metric of registry) {
121
+ result[metric.name] = metric.compute(model, layout);
122
+ }
123
+ if (layoutMs !== undefined)
124
+ result.layoutMs = layoutMs;
125
+ return result;
126
+ }
127
+ /** Polyline length ÷ Manhattan distance above which an edge counts as a detour. */
128
+ export const TRUNK_DETOUR_FACTOR = 2;
129
+ /** Distance (px) and run (px) for [[LayoutMetrics.edgeNodeFlush]]. */
130
+ export const EDGE_NODE_FLUSH_DISTANCE = 4;
131
+ export const EDGE_NODE_FLUSH_MIN_LENGTH = 40;
132
+ /** Frames with at least this many nodes count for [[LayoutMetrics.frameFillRatio]]. */
133
+ export const FRAME_FILL_MIN_NODES = 6;
134
+ function computeFrameFillRatio(layout) {
135
+ const frames = (layout.packages ?? []).slice().sort((p, q) => p.width * p.height - q.width * q.height);
136
+ const stats = new Map();
137
+ for (const n of layout.nodes) {
138
+ const cx = n.x + n.width / 2;
139
+ const cy = n.y + n.height / 2;
140
+ const f = frames.find((p) => cx >= p.x && cx <= p.x + p.width && cy >= p.y && cy <= p.y + p.height);
141
+ if (!f)
142
+ continue;
143
+ const s = stats.get(f.id) ?? { area: 0, count: 0 };
144
+ s.area += n.width * n.height;
145
+ s.count++;
146
+ stats.set(f.id, s);
147
+ }
148
+ let ratio = 1;
149
+ for (const f of frames) {
150
+ const s = stats.get(f.id);
151
+ if (!s || s.count < FRAME_FILL_MIN_NODES || f.width * f.height <= 0)
152
+ continue;
153
+ ratio = Math.min(ratio, s.area / (f.width * f.height));
154
+ }
155
+ return ratio;
156
+ }
157
+ function computeEdgeCanvasMargin(edges, layout) {
158
+ let margin = Math.min(layout.width, layout.height);
159
+ for (const e of edges) {
160
+ for (const p of e.points) {
161
+ margin = Math.min(margin, p.x, p.y, layout.width - p.x, layout.height - p.y);
162
+ }
163
+ }
164
+ return margin;
165
+ }
166
+ function countTrunkDetours(edges) {
167
+ let count = 0;
168
+ for (const e of edges) {
169
+ if (e.points.length < 3)
170
+ continue;
171
+ const a = e.points[0];
172
+ const b = e.points[e.points.length - 1];
173
+ const manhattan = Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
174
+ if (polylineLength(e.points) > TRUNK_DETOUR_FACTOR * Math.max(manhattan, 1))
175
+ count++;
176
+ }
177
+ return count;
178
+ }
179
+ function countNestedLoops(edges, layout) {
180
+ const nodes = layout.nodes;
181
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
182
+ const frames = (layout.packages ?? []).slice().sort((p, q) => p.width * p.height - q.width * q.height);
183
+ const frameOf = (id) => {
184
+ const n = nodeById.get(id);
185
+ if (!n)
186
+ return undefined;
187
+ const cx = n.x + n.width / 2;
188
+ const cy = n.y + n.height / 2;
189
+ return frames.find((f) => cx >= f.x && cx <= f.x + f.width && cy >= f.y && cy <= f.y + f.height)?.id;
190
+ };
191
+ const loops = [];
192
+ for (const e of edges) {
193
+ const frame = frameOf(e.source);
194
+ if (frame === undefined || frame !== frameOf(e.target))
195
+ continue; // only coils inside one frame
196
+ // A U-turn: three consecutive segments whose outer two run in opposite
197
+ // directions — the route leaves, crosses over and comes back.
198
+ for (let i = 0; i + 3 < e.points.length; i++) {
199
+ const [p0, p1, p2, p3] = e.points.slice(i, i + 4);
200
+ const firstVertical = Math.abs(p0.x - p1.x) < 1e-6 && Math.abs(p0.y - p1.y) > 1e-6;
201
+ const firstHorizontal = Math.abs(p0.y - p1.y) < 1e-6 && Math.abs(p0.x - p1.x) > 1e-6;
202
+ const d1 = firstVertical ? p1.y - p0.y : p1.x - p0.x;
203
+ const d3 = firstVertical ? p3.y - p2.y : p3.x - p2.x;
204
+ const middleOk = firstVertical ? Math.abs(p1.y - p2.y) < 1e-6 : Math.abs(p1.x - p2.x) < 1e-6;
205
+ const lastOk = firstVertical ? Math.abs(p2.x - p3.x) < 1e-6 : Math.abs(p2.y - p3.y) < 1e-6;
206
+ if ((!firstVertical && !firstHorizontal) || !middleOk || !lastOk || d1 * d3 >= 0)
207
+ continue;
208
+ const xs = [p0.x, p1.x, p2.x, p3.x];
209
+ const ys = [p0.y, p1.y, p2.y, p3.y];
210
+ const box = { x0: Math.min(...xs), y0: Math.min(...ys), x1: Math.max(...xs), y1: Math.max(...ys) };
211
+ const wraps = nodes.some((n) => n.id !== e.source &&
212
+ n.id !== e.target &&
213
+ n.x > box.x0 && n.x + n.width < box.x1 &&
214
+ n.y > box.y0 && n.y + n.height < box.y1);
215
+ if (wraps)
216
+ loops.push({ frame, ...box });
217
+ }
218
+ }
219
+ let count = 0;
220
+ loops.forEach((inner, i) => {
221
+ const nested = loops.some((outer, j) => j !== i &&
222
+ outer.frame === inner.frame &&
223
+ outer.x0 <= inner.x0 && outer.y0 <= inner.y0 && outer.x1 >= inner.x1 && outer.y1 >= inner.y1 &&
224
+ (outer.x0 < inner.x0 || outer.y0 < inner.y0 || outer.x1 > inner.x1 || outer.y1 > inner.y1));
225
+ if (nested)
226
+ count++;
227
+ });
228
+ return count;
229
+ }
230
+ function countEdgeNodeFlush(edges, layout) {
231
+ const nodeById = new Map(layout.nodes.map((n) => [n.id, n]));
232
+ let count = 0;
233
+ for (const edge of edges) {
234
+ const ends = new Set([edge.source, edge.target]);
235
+ for (let i = 0; i + 1 < edge.points.length; i++) {
236
+ const a = edge.points[i];
237
+ const b = edge.points[i + 1];
238
+ const vertical = Math.abs(a.x - b.x) < 1e-6;
239
+ const horizontal = Math.abs(a.y - b.y) < 1e-6;
240
+ if (vertical === horizontal)
241
+ continue;
242
+ if (Math.hypot(a.x - b.x, a.y - b.y) < EDGE_NODE_FLUSH_MIN_LENGTH)
243
+ continue;
244
+ for (const id of ends) {
245
+ const n = nodeById.get(id);
246
+ if (n && segmentFlushRect(a, b, vertical, nodeRect(n), EDGE_NODE_FLUSH_DISTANCE))
247
+ count++;
248
+ }
249
+ }
250
+ }
251
+ return count;
252
+ }
253
+ function polylineLength(points) {
254
+ let len = 0;
255
+ for (let i = 0; i + 1 < points.length; i++) {
256
+ len += Math.hypot(points[i + 1].x - points[i].x, points[i + 1].y - points[i].y);
257
+ }
258
+ return len;
259
+ }
260
+ function orient(a, b, c) {
261
+ const v = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
262
+ return Math.abs(v) < 1e-9 ? 0 : v > 0 ? 1 : -1;
263
+ }
264
+ /** True when segments ab and cd cross at a point interior to both. */
265
+ function segmentsCross(a, b, c, d) {
266
+ const o1 = orient(a, b, c);
267
+ const o2 = orient(a, b, d);
268
+ const o3 = orient(c, d, a);
269
+ const o4 = orient(c, d, b);
270
+ return o1 * o2 < 0 && o3 * o4 < 0;
271
+ }
272
+ function countEdgeCrossings(polylines) {
273
+ return perEdgeCrossings(polylines).reduce((sum, n) => sum + n, 0) / 2;
274
+ }
275
+ /** For each polyline, the number of crossings it takes part in (each crossing counts for both edges). */
276
+ export function perEdgeCrossings(polylines) {
277
+ const segs = [];
278
+ polylines.forEach((pts, edge) => {
279
+ for (let i = 0; i + 1 < pts.length; i++) {
280
+ const a = pts[i];
281
+ const b = pts[i + 1];
282
+ segs.push({
283
+ a,
284
+ b,
285
+ edge,
286
+ minX: Math.min(a.x, b.x),
287
+ maxX: Math.max(a.x, b.x),
288
+ minY: Math.min(a.y, b.y),
289
+ maxY: Math.max(a.y, b.y),
290
+ });
291
+ }
292
+ });
293
+ segs.sort((p, q) => p.minX - q.minX);
294
+ const counts = polylines.map(() => 0);
295
+ for (let i = 0; i < segs.length; i++) {
296
+ const s = segs[i];
297
+ for (let j = i + 1; j < segs.length; j++) {
298
+ const t = segs[j];
299
+ if (t.minX > s.maxX)
300
+ break;
301
+ if (t.edge === s.edge)
302
+ continue;
303
+ if (t.maxY < s.minY || t.minY > s.maxY)
304
+ continue;
305
+ if (segmentsCross(s.a, s.b, t.a, t.b)) {
306
+ counts[s.edge]++;
307
+ counts[t.edge]++;
308
+ }
309
+ }
310
+ }
311
+ return counts;
312
+ }
313
+ function countTextOverflow(model, layout) {
314
+ const nodes = new Map(layout.nodes.map((n) => [n.id, n]));
315
+ let count = 0;
316
+ for (const entity of model.entities) {
317
+ const node = nodes.get(entity.id);
318
+ if (!node)
319
+ continue;
320
+ const lines = [displayName(entity), ...entity.members.map((m) => m.raw)];
321
+ for (const line of lines) {
322
+ const needed = measureTextWidth(line, METRICS.fontSize) + METRICS.paddingX * 2;
323
+ if (needed > node.width + 0.01)
324
+ count++;
325
+ }
326
+ }
327
+ return count;
328
+ }
329
+ function rectsOverlap(a, b) {
330
+ return (a.x < b.x + b.width &&
331
+ b.x < a.x + a.width &&
332
+ a.y < b.y + b.height &&
333
+ b.y < a.y + a.height);
334
+ }
335
+ /**
336
+ * A package's label tab is drawn at its top-left corner. It is counted when it
337
+ * overlaps an entity or sub-package frame inside the package, or when the tab
338
+ * is narrower than the label text it has to hold.
339
+ */
340
+ function countLabelOverlap(model, layout) {
341
+ const frames = new Map((layout.packages ?? []).map((p) => [p.id, p]));
342
+ const nodes = new Map(layout.nodes.map((n) => [n.id, n]));
343
+ let count = 0;
344
+ for (const pkg of model.packages) {
345
+ const frame = frames.get(pkg.id);
346
+ if (!frame)
347
+ continue;
348
+ const textWidth = measureTextWidth(pkg.name, PACKAGE_LABEL.fontSize);
349
+ const wanted = textWidth + PACKAGE_LABEL.tabPadding;
350
+ const tab = {
351
+ x: frame.x,
352
+ y: frame.y,
353
+ width: Math.min(frame.width, Math.max(24, wanted)),
354
+ height: PACKAGE_LABEL.tabHeight,
355
+ };
356
+ let bad = textWidth + 6 > tab.width + 0.01;
357
+ for (const entity of model.entities) {
358
+ if (bad)
359
+ break;
360
+ if (entity.packageId !== pkg.id)
361
+ continue;
362
+ const node = nodes.get(entity.id);
363
+ if (node && rectsOverlap(tab, nodeRect(node)))
364
+ bad = true;
365
+ }
366
+ for (const child of model.packages) {
367
+ if (bad)
368
+ break;
369
+ if (child.parentId !== pkg.id)
370
+ continue;
371
+ const childFrame = frames.get(child.id);
372
+ if (childFrame && rectsOverlap(tab, childFrame))
373
+ bad = true;
374
+ }
375
+ if (bad)
376
+ count++;
377
+ }
378
+ return count;
379
+ }
380
+ /** Lateral distance (px) within which two parallel segments count as overlapping. */
381
+ export const PARALLEL_PROXIMITY = 8;
382
+ /**
383
+ * Summed length of near-parallel, overlapping segments from different edges.
384
+ * Two segments that run in the same direction within [[PARALLEL_PROXIMITY]] px
385
+ * of each other and share a stretch of their projection read as one "wall" of
386
+ * lines. Each (segment, segment) pair is counted once, in canvas pixels.
387
+ */
388
+ function countOverlappingEdgeLength(polylines, ends) {
389
+ const segs = [];
390
+ polylines.forEach((pts, edge) => {
391
+ for (let i = 0; i + 1 < pts.length; i++) {
392
+ segs.push({ a: pts[i], b: pts[i + 1], edge });
393
+ }
394
+ });
395
+ let total = 0;
396
+ for (let i = 0; i < segs.length; i++) {
397
+ for (let j = i + 1; j < segs.length; j++) {
398
+ if (segs[i].edge === segs[j].edge)
399
+ continue;
400
+ const length = nearParallelOverlapLength(segs[i].a, segs[i].b, segs[j].a, segs[j].b);
401
+ if (length > 0 && sharesEnd(ends[segs[i].edge], ends[segs[j].edge]) && isCoincident(segs[i], segs[j]))
402
+ continue;
403
+ total += length;
404
+ }
405
+ }
406
+ return total;
407
+ }
408
+ /** Two edges leave the same source or arrive at the same target. */
409
+ function sharesEnd(a, b) {
410
+ return a[0] === b[0] || a[1] === b[1];
411
+ }
412
+ /** Segments lying on one line: a merged fan-out trunk draws them once, so they are not clutter. */
413
+ function isCoincident(s, t) {
414
+ const onLine = (p, a, b) => {
415
+ const len = Math.hypot(b.x - a.x, b.y - a.y);
416
+ return len > 1e-9 && Math.abs((b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)) / len < COINCIDENT_DISTANCE;
417
+ };
418
+ return onLine(t.a, s.a, s.b) && onLine(t.b, s.a, s.b);
419
+ }
420
+ /** Shared projected length of two near-parallel segments, else 0 (canvas pixels). */
421
+ function nearParallelOverlapLength(a, b, c, d) {
422
+ const ux = b.x - a.x;
423
+ const uy = b.y - a.y;
424
+ const vx = d.x - c.x;
425
+ const vy = d.y - c.y;
426
+ const lenU = Math.hypot(ux, uy);
427
+ const lenV = Math.hypot(vx, vy);
428
+ if (lenU < 1e-9 || lenV < 1e-9)
429
+ return 0;
430
+ // Parallel: the two direction vectors must be colinear.
431
+ if (Math.abs(ux * vy - uy * vx) > 1e-9 * lenU * lenV)
432
+ return 0;
433
+ // Near: the lateral distance between the two lines is within the proximity.
434
+ const dist = Math.abs(ux * (c.y - a.y) - uy * (c.x - a.x)) / lenU;
435
+ if (dist > PARALLEL_PROXIMITY)
436
+ return 0;
437
+ const uxN = ux / lenU;
438
+ const uyN = uy / lenU;
439
+ const proj = (p) => (p.x - a.x) * uxN + (p.y - a.y) * uyN;
440
+ const t0 = Math.min(proj(a), proj(b));
441
+ const t1 = Math.max(proj(a), proj(b));
442
+ const s0 = Math.min(proj(c), proj(d));
443
+ const s1 = Math.max(proj(c), proj(d));
444
+ const overlap = Math.min(t1, s1) - Math.max(t0, s0);
445
+ return overlap > 0 ? overlap : 0;
446
+ }
447
+ /** Hub badges (drawn on the node's corner) that touch the node's own text. */
448
+ function countBadgeLabelOverlap(model, layout) {
449
+ const nodes = new Map(layout.nodes.map((n) => [n.id, n]));
450
+ let count = 0;
451
+ for (const entity of model.entities) {
452
+ if (!entity.usageCount || entity.usageCount <= 0)
453
+ continue;
454
+ const node = nodes.get(entity.id);
455
+ if (!node)
456
+ continue;
457
+ const badge = hubBadgeRect(node);
458
+ if (entityTextRects(entity, node).some((r) => rectsIntersect(badge, r)))
459
+ count++;
460
+ }
461
+ return count;
462
+ }
463
+ /** Edge labels overlapping each other, or covering a node box. */
464
+ function countEdgeLabelOverlap(model, layout) {
465
+ const labels = placeEdgeLabels(model, layout);
466
+ const nodeRects = layout.nodes.map(nodeRect);
467
+ let count = 0;
468
+ for (let i = 0; i < labels.length; i++) {
469
+ let bad = nodeRects.some((n) => rectsIntersect(labels[i].rect, n));
470
+ for (let j = 0; j < i && !bad; j++) {
471
+ if (rectsIntersect(labels[i].rect, labels[j].rect))
472
+ bad = true;
473
+ }
474
+ if (bad)
475
+ count++;
476
+ }
477
+ return count;
478
+ }
479
+ function countLabelNodeOverlap(model, layout) {
480
+ const nodeRects = layout.nodes.map(nodeRect);
481
+ return placeEdgeLabels(model, layout).filter((l) => nodeRects.some((n) => rectsIntersect(l.rect, n))).length;
482
+ }
483
+ function countLabelEdgeOverlap(model, layout) {
484
+ const edges = layout.edges ?? [];
485
+ return placeEdgeLabels(model, layout).filter((l) => rectHitsOtherEdge(l.rect, edges, `rel-${l.edge}`)).length;
486
+ }
487
+ function countLabelLabelOverlap(model, layout) {
488
+ const labels = placeEdgeLabels(model, layout);
489
+ let count = 0;
490
+ for (let i = 0; i < labels.length; i++) {
491
+ for (let j = i + 1; j < labels.length; j++) {
492
+ if (rectsIntersect(labels[i].rect, labels[j].rect))
493
+ count++;
494
+ }
495
+ }
496
+ return count;
497
+ }
498
+ /**
499
+ * Incoming arrowheads are grouped by (target node, entry side). A side that
500
+ * receives more than [[HUB_ARROW_STACK_THRESHOLD]] arrowheads contributes its
501
+ * excess to the total — the "everyone points at this hub from the same side"
502
+ * pile-up, invisible to [[countEdgeCrossings]].
503
+ */
504
+ const HUB_ARROW_STACK_THRESHOLD = 3;
505
+ function countHubArrowStack(edges) {
506
+ const groups = new Map();
507
+ for (const edge of edges) {
508
+ const pts = edge.points;
509
+ if (pts.length < 2)
510
+ continue;
511
+ const side = approachSide(pts[pts.length - 2], pts[pts.length - 1]);
512
+ if (side === undefined)
513
+ continue;
514
+ const key = `${edge.target}\u0000${side}`;
515
+ groups.set(key, (groups.get(key) ?? 0) + 1);
516
+ }
517
+ let total = 0;
518
+ for (const count of groups.values()) {
519
+ if (count > HUB_ARROW_STACK_THRESHOLD)
520
+ total += count - HUB_ARROW_STACK_THRESHOLD;
521
+ }
522
+ return total;
523
+ }
524
+ /** Side of the target the arrowhead enters from, derived from the last segment. */
525
+ function approachSide(from, to) {
526
+ const dx = to.x - from.x;
527
+ const dy = to.y - from.y;
528
+ if (Math.abs(dx) < 1e-9 && Math.abs(dy) < 1e-9)
529
+ return undefined;
530
+ if (Math.abs(dx) >= Math.abs(dy))
531
+ return dx > 0 ? 'left' : 'right';
532
+ return dy > 0 ? 'top' : 'bottom';
533
+ }
534
+ /** Edge-to-box distance (px) below which a parallel segment counts as flush. */
535
+ export const EDGE_FLUSH_DISTANCE = 6;
536
+ /** Raster cell (px) used to find the largest empty block; coarsened for huge canvases. */
537
+ const EMPTY_BLOCK_CELL = 20;
538
+ const EMPTY_BLOCK_MAX_CELLS = 400;
539
+ /**
540
+ * Rasterises nodes and package frames onto a coarse grid and returns the
541
+ * largest all-empty rectangle (maximal-rectangle-in-histogram) over the canvas.
542
+ */
543
+ export function computeMaxEmptyBlockRatio(layout) {
544
+ if (layout.width <= 0 || layout.height <= 0)
545
+ return 0;
546
+ const cell = Math.max(EMPTY_BLOCK_CELL, layout.width / EMPTY_BLOCK_MAX_CELLS, layout.height / EMPTY_BLOCK_MAX_CELLS);
547
+ const cols = Math.ceil(layout.width / cell);
548
+ const rows = Math.ceil(layout.height / cell);
549
+ const filled = new Uint8Array(cols * rows);
550
+ const boxes = [...layout.nodes.map(nodeRect), ...(layout.packages ?? [])];
551
+ for (const b of boxes) {
552
+ const c0 = Math.max(0, Math.floor(b.x / cell));
553
+ const c1 = Math.min(cols - 1, Math.ceil((b.x + b.width) / cell) - 1);
554
+ const r0 = Math.max(0, Math.floor(b.y / cell));
555
+ const r1 = Math.min(rows - 1, Math.ceil((b.y + b.height) / cell) - 1);
556
+ for (let r = r0; r <= r1; r++)
557
+ for (let c = c0; c <= c1; c++)
558
+ filled[r * cols + c] = 1;
559
+ }
560
+ const heights = new Array(cols).fill(0);
561
+ let best = 0;
562
+ for (let r = 0; r < rows; r++) {
563
+ for (let c = 0; c < cols; c++)
564
+ heights[c] = filled[r * cols + c] ? 0 : heights[c] + 1;
565
+ const stack = [];
566
+ for (let c = 0; c <= cols; c++) {
567
+ const h = c === cols ? 0 : heights[c];
568
+ while (stack.length > 0 && heights[stack[stack.length - 1]] >= h) {
569
+ const height = heights[stack.pop()];
570
+ const left = stack.length > 0 ? stack[stack.length - 1] + 1 : 0;
571
+ best = Math.max(best, height * (c - left));
572
+ }
573
+ stack.push(c);
574
+ }
575
+ }
576
+ return Math.min(1, (best * cell * cell) / (layout.width * layout.height));
577
+ }
578
+ /** Segments parallel and within [[EDGE_FLUSH_DISTANCE]] of an unrelated node side or frame border. */
579
+ function countEdgeFlush(edges, layout) {
580
+ const nodeBoxes = layout.nodes.map((n) => ({ id: n.id, rect: nodeRect(n) }));
581
+ const frames = (layout.packages ?? []).map((p) => ({ id: p.id, rect: p }));
582
+ let count = 0;
583
+ for (const edge of edges) {
584
+ const ends = new Set([edge.source, edge.target]);
585
+ for (let i = 0; i + 1 < edge.points.length; i++) {
586
+ const a = edge.points[i];
587
+ const b = edge.points[i + 1];
588
+ const vertical = Math.abs(a.x - b.x) < 1e-6;
589
+ const horizontal = Math.abs(a.y - b.y) < 1e-6;
590
+ if (vertical === horizontal)
591
+ continue; // diagonal or degenerate
592
+ for (const box of nodeBoxes) {
593
+ if (!ends.has(box.id) && segmentFlushRect(a, b, vertical, box.rect))
594
+ count++;
595
+ }
596
+ for (const frame of frames) {
597
+ if (!ends.has(frame.id) && segmentFlushRect(a, b, vertical, frame.rect))
598
+ count++;
599
+ }
600
+ }
601
+ }
602
+ return count;
603
+ }
604
+ /** True when the axis-aligned segment ab runs along a side of `r` within the flush distance. */
605
+ function segmentFlushRect(a, b, vertical, r, distance = EDGE_FLUSH_DISTANCE) {
606
+ if (vertical) {
607
+ const lo = Math.min(a.y, b.y);
608
+ const hi = Math.max(a.y, b.y);
609
+ if (Math.min(hi, r.y + r.height) - Math.max(lo, r.y) <= 1e-6)
610
+ return false;
611
+ return (Math.abs(a.x - r.x) < distance ||
612
+ Math.abs(a.x - (r.x + r.width)) < distance);
613
+ }
614
+ const lo = Math.min(a.x, b.x);
615
+ const hi = Math.max(a.x, b.x);
616
+ if (Math.min(hi, r.x + r.width) - Math.max(lo, r.x) <= 1e-6)
617
+ return false;
618
+ return (Math.abs(a.y - r.y) < distance ||
619
+ Math.abs(a.y - (r.y + r.height)) < distance);
620
+ }
621
+ /** Edges per comb needed to count it. */
622
+ export const BUNDLE_MIN_EDGES = 5;
623
+ /** Lateral distance (px) within which two same-way segments sit "side by side". */
624
+ export const BUNDLE_LATERAL_DISTANCE = 16;
625
+ /** Shared run (px) two side-by-side segments need to count. */
626
+ export const BUNDLE_MIN_RUN = 120;
627
+ /** Segments closer than this are one drawn line (an intentionally merged trunk). */
628
+ const COINCIDENT_DISTANCE = 0.5;
629
+ /** Lists source-sharing and target-sharing combs; see [[LayoutMetrics.parallelBundleCount]]. */
630
+ export function findParallelBundles(edges) {
631
+ const found = [];
632
+ for (const end of ['source', 'target']) {
633
+ const byEnd = new Map();
634
+ edges.forEach((e, i) => {
635
+ if (e.points.length < 2)
636
+ return;
637
+ const list = byEnd.get(e[end]);
638
+ if (list)
639
+ list.push(i);
640
+ else
641
+ byEnd.set(e[end], [i]);
642
+ });
643
+ for (const [node, members] of byEnd) {
644
+ if (members.length < BUNDLE_MIN_EDGES)
645
+ continue;
646
+ for (const comb of combsAmong(members.map((i) => edges[i].points))) {
647
+ if (comb.length >= BUNDLE_MIN_EDGES)
648
+ found.push({ end, node, edges: comb.map((k) => edges[members[k]].id) });
649
+ }
650
+ }
651
+ }
652
+ return found;
653
+ }
654
+ function countParallelBundles(edges) {
655
+ return findParallelBundles(edges).length;
656
+ }
657
+ /** Components of polylines (as index lists) linked by side-by-side same-way segments. */
658
+ function combsAmong(polylines) {
659
+ const segs = [];
660
+ polylines.forEach((pts, edge) => {
661
+ for (let i = 0; i + 1 < pts.length; i++) {
662
+ const a = pts[i];
663
+ const b = pts[i + 1];
664
+ const horizontal = Math.abs(a.y - b.y) < 1e-6;
665
+ const vertical = Math.abs(a.x - b.x) < 1e-6;
666
+ if (horizontal === vertical)
667
+ continue;
668
+ const [from, to] = horizontal ? [a.x, b.x] : [a.y, b.y];
669
+ if (Math.abs(to - from) < BUNDLE_MIN_RUN)
670
+ continue;
671
+ segs.push({
672
+ edge,
673
+ horizontal,
674
+ sign: Math.sign(to - from),
675
+ c: horizontal ? a.y : a.x,
676
+ lo: Math.min(from, to),
677
+ hi: Math.max(from, to),
678
+ });
679
+ }
680
+ });
681
+ const parent = polylines.map((_, i) => i);
682
+ const find = (i) => {
683
+ while (parent[i] !== i)
684
+ i = parent[i] = parent[parent[i]];
685
+ return i;
686
+ };
687
+ for (let i = 0; i < segs.length; i++) {
688
+ for (let j = i + 1; j < segs.length; j++) {
689
+ const s = segs[i];
690
+ const t = segs[j];
691
+ if (s.edge === t.edge || s.horizontal !== t.horizontal || s.sign !== t.sign)
692
+ continue;
693
+ const lateral = Math.abs(s.c - t.c);
694
+ if (lateral < COINCIDENT_DISTANCE || lateral > BUNDLE_LATERAL_DISTANCE)
695
+ continue;
696
+ if (Math.min(s.hi, t.hi) - Math.max(s.lo, t.lo) < BUNDLE_MIN_RUN)
697
+ continue;
698
+ parent[find(s.edge)] = find(t.edge);
699
+ }
700
+ }
701
+ const comps = new Map();
702
+ polylines.forEach((_, i) => {
703
+ const root = find(i);
704
+ const list = comps.get(root);
705
+ if (list)
706
+ list.push(i);
707
+ else
708
+ comps.set(root, [i]);
709
+ });
710
+ return [...comps.values()];
711
+ }
712
+ /** Depth (px) a segment must run inside a frame to count as crossing it. */
713
+ const FRAME_CROSSING_MIN_RUN = 4;
714
+ /** Ids of the packages an endpoint lives in (itself too, when it is a package). */
715
+ function packageChain(id, entityPackage, parentOf) {
716
+ const chain = new Set();
717
+ let cur = parentOf.has(id) ? id : entityPackage.get(id);
718
+ while (cur !== undefined && !chain.has(cur)) {
719
+ chain.add(cur);
720
+ cur = parentOf.get(cur);
721
+ }
722
+ return chain;
723
+ }
724
+ function countEdgeFrameCrossings(model, edges, layout) {
725
+ const frames = layout.packages ?? [];
726
+ if (frames.length === 0)
727
+ return 0;
728
+ const entityPackage = new Map(model.entities.map((e) => [e.id, e.packageId]));
729
+ const parentOf = new Map(model.packages.map((p) => [p.id, p.parentId]));
730
+ let count = 0;
731
+ for (const edge of edges) {
732
+ if (edge.points.length < 2)
733
+ continue;
734
+ const related = new Set([
735
+ ...packageChain(edge.source, entityPackage, parentOf),
736
+ ...packageChain(edge.target, entityPackage, parentOf),
737
+ ]);
738
+ for (const frame of frames) {
739
+ if (related.has(frame.id))
740
+ continue;
741
+ if (polylineRunsInside(edge.points, frame))
742
+ count++;
743
+ }
744
+ }
745
+ return count;
746
+ }
747
+ /** True when some axis-aligned segment runs ≥ [[FRAME_CROSSING_MIN_RUN]] px strictly inside `r`. */
748
+ function polylineRunsInside(points, r) {
749
+ for (let i = 0; i + 1 < points.length; i++) {
750
+ const a = points[i];
751
+ const b = points[i + 1];
752
+ if (Math.abs(a.y - b.y) < 1e-6) {
753
+ if (a.y <= r.y || a.y >= r.y + r.height)
754
+ continue;
755
+ if (Math.min(Math.max(a.x, b.x), r.x + r.width) - Math.max(Math.min(a.x, b.x), r.x) >= FRAME_CROSSING_MIN_RUN)
756
+ return true;
757
+ }
758
+ else if (Math.abs(a.x - b.x) < 1e-6) {
759
+ if (a.x <= r.x || a.x >= r.x + r.width)
760
+ continue;
761
+ if (Math.min(Math.max(a.y, b.y), r.y + r.height) - Math.max(Math.min(a.y, b.y), r.y) >= FRAME_CROSSING_MIN_RUN)
762
+ return true;
763
+ }
764
+ }
765
+ return false;
766
+ }