@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
package/dist/transform.js CHANGED
@@ -9,6 +9,11 @@
9
9
  * input and produces the same output for the same input, so a transformed
10
10
  * render stays byte-for-byte reproducible.
11
11
  */
12
+ import { relationSemantics } from './foundation/relation-semantics.js';
13
+ /** Entity count above which inter-package edges aggregate into corridors by default. */
14
+ export const INTER_PACKAGE_AGGREGATE_ENTITY_THRESHOLD = 150;
15
+ /** Stub count above which a single-package view groups stubs by name prefix. */
16
+ export const DEFAULT_STUB_GROUP_THRESHOLD = 8;
12
17
  const ROOT = '__root__';
13
18
  function cloneModel(model) {
14
19
  return {
@@ -24,23 +29,89 @@ function cloneModel(model) {
24
29
  * summarization, then cycle marking on the final edge set.
25
30
  */
26
31
  export function transformModel(model, options = {}) {
27
- const { aggregate = true, transitiveReduction = false, hubThreshold, cycles = false } = options;
32
+ return transformModelDetailed(model, options).model;
33
+ }
34
+ /** [[transformModel]] plus the number of edges the transitive reduction hid. */
35
+ export function transformModelDetailed(model, options = {}) {
36
+ const { aggregate = true, transitiveReduction = false, hubThreshold, cycles = false, interPackage = 'raw' } = options;
28
37
  let out = cloneModel(model);
38
+ out = summarizeExternals(out, EXTERNAL_SUMMARY_MIN_IN_EDGES);
29
39
  if (aggregate)
30
40
  out = aggregateRelations(out);
31
- if (transitiveReduction)
41
+ let hiddenTransitive = 0;
42
+ if (transitiveReduction) {
43
+ const before = out.relations.length;
32
44
  out = transitiveReduce(out);
45
+ hiddenTransitive = before - out.relations.length;
46
+ }
33
47
  if (hubThreshold !== undefined)
34
48
  out = summarizeHubs(out, hubThreshold);
49
+ if (interPackage === 'aggregate')
50
+ out = aggregateInterPackage(out);
35
51
  if (cycles)
36
52
  out = markCycles(out);
37
- return out;
53
+ return { model: groupExternals(out), hiddenTransitive };
54
+ }
55
+ /** Edge count ÷ node count above which `edgeReduction: 'auto'` reduces a pure dependency graph. */
56
+ export const AUTO_REDUCTION_EDGE_NODE_RATIO = 3;
57
+ /**
58
+ * Whether `edgeReduction: 'auto'` applies: every relation is bundlable
59
+ * (dependency/association) and there are more than
60
+ * [[AUTO_REDUCTION_EDGE_NODE_RATIO]] times as many edges as nodes.
61
+ */
62
+ export function shouldAutoReduce(model) {
63
+ if (model.relations.length === 0)
64
+ return false;
65
+ if (!model.relations.every((r) => relationSemantics(r.type).isBundlable()))
66
+ return false;
67
+ const nodes = model.entities.length;
68
+ const distinct = new Set(model.relations.map((r) => `${r.source}\u0000${r.target}`)).size;
69
+ return distinct > AUTO_REDUCTION_EDGE_NODE_RATIO * nodes;
70
+ }
71
+ /** Incoming edges at which an external type's edges collapse into a count badge. */
72
+ export const EXTERNAL_SUMMARY_MIN_IN_EDGES = 3;
73
+ /** Id of the synthetic package that gathers implicit/external types. */
74
+ export const EXTERNAL_PACKAGE_ID = '__external__';
75
+ /**
76
+ * Implicit/external types (`Error`, `EventEmitter`, …) receive many parallel
77
+ * edges that would fly across the whole canvas. Once one has `minEdges` or more
78
+ * incoming relations they are dropped and replaced by a `usageCount` badge that
79
+ * carries the original incoming-edge count.
80
+ */
81
+ export function summarizeExternals(model, minEdges) {
82
+ const incoming = new Map();
83
+ for (const r of model.relations) {
84
+ const e = model.entities.find((x) => x.id === r.target);
85
+ if (e?.external)
86
+ incoming.set(r.target, (incoming.get(r.target) ?? 0) + (r.weight ?? 1));
87
+ }
88
+ const summarized = new Set([...incoming].filter(([, n]) => n >= minEdges).map(([id]) => id));
89
+ if (summarized.size === 0)
90
+ return model;
91
+ return {
92
+ ...model,
93
+ entities: model.entities.map((e) => summarized.has(e.id) ? { ...e, usageCount: incoming.get(e.id) } : e),
94
+ relations: model.relations.filter((r) => !summarized.has(r.target)),
95
+ };
96
+ }
97
+ /** Gather top-level external entities into one synthetic External package. */
98
+ export function groupExternals(model) {
99
+ const externals = model.entities.filter((e) => e.external && e.packageId === undefined);
100
+ if (externals.length < 2 || model.packages.some((p) => p.id === EXTERNAL_PACKAGE_ID)) {
101
+ return model;
102
+ }
103
+ const ids = new Set(externals.map((e) => e.id));
104
+ return {
105
+ ...model,
106
+ entities: model.entities.map((e) => (ids.has(e.id) ? { ...e, packageId: EXTERNAL_PACKAGE_ID } : e)),
107
+ packages: [...model.packages, { id: EXTERNAL_PACKAGE_ID, name: 'External' }],
108
+ };
38
109
  }
39
110
  /** Merge relations sharing (source, target) into one edge with `weight`. */
40
111
  export function aggregateRelations(model) {
41
112
  const byKey = new Map();
42
113
  for (const relation of model.relations) {
43
- const key = `${relation.source}\u0000${relation.target}`;
114
+ const key = `${relation.source}\u0000${relation.target}\u0000${relation.sourcePackageId ?? ''}\u0000${relation.targetPackageId ?? ''}`;
44
115
  const existing = byKey.get(key);
45
116
  if (existing) {
46
117
  existing.weight = (existing.weight ?? 1) + 1;
@@ -51,6 +122,89 @@ export function aggregateRelations(model) {
51
122
  }
52
123
  return { ...model, relations: [...byKey.values()] };
53
124
  }
125
+ /**
126
+ * Fold every relation between two different packages into one corridor edge
127
+ * per ordered package pair: `weight` = number of originals, `descriptions`
128
+ * keeps each original, and the endpoints are the entities carrying most of the
129
+ * traffic with `sourcePackageId`/`targetPackageId` set so the layout clips the
130
+ * edge to the frame boundaries. Intra-package edges and edges touching
131
+ * package-less entities, or between nested packages, are left as they are.
132
+ */
133
+ export function aggregateInterPackage(model) {
134
+ const pkgOf = new Map(model.entities.map((e) => [e.id, e.packageId]));
135
+ const parent = new Map(model.packages.map((p) => [p.id, p.parentId]));
136
+ const nested = (a, b) => {
137
+ for (const [x, y] of [[a, b], [b, a]]) {
138
+ for (let p = parent.get(x); p !== undefined; p = parent.get(p))
139
+ if (p === y)
140
+ return true;
141
+ }
142
+ return false;
143
+ };
144
+ const groups = new Map();
145
+ const relations = [];
146
+ for (const rel of model.relations) {
147
+ const a = pkgOf.get(rel.source);
148
+ const b = pkgOf.get(rel.target);
149
+ if (!a || !b || a === b || nested(a, b) || rel.sourcePackageId || rel.targetPackageId) {
150
+ relations.push(rel);
151
+ continue;
152
+ }
153
+ const key = `${a}\u0000${b}`;
154
+ let g = groups.get(key);
155
+ if (!g) {
156
+ g = { src: a, dst: b, items: [], srcCount: new Map(), dstCount: new Map(), at: relations.length };
157
+ groups.set(key, g);
158
+ relations.push(g);
159
+ }
160
+ g.items.push(rel);
161
+ g.srcCount.set(rel.source, (g.srcCount.get(rel.source) ?? 0) + 1);
162
+ g.dstCount.set(rel.target, (g.dstCount.get(rel.target) ?? 0) + 1);
163
+ }
164
+ const top = (m) => {
165
+ let best = '';
166
+ let n = -1;
167
+ for (const [k, v] of m)
168
+ if (v > n) {
169
+ best = k;
170
+ n = v;
171
+ }
172
+ return best;
173
+ };
174
+ const out = relations.map((r) => {
175
+ if (!('items' in r))
176
+ return r;
177
+ const types = new Set(r.items.map((i) => i.type));
178
+ const only = r.items.length === 1 ? r.items[0].type : [...types][0];
179
+ const type = types.size === 1 && relationSemantics(only).isBundlable() ? only : 'dependency';
180
+ return {
181
+ source: top(r.srcCount),
182
+ target: top(r.dstCount),
183
+ type,
184
+ weight: r.items.reduce((sum, i) => sum + (i.weight ?? 1), 0),
185
+ feedback: r.items.every((i) => i.feedback) || undefined,
186
+ sourcePackageId: r.src,
187
+ targetPackageId: r.dst,
188
+ descriptions: r.items.map((i) => `${i.source} ${ARROWS[i.type]} ${i.target}${i.label ? ` : ${i.label}` : ''}`),
189
+ };
190
+ });
191
+ return { ...model, relations: out.map(dropUndefined) };
192
+ }
193
+ const ARROWS = {
194
+ inheritance: '--|>',
195
+ implementation: '..|>',
196
+ dependency: '..>',
197
+ association: '-->',
198
+ aggregation: 'o--',
199
+ composition: '*--',
200
+ };
201
+ function dropUndefined(r) {
202
+ const copy = { ...r };
203
+ for (const k of Object.keys(copy))
204
+ if (copy[k] === undefined)
205
+ delete copy[k];
206
+ return copy;
207
+ }
54
208
  /**
55
209
  * Remove a relation A→B when A still reaches B through another path (an edge
56
210
  * derivable by transitivity). Only the redundant direct edge is dropped; the
@@ -58,37 +212,52 @@ export function aggregateRelations(model) {
58
212
  */
59
213
  export function transitiveReduce(model) {
60
214
  const targets = new Map();
61
- for (const relation of model.relations) {
62
- const list = targets.get(relation.source);
63
- if (list)
64
- list.push(relation.target);
65
- else
66
- targets.set(relation.source, [relation.target]);
67
- }
68
- const reachable = (start, goal, skipTarget) => {
69
- const seen = new Set();
70
- const queue = [start];
71
- while (queue.length > 0) {
72
- const u = queue.shift();
73
- for (const v of targets.get(u) ?? []) {
74
- if (u === start && v === skipTarget)
75
- continue; // ignore the direct edge under test
215
+ const addEdge = (u, v) => {
216
+ const m = targets.get(u) ?? new Map();
217
+ m.set(v, (m.get(v) ?? 0) + 1);
218
+ targets.set(u, m);
219
+ };
220
+ for (const relation of model.relations)
221
+ addEdge(relation.source, relation.target);
222
+ const reachable = (start, goal) => {
223
+ const seen = new Set([start]);
224
+ const stack = [start];
225
+ while (stack.length > 0) {
226
+ const u = stack.pop();
227
+ for (const v of targets.get(u)?.keys() ?? []) {
76
228
  if (v === goal)
77
229
  return true;
78
230
  if (!seen.has(v)) {
79
231
  seen.add(v);
80
- queue.push(v);
232
+ stack.push(v);
81
233
  }
82
234
  }
83
235
  }
84
236
  return false;
85
237
  };
86
- const relations = model.relations.filter((relation) => {
87
- // A self-loop is its own shortest witness; keep it. A single-hop edge with
88
- // no alternate path is necessary; keep it.
89
- return !reachable(relation.source, relation.target, relation.target);
238
+ // Greedy in authored order: an edge is dropped when its end points stay
239
+ // connected through the edges still present, so reachability is preserved
240
+ // exactly (also inside cycles, where dropping every "redundant" edge at once
241
+ // would disconnect the cycle). A self-loop is its own witness and is kept.
242
+ const removed = new Set();
243
+ model.relations.forEach((relation, i) => {
244
+ if (relation.source === relation.target)
245
+ return;
246
+ const out = targets.get(relation.source);
247
+ const count = out.get(relation.target);
248
+ if (count > 1) {
249
+ // A parallel duplicate of the same pair is always redundant.
250
+ out.set(relation.target, count - 1);
251
+ removed.add(i);
252
+ return;
253
+ }
254
+ out.delete(relation.target);
255
+ if (reachable(relation.source, relation.target))
256
+ removed.add(i);
257
+ else
258
+ out.set(relation.target, 1);
90
259
  });
91
- return { ...model, relations };
260
+ return { ...model, relations: model.relations.filter((_, i) => !removed.has(i)) };
92
261
  }
93
262
  /**
94
263
  * Nodes with an in-degree above `threshold` have their incoming edges dropped
@@ -170,7 +339,7 @@ export function applyView(model, view) {
170
339
  }
171
340
  return overviewView(model);
172
341
  case 'package':
173
- return packageView(model, view.id);
342
+ return packageView(model, view.id, view.stubGroupThreshold ?? DEFAULT_STUB_GROUP_THRESHOLD);
174
343
  case 'neighborhood':
175
344
  return neighborhoodView(model, view.entity, view.hops);
176
345
  }
@@ -276,7 +445,7 @@ export function groupByPathPrefix(model, depth) {
276
445
  * merged by aggregation). Entities and relations entirely outside the package
277
446
  * are dropped.
278
447
  */
279
- function packageView(model, id) {
448
+ function packageView(model, id, stubGroupThreshold) {
280
449
  const pkgById = new Map(model.packages.map((p) => [p.id, p]));
281
450
  const selected = pkgById.get(id);
282
451
  if (!selected) {
@@ -332,13 +501,92 @@ function packageView(model, id) {
332
501
  const stub = stubFor(externalKey);
333
502
  relations.push(sourceIn ? { ...relation, target: stub.id } : { ...relation, source: stub.id });
334
503
  }
504
+ const grouped = groupStubs({ entities, relations }, stubGroupThreshold);
335
505
  return {
336
506
  diagramType: 'class',
337
- entities,
338
- relations,
507
+ entities: grouped.entities,
508
+ relations: grouped.relations,
339
509
  packages: [{ ...selected, parentId: undefined }],
340
510
  };
341
511
  }
512
+ /** Name with its last word dropped (`Src Cli Utils` → `Src Cli`); a one-word name stays as is. */
513
+ function namePrefix(name) {
514
+ const words = name.trim().split(/\s+/);
515
+ return words.length > 1 ? words.slice(0, -1).join(' ') : name;
516
+ }
517
+ /**
518
+ * Past `threshold` stubs, merge stubs sharing a name prefix into one group stub
519
+ * (`Src Cli … (×6)`), dropping trailing words until the group count fits (or nothing shortens any
520
+ * more). The edges to the members of a group collapse onto
521
+ * the group stub per (entity, direction): `weight` counts the originals and
522
+ * `descriptions` keeps each one for the edge's `<title>`.
523
+ */
524
+ export function groupStubs(model, threshold) {
525
+ const stubs = model.entities.filter((e) => e.stub);
526
+ if (stubs.length <= threshold)
527
+ return model;
528
+ let keyOf = new Map(stubs.map((e) => [e.id, e.name]));
529
+ for (;;) {
530
+ if (new Set(keyOf.values()).size <= threshold)
531
+ break;
532
+ const next = new Map([...keyOf].map(([id, k]) => [id, namePrefix(k)]));
533
+ if ([...next].every(([id, k]) => k === keyOf.get(id)))
534
+ break;
535
+ keyOf = next;
536
+ }
537
+ const members = new Map();
538
+ for (const stub of stubs) {
539
+ const key = keyOf.get(stub.id);
540
+ const list = members.get(key);
541
+ if (list)
542
+ list.push(stub);
543
+ else
544
+ members.set(key, [stub]);
545
+ }
546
+ const groupId = new Map();
547
+ const entities = [];
548
+ for (const e of model.entities) {
549
+ if (!e.stub) {
550
+ entities.push(e);
551
+ continue;
552
+ }
553
+ const key = keyOf.get(e.id);
554
+ const list = members.get(key);
555
+ if (list.length === 1) {
556
+ groupId.set(e.id, e.id);
557
+ entities.push(e);
558
+ }
559
+ else if (list[0] === e) {
560
+ const id = `stub::group::${key}`;
561
+ for (const m of list)
562
+ groupId.set(m.id, id);
563
+ entities.push({ ...e, id, name: `${list.length} other ${key} packages`, groupMembers: list.map((m) => m.name) });
564
+ }
565
+ }
566
+ const merged = new Map();
567
+ const out = [];
568
+ const label = (r) => `${r.source} ${r.type} ${r.target}`;
569
+ for (const r of model.relations) {
570
+ const source = groupId.get(r.source) ?? r.source;
571
+ const target = groupId.get(r.target) ?? r.target;
572
+ if (source === r.source && target === r.target) {
573
+ out.push(r);
574
+ continue;
575
+ }
576
+ const key = `${source}\u0000${target}`;
577
+ const existing = merged.get(key);
578
+ if (existing) {
579
+ existing.weight = (existing.weight ?? 1) + (r.weight ?? 1);
580
+ existing.descriptions.push(...(r.descriptions ?? [label(r)]));
581
+ }
582
+ else {
583
+ const rel = { ...r, source, target, weight: r.weight ?? 1, descriptions: [...(r.descriptions ?? [label(r)])] };
584
+ merged.set(key, rel);
585
+ out.push(rel);
586
+ }
587
+ }
588
+ return { entities, relations: out };
589
+ }
342
590
  /**
343
591
  * Expand one entity plus every entity within `hops` undirected hops, together
344
592
  * with the package frames that contain the kept entities.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yalehwang/plantuml-js",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Pure-TypeScript PlantUML class diagram renderer (MIT)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,12 +34,13 @@
34
34
  "node": ">=18"
35
35
  },
36
36
  "scripts": {
37
- "build": "tsc -p tsconfig.json",
37
+ "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
38
+ "build": "npm run clean && tsc -p tsconfig.json",
38
39
  "typecheck": "tsc -p tsconfig.test.json",
39
40
  "test": "vitest run",
40
41
  "demo:build": "node scripts/build-demo.mjs",
41
42
  "release": "node scripts/release.mjs",
42
- "prepublishOnly": "npm run typecheck && npm run build"
43
+ "prepublishOnly": "npm run typecheck && npm run build && node scripts/check-dist-clean.mjs"
43
44
  },
44
45
  "dependencies": {
45
46
  "@viz-js/viz": "^3.31.0",
@@ -1,42 +0,0 @@
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 './model.js';
12
- import { LayoutResult } from './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
- /** Summed polyline length of every edge, in canvas pixels. */
19
- totalEdgeLength: number;
20
- /** Sum of node areas divided by canvas area (0..1). */
21
- areaUtilization: number;
22
- /** Canvas width / height. */
23
- aspectRatio: number;
24
- /** Entity text lines wider than the box they are drawn in. */
25
- textOverflowCount: number;
26
- /** Package label tabs that overlap content or are narrower than their text. */
27
- labelOverlapCount: number;
28
- /**
29
- * Summed length of collinear, overlapping segments from *different* edges —
30
- * the orthogonal router's "parallel-line wall" is invisible to
31
- * [[edgeCrossings]] (segments that merely overlap never *cross*), so this
32
- * counts that hidden overlap in canvas pixels.
33
- */
34
- overlappingEdgeLength: number;
35
- /**
36
- * Incoming arrowheads stacked beyond a threshold on the same side of the
37
- * same node — the "everything points at this hub" pile-up that
38
- * [[edgeCrossings]] cannot see either.
39
- */
40
- hubArrowStack: number;
41
- }
42
- export declare function computeLayoutMetrics(model: PlantUmlModel, layout: LayoutResult): LayoutMetrics;
@@ -1,241 +0,0 @@
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, displayName } from './layout.js';
12
- import { countEdgeBoxIntersections, nodeRect } from './edge-router.js';
13
- import { measureTextWidth } from './text-measure.js';
14
- /** Package label tab geometry, kept in step with [[../render/svg]]. */
15
- const PACKAGE_TAB_HEIGHT = 18;
16
- const PACKAGE_LABEL_FONT_SIZE = 12;
17
- const PACKAGE_TAB_PADDING = 12;
18
- export function computeLayoutMetrics(model, layout) {
19
- const edges = layout.edges ?? [];
20
- const nodeArea = layout.nodes.reduce((sum, n) => sum + n.width * n.height, 0);
21
- const canvasArea = layout.width * layout.height;
22
- return {
23
- edgeCrossings: countEdgeCrossings(edges.map((e) => e.points)),
24
- edgeNodeIntersections: countEdgeBoxIntersections(edges, layout.nodes),
25
- totalEdgeLength: edges.reduce((sum, e) => sum + polylineLength(e.points), 0),
26
- areaUtilization: canvasArea > 0 ? nodeArea / canvasArea : 0,
27
- aspectRatio: layout.height > 0 ? layout.width / layout.height : 0,
28
- textOverflowCount: countTextOverflow(model, layout),
29
- labelOverlapCount: countLabelOverlap(model, layout),
30
- overlappingEdgeLength: countOverlappingEdgeLength(edges.map((e) => e.points)),
31
- hubArrowStack: countHubArrowStack(edges),
32
- };
33
- }
34
- function polylineLength(points) {
35
- let len = 0;
36
- for (let i = 0; i + 1 < points.length; i++) {
37
- len += Math.hypot(points[i + 1].x - points[i].x, points[i + 1].y - points[i].y);
38
- }
39
- return len;
40
- }
41
- function orient(a, b, c) {
42
- const v = (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
43
- return Math.abs(v) < 1e-9 ? 0 : v > 0 ? 1 : -1;
44
- }
45
- /** True when segments ab and cd cross at a point interior to both. */
46
- function segmentsCross(a, b, c, d) {
47
- const o1 = orient(a, b, c);
48
- const o2 = orient(a, b, d);
49
- const o3 = orient(c, d, a);
50
- const o4 = orient(c, d, b);
51
- return o1 * o2 < 0 && o3 * o4 < 0;
52
- }
53
- function countEdgeCrossings(polylines) {
54
- const segs = [];
55
- polylines.forEach((pts, edge) => {
56
- for (let i = 0; i + 1 < pts.length; i++) {
57
- const a = pts[i];
58
- const b = pts[i + 1];
59
- segs.push({
60
- a,
61
- b,
62
- edge,
63
- minX: Math.min(a.x, b.x),
64
- maxX: Math.max(a.x, b.x),
65
- minY: Math.min(a.y, b.y),
66
- maxY: Math.max(a.y, b.y),
67
- });
68
- }
69
- });
70
- segs.sort((p, q) => p.minX - q.minX);
71
- let count = 0;
72
- for (let i = 0; i < segs.length; i++) {
73
- const s = segs[i];
74
- for (let j = i + 1; j < segs.length; j++) {
75
- const t = segs[j];
76
- if (t.minX > s.maxX)
77
- break;
78
- if (t.edge === s.edge)
79
- continue;
80
- if (t.maxY < s.minY || t.minY > s.maxY)
81
- continue;
82
- if (segmentsCross(s.a, s.b, t.a, t.b))
83
- count++;
84
- }
85
- }
86
- return count;
87
- }
88
- function countTextOverflow(model, layout) {
89
- const nodes = new Map(layout.nodes.map((n) => [n.id, n]));
90
- let count = 0;
91
- for (const entity of model.entities) {
92
- const node = nodes.get(entity.id);
93
- if (!node)
94
- continue;
95
- const lines = [displayName(entity), ...entity.members.map((m) => m.raw)];
96
- for (const line of lines) {
97
- const needed = measureTextWidth(line, METRICS.fontSize) + METRICS.paddingX * 2;
98
- if (needed > node.width + 0.01)
99
- count++;
100
- }
101
- }
102
- return count;
103
- }
104
- function rectsOverlap(a, b) {
105
- return (a.x < b.x + b.width &&
106
- b.x < a.x + a.width &&
107
- a.y < b.y + b.height &&
108
- b.y < a.y + a.height);
109
- }
110
- /**
111
- * A package's label tab is drawn at its top-left corner. It is counted when it
112
- * overlaps an entity or sub-package frame inside the package, or when the tab
113
- * is narrower than the label text it has to hold.
114
- */
115
- function countLabelOverlap(model, layout) {
116
- const frames = new Map((layout.packages ?? []).map((p) => [p.id, p]));
117
- const nodes = new Map(layout.nodes.map((n) => [n.id, n]));
118
- let count = 0;
119
- for (const pkg of model.packages) {
120
- const frame = frames.get(pkg.id);
121
- if (!frame)
122
- continue;
123
- const textWidth = measureTextWidth(pkg.name, PACKAGE_LABEL_FONT_SIZE);
124
- const wanted = textWidth + PACKAGE_TAB_PADDING;
125
- const tab = {
126
- x: frame.x,
127
- y: frame.y,
128
- width: Math.min(frame.width, Math.max(24, wanted)),
129
- height: PACKAGE_TAB_HEIGHT,
130
- };
131
- let bad = textWidth + 6 > tab.width + 0.01;
132
- for (const entity of model.entities) {
133
- if (bad)
134
- break;
135
- if (entity.packageId !== pkg.id)
136
- continue;
137
- const node = nodes.get(entity.id);
138
- if (node && rectsOverlap(tab, nodeRect(node)))
139
- bad = true;
140
- }
141
- for (const child of model.packages) {
142
- if (bad)
143
- break;
144
- if (child.parentId !== pkg.id)
145
- continue;
146
- const childFrame = frames.get(child.id);
147
- if (childFrame && rectsOverlap(tab, childFrame))
148
- bad = true;
149
- }
150
- if (bad)
151
- count++;
152
- }
153
- return count;
154
- }
155
- /**
156
- * Summed length of collinear, overlapping segments from different edges. Two
157
- * axis-aligned segments that lie on the same line and overlap are a
158
- * "parallel-line wall": they never produce a proper crossing (so
159
- * [[countEdgeCrossings]] misses them) yet still read as clutter. Each
160
- * (segment, segment) overlap is counted once, in canvas pixels.
161
- */
162
- function countOverlappingEdgeLength(polylines) {
163
- const segs = [];
164
- polylines.forEach((pts, edge) => {
165
- for (let i = 0; i + 1 < pts.length; i++) {
166
- segs.push({ a: pts[i], b: pts[i + 1], edge });
167
- }
168
- });
169
- let total = 0;
170
- for (let i = 0; i < segs.length; i++) {
171
- for (let j = i + 1; j < segs.length; j++) {
172
- if (segs[i].edge === segs[j].edge)
173
- continue;
174
- total += collinearOverlapLength(segs[i].a, segs[i].b, segs[j].a, segs[j].b);
175
- }
176
- }
177
- return total;
178
- }
179
- /** Overlap length of two segments when collinear, else 0 (in canvas pixels). */
180
- function collinearOverlapLength(a, b, c, d) {
181
- const ux = b.x - a.x;
182
- const uy = b.y - a.y;
183
- const vx = d.x - c.x;
184
- const vy = d.y - c.y;
185
- const lenU = Math.hypot(ux, uy);
186
- const lenV = Math.hypot(vx, vy);
187
- if (lenU < 1e-9 || lenV < 1e-9)
188
- return 0;
189
- // Parallel: the two direction vectors must be colinear.
190
- if (Math.abs(ux * vy - uy * vx) > 1e-9 * lenU * lenV)
191
- return 0;
192
- // Colinear: `c` must lie on the line through `a`/`b` (perpendicular distance).
193
- const distC = Math.abs(ux * (c.y - a.y) - uy * (c.x - a.x)) / lenU;
194
- if (distC > 0.01)
195
- return 0;
196
- const uxN = ux / lenU;
197
- const uyN = uy / lenU;
198
- const proj = (p) => (p.x - a.x) * uxN + (p.y - a.y) * uyN;
199
- const t0 = Math.min(proj(a), proj(b));
200
- const t1 = Math.max(proj(a), proj(b));
201
- const s0 = Math.min(proj(c), proj(d));
202
- const s1 = Math.max(proj(c), proj(d));
203
- const overlap = Math.min(t1, s1) - Math.max(t0, s0);
204
- return overlap > 0 ? overlap : 0;
205
- }
206
- /**
207
- * Incoming arrowheads are grouped by (target node, entry side). A side that
208
- * receives more than [[HUB_ARROW_STACK_THRESHOLD]] arrowheads contributes its
209
- * excess to the total — the "everyone points at this hub from the same side"
210
- * pile-up, invisible to [[countEdgeCrossings]].
211
- */
212
- const HUB_ARROW_STACK_THRESHOLD = 3;
213
- function countHubArrowStack(edges) {
214
- const groups = new Map();
215
- for (const edge of edges) {
216
- const pts = edge.points;
217
- if (pts.length < 2)
218
- continue;
219
- const side = approachSide(pts[pts.length - 2], pts[pts.length - 1]);
220
- if (side === undefined)
221
- continue;
222
- const key = `${edge.target}\u0000${side}`;
223
- groups.set(key, (groups.get(key) ?? 0) + 1);
224
- }
225
- let total = 0;
226
- for (const count of groups.values()) {
227
- if (count > HUB_ARROW_STACK_THRESHOLD)
228
- total += count - HUB_ARROW_STACK_THRESHOLD;
229
- }
230
- return total;
231
- }
232
- /** Side of the target the arrowhead enters from, derived from the last segment. */
233
- function approachSide(from, to) {
234
- const dx = to.x - from.x;
235
- const dy = to.y - from.y;
236
- if (Math.abs(dx) < 1e-9 && Math.abs(dy) < 1e-9)
237
- return undefined;
238
- if (Math.abs(dx) >= Math.abs(dy))
239
- return dx > 0 ? 'left' : 'right';
240
- return dy > 0 ? 'top' : 'bottom';
241
- }