@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,99 @@
1
+ /**
2
+ * Theme — every visual decision of the SVG renderer as a token. `svg.ts` and
3
+ * `legend.ts` read only from a resolved Theme, so no colour, size or dash
4
+ * pattern lives as a literal in drawing code. Rationale and the font decision
5
+ * are recorded in docs/visual-design.md.
6
+ */
7
+ export const defaultTheme = {
8
+ font: {
9
+ family: 'monospace',
10
+ size: { title: 13, member: 12, packageLabel: 12, edgeLabel: 11, badge: 11 },
11
+ weight: { title: 'bold', member: 'normal', packageLabel: 'bold' },
12
+ },
13
+ color: {
14
+ canvas: '#ffffff',
15
+ stroke: '#64748b',
16
+ strokeStub: '#7c8ba1',
17
+ nodeFill: '#ffffff',
18
+ stubFill: '#f1f5f9',
19
+ textTitle: '#1e293b',
20
+ textMember: '#475569',
21
+ textPackage: '#334155',
22
+ edge: '#5b6a80',
23
+ edgeWeak: '#566479',
24
+ labelHalo: '#ffffff',
25
+ warn: '#3b0a0a',
26
+ accent: '#475569',
27
+ accentText: '#ffffff',
28
+ packageFills: ['#f8fafc', '#f1f5f9', '#eef2f7', '#e8eef5'],
29
+ packageStroke: '#94a3b8',
30
+ legendStroke: '#94a3b8',
31
+ },
32
+ shape: { nodeRadius: 6, packageRadius: 8, edgeCornerRadius: 6 },
33
+ stroke: {
34
+ node: 1,
35
+ package: 1,
36
+ edge: 1,
37
+ arrow: 1,
38
+ weightStep: 0.5,
39
+ edgeWidthMax: 4,
40
+ feedback: 1.5,
41
+ heavyWeight: 3,
42
+ opacity: { light: 0.8, medium: 0.9, strong: 1, feedback: 0.95 },
43
+ edgeNonScaling: true,
44
+ labelHalo: 3,
45
+ },
46
+ dash: { weak: '6,3', stub: '5,3', feedback: '10,3,2,3' },
47
+ arrow: { triangle: 12, chevron: 10, diamond: 14 },
48
+ grid: 8,
49
+ };
50
+ /** Merge a partial theme over the defaults (arrays replace, objects merge). */
51
+ export function resolveTheme(overrides) {
52
+ return mergeDeep(defaultTheme, overrides ?? {});
53
+ }
54
+ function mergeDeep(base, over) {
55
+ if (over === undefined)
56
+ return base;
57
+ if (typeof base === 'object' && base !== null && !Array.isArray(base) &&
58
+ typeof over === 'object' && over !== null && !Array.isArray(over)) {
59
+ const out = {};
60
+ const b = base;
61
+ const o = over;
62
+ for (const key of Object.keys(b))
63
+ out[key] = mergeDeep(b[key], o[key]);
64
+ return out;
65
+ }
66
+ return over;
67
+ }
68
+ /** Every colour value of a theme (for token-membership assertions). */
69
+ export function themeColors(theme) {
70
+ const out = new Set();
71
+ for (const v of Object.values(theme.color)) {
72
+ if (Array.isArray(v))
73
+ v.forEach((c) => out.add(c));
74
+ else
75
+ out.add(v);
76
+ }
77
+ return out;
78
+ }
79
+ /** `fg` at `alpha` composited over `bg` (both `#rrggbb`), as a `#rrggbb` colour. */
80
+ export function blendOver(fg, bg, alpha) {
81
+ const f = parseInt(fg.slice(1), 16);
82
+ const b = parseInt(bg.slice(1), 16);
83
+ const mix = (shift) => Math.round(alpha * ((f >> shift) & 255) + (1 - alpha) * ((b >> shift) & 255));
84
+ return `#${((mix(16) << 16) | (mix(8) << 8) | mix(0)).toString(16).padStart(6, '0')}`;
85
+ }
86
+ /** WCAG relative luminance contrast ratio between two `#rrggbb` colours. */
87
+ export function contrastRatio(a, b) {
88
+ const la = luminance(a);
89
+ const lb = luminance(b);
90
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
91
+ }
92
+ function luminance(hex) {
93
+ const n = parseInt(hex.slice(1), 16);
94
+ const ch = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => {
95
+ const s = v / 255;
96
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
97
+ });
98
+ return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
99
+ }
@@ -9,7 +9,7 @@
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 { PlantUmlModel } from './model.js';
12
+ import { PlantUmlModel } from '../foundation/model.js';
13
13
  /** Options driving the model-transform pipeline. */
14
14
  export interface TransformOptions {
15
15
  /**
@@ -25,6 +25,12 @@ export interface TransformOptions {
25
25
  * hub summarization. Default undefined.
26
26
  */
27
27
  hubThreshold?: number;
28
+ /**
29
+ * `'aggregate'` folds all relations between two packages into one
30
+ * box-to-box weighted corridor edge; `'raw'` keeps every edge. Default:
31
+ * `'aggregate'` above [[INTER_PACKAGE_AGGREGATE_ENTITY_THRESHOLD]] entities.
32
+ */
33
+ interPackage?: 'aggregate' | 'raw';
28
34
  /** Detect cycles and mark one edge per cycle as a `feedback` arc. Default false. */
29
35
  cycles?: boolean;
30
36
  }
@@ -44,19 +50,59 @@ export type ViewSpec = {
44
50
  } | {
45
51
  kind: 'package';
46
52
  id: string;
53
+ stubGroupThreshold?: number;
47
54
  } | {
48
55
  kind: 'neighborhood';
49
56
  entity: string;
50
57
  hops: number;
51
58
  };
59
+ /** Entity count above which inter-package edges aggregate into corridors by default. */
60
+ export declare const INTER_PACKAGE_AGGREGATE_ENTITY_THRESHOLD = 150;
61
+ /** Stub count above which a single-package view groups stubs by name prefix. */
62
+ export declare const DEFAULT_STUB_GROUP_THRESHOLD = 8;
52
63
  /**
53
64
  * Run the transform pipeline over a model. Order matters: aggregation first
54
65
  * (so transitive reduction sees one edge per pair), then reduction, then hub
55
66
  * summarization, then cycle marking on the final edge set.
56
67
  */
57
68
  export declare function transformModel(model: PlantUmlModel, options?: TransformOptions): PlantUmlModel;
69
+ /** [[transformModel]] plus the number of edges the transitive reduction hid. */
70
+ export declare function transformModelDetailed(model: PlantUmlModel, options?: TransformOptions): {
71
+ model: PlantUmlModel;
72
+ hiddenTransitive: number;
73
+ };
74
+ /** Edge count ÷ node count above which `edgeReduction: 'auto'` reduces a pure dependency graph. */
75
+ export declare const AUTO_REDUCTION_EDGE_NODE_RATIO = 3;
76
+ /**
77
+ * Whether `edgeReduction: 'auto'` applies: every relation is bundlable
78
+ * (dependency/association) and there are more than
79
+ * [[AUTO_REDUCTION_EDGE_NODE_RATIO]] times as many edges as nodes.
80
+ */
81
+ export declare function shouldAutoReduce(model: PlantUmlModel): boolean;
82
+ /** Incoming edges at which an external type's edges collapse into a count badge. */
83
+ export declare const EXTERNAL_SUMMARY_MIN_IN_EDGES = 3;
84
+ /** Id of the synthetic package that gathers implicit/external types. */
85
+ export declare const EXTERNAL_PACKAGE_ID = "__external__";
86
+ /**
87
+ * Implicit/external types (`Error`, `EventEmitter`, …) receive many parallel
88
+ * edges that would fly across the whole canvas. Once one has `minEdges` or more
89
+ * incoming relations they are dropped and replaced by a `usageCount` badge that
90
+ * carries the original incoming-edge count.
91
+ */
92
+ export declare function summarizeExternals(model: PlantUmlModel, minEdges: number): PlantUmlModel;
93
+ /** Gather top-level external entities into one synthetic External package. */
94
+ export declare function groupExternals(model: PlantUmlModel): PlantUmlModel;
58
95
  /** Merge relations sharing (source, target) into one edge with `weight`. */
59
96
  export declare function aggregateRelations(model: PlantUmlModel): PlantUmlModel;
97
+ /**
98
+ * Fold every relation between two different packages into one corridor edge
99
+ * per ordered package pair: `weight` = number of originals, `descriptions`
100
+ * keeps each original, and the endpoints are the entities carrying most of the
101
+ * traffic with `sourcePackageId`/`targetPackageId` set so the layout clips the
102
+ * edge to the frame boundaries. Intra-package edges and edges touching
103
+ * package-less entities, or between nested packages, are left as they are.
104
+ */
105
+ export declare function aggregateInterPackage(model: PlantUmlModel): PlantUmlModel;
60
106
  /**
61
107
  * Remove a relation A→B when A still reaches B through another path (an edge
62
108
  * derivable by transitivity). Only the redundant direct edge is dropped; the
@@ -90,3 +136,11 @@ export declare function applyView(model: PlantUmlModel, view?: ViewSpec): PlantU
90
136
  * edges are dropped (they do not cross the collapsed boundary).
91
137
  */
92
138
  export declare function groupByPathPrefix(model: PlantUmlModel, depth: number): PlantUmlModel;
139
+ /**
140
+ * Past `threshold` stubs, merge stubs sharing a name prefix into one group stub
141
+ * (`Src Cli … (×6)`), dropping trailing words until the group count fits (or nothing shortens any
142
+ * more). The edges to the members of a group collapse onto
143
+ * the group stub per (entity, direction): `weight` counts the originals and
144
+ * `descriptions` keeps each one for the edge's `<title>`.
145
+ */
146
+ export declare function groupStubs(model: Pick<PlantUmlModel, 'entities' | 'relations'>, threshold: number): Pick<PlantUmlModel, 'entities' | 'relations'>;
@@ -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.1",
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;