@yalehwang/plantuml-js 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +20 -0
  2. package/dist/core.d.ts +102 -9
  3. package/dist/core.js +91 -51
  4. package/dist/foundation/layers.d.ts +25 -0
  5. package/dist/foundation/layers.js +101 -0
  6. package/dist/foundation/layout.d.ts +116 -0
  7. package/dist/foundation/layout.js +298 -0
  8. package/dist/{model.d.ts → foundation/model.d.ts} +20 -0
  9. package/dist/foundation/relation-semantics.d.ts +49 -0
  10. package/dist/foundation/relation-semantics.js +112 -0
  11. package/dist/{text-measure.d.ts → foundation/text-measure.d.ts} +2 -0
  12. package/dist/{text-measure.js → foundation/text-measure.js} +20 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.js +1 -1
  15. package/dist/layout/compound.d.ts +59 -0
  16. package/dist/layout/compound.js +1178 -0
  17. package/dist/layout/elk-graph.d.ts +36 -0
  18. package/dist/layout/elk-graph.js +220 -0
  19. package/dist/layout/elk-result.d.ts +18 -0
  20. package/dist/layout/elk-result.js +127 -0
  21. package/dist/layout/elk.d.ts +63 -33
  22. package/dist/layout/elk.js +215 -321
  23. package/dist/layout/fanout-trunk.d.ts +18 -0
  24. package/dist/layout/fanout-trunk.js +380 -0
  25. package/dist/layout/grid-router.d.ts +69 -0
  26. package/dist/layout/grid-router.js +615 -0
  27. package/dist/layout/lane-spread.d.ts +11 -0
  28. package/dist/layout/lane-spread.js +302 -0
  29. package/dist/layout/layout-metrics.d.ts +183 -0
  30. package/dist/layout/layout-metrics.js +766 -0
  31. package/dist/layout/package-endpoints.d.ts +10 -0
  32. package/dist/layout/package-endpoints.js +54 -0
  33. package/dist/layout/placement-strategy.d.ts +98 -0
  34. package/dist/layout/placement-strategy.js +26 -0
  35. package/dist/layout/strategy.d.ts +27 -0
  36. package/dist/layout/strategy.js +19 -0
  37. package/dist/layout/trunk-lanes.d.ts +29 -0
  38. package/dist/layout/trunk-lanes.js +308 -0
  39. package/dist/layout/viz.d.ts +3 -3
  40. package/dist/layout/viz.js +13 -17
  41. package/dist/parser.d.ts +27 -1
  42. package/dist/parser.js +111 -5
  43. package/dist/render/labels.d.ts +44 -0
  44. package/dist/render/labels.js +231 -0
  45. package/dist/render/legend.d.ts +15 -0
  46. package/dist/render/legend.js +42 -0
  47. package/dist/render/svg.d.ts +60 -3
  48. package/dist/render/svg.js +326 -98
  49. package/dist/render/theme.d.ts +109 -0
  50. package/dist/render/theme.js +99 -0
  51. package/dist/serialize.d.ts +32 -0
  52. package/dist/serialize.js +218 -0
  53. package/dist/transform.d.ts +55 -1
  54. package/dist/transform.js +277 -29
  55. package/package.json +4 -3
  56. package/dist/layout-metrics.d.ts +0 -42
  57. package/dist/layout-metrics.js +0 -241
  58. package/dist/layout.d.ts +0 -75
  59. package/dist/layout.js +0 -51
  60. /package/dist/{edge-router.d.ts → foundation/edge-router.d.ts} +0 -0
  61. /package/dist/{edge-router.js → foundation/edge-router.js} +0 -0
  62. /package/dist/{model.js → foundation/model.js} +0 -0
  63. /package/dist/{render-dot.d.ts → foundation/render-dot.d.ts} +0 -0
  64. /package/dist/{render-dot.js → foundation/render-dot.js} +0 -0
@@ -0,0 +1,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
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * PlantUmlModel → PlantUML class-diagram text.
3
+ *
4
+ * The inverse of [[parsePlantUml]]: `toPlantUml(renderDetailed(...).model)`
5
+ * reproduces the diagram that was actually drawn (after view/transform/detail)
6
+ * as source a user can copy and paste. Pure and synchronous — the model is the
7
+ * only input, so the caller composes the scope by picking the model
8
+ * (`toPlantUml(applyView(model, {kind: 'neighborhood', entity, hops}))`); this
9
+ * function never chooses a range on its own.
10
+ *
11
+ * Shapes that only exist after parsing (stub/external/usageCount, aggregated
12
+ * edge weights, cycle markers) are rendering semantics with no PlantUML
13
+ * spelling; they serialize as their plain-text equivalent (stub → class,
14
+ * weighted edge → one edge). The parts that do have a spelling — packages
15
+ * (nested), entity kind/generics/alias, member rows, relation arrow/label/
16
+ * cardinality — round-trip back through [[parsePlantUml]] unchanged.
17
+ *
18
+ * The displayed name is always emitted quoted (`class "<<function>> halt"
19
+ * as n0`), so names carrying a stereotype prefix or `/` path separators survive
20
+ * the round trip as authored.
21
+ */
22
+ import type { PlantUmlModel } from './foundation/model.js';
23
+ /**
24
+ * Serialize a model to `@startuml … @enduml` class-diagram source.
25
+ *
26
+ * Emitted layout: nested `package` blocks first (each holding the entities that
27
+ * carry its `packageId`), then package-less entities, then every relation.
28
+ * Entities always use the quoted form with an explicit alias, which is the only
29
+ * form that carries a display name containing whitespace, `/`, or a
30
+ * `<<stereotype>>` prefix.
31
+ */
32
+ export declare function toPlantUml(model: PlantUmlModel): string;
@@ -0,0 +1,218 @@
1
+ /**
2
+ * PlantUmlModel → PlantUML class-diagram text.
3
+ *
4
+ * The inverse of [[parsePlantUml]]: `toPlantUml(renderDetailed(...).model)`
5
+ * reproduces the diagram that was actually drawn (after view/transform/detail)
6
+ * as source a user can copy and paste. Pure and synchronous — the model is the
7
+ * only input, so the caller composes the scope by picking the model
8
+ * (`toPlantUml(applyView(model, {kind: 'neighborhood', entity, hops}))`); this
9
+ * function never chooses a range on its own.
10
+ *
11
+ * Shapes that only exist after parsing (stub/external/usageCount, aggregated
12
+ * edge weights, cycle markers) are rendering semantics with no PlantUML
13
+ * spelling; they serialize as their plain-text equivalent (stub → class,
14
+ * weighted edge → one edge). The parts that do have a spelling — packages
15
+ * (nested), entity kind/generics/alias, member rows, relation arrow/label/
16
+ * cardinality — round-trip back through [[parsePlantUml]] unchanged.
17
+ *
18
+ * The displayed name is always emitted quoted (`class "<<function>> halt"
19
+ * as n0`), so names carrying a stereotype prefix or `/` path separators survive
20
+ * the round trip as authored.
21
+ */
22
+ /** A bare PlantUML identifier (what `as <alias>` accepts). */
23
+ const PLANTUML_ID = /^[A-Za-z_][A-Za-z0-9_]*$/;
24
+ const INDENT = ' ';
25
+ const VISIBILITY_SYMBOL = {
26
+ public: '+',
27
+ private: '-',
28
+ protected: '#',
29
+ 'package-private': '~',
30
+ };
31
+ /**
32
+ * One arrow per relation type, written in canonical source→target direction.
33
+ * The parser's own arrow table also accepts the swapped mirror forms
34
+ * (`<|--`, `--o`, …); emitting the canonical form is what makes the round trip
35
+ * stable.
36
+ */
37
+ const RELATION_ARROW = {
38
+ inheritance: '--|>',
39
+ implementation: '..|>',
40
+ dependency: '..>',
41
+ association: '-->',
42
+ aggregation: 'o--',
43
+ composition: '*--',
44
+ };
45
+ /**
46
+ * Serialize a model to `@startuml … @enduml` class-diagram source.
47
+ *
48
+ * Emitted layout: nested `package` blocks first (each holding the entities that
49
+ * carry its `packageId`), then package-less entities, then every relation.
50
+ * Entities always use the quoted form with an explicit alias, which is the only
51
+ * form that carries a display name containing whitespace, `/`, or a
52
+ * `<<stereotype>>` prefix.
53
+ */
54
+ export function toPlantUml(model) {
55
+ const entities = new AliasBook('entity');
56
+ const packages = new AliasBook('package');
57
+ const lines = ['@startuml'];
58
+ const nodes = packageNodes(model);
59
+ const emitted = new Set();
60
+ for (const node of nodes.roots)
61
+ emitPackage(node, 0, lines, entities, packages, emitted);
62
+ // Defensive: a parentId cycle leaves nodes unreachable from any root; emit
63
+ // each remaining package as a root rather than silently dropping it.
64
+ for (const node of nodes.all) {
65
+ if (!emitted.has(node.pkg.id))
66
+ emitPackage(node, 0, lines, entities, packages, emitted);
67
+ }
68
+ for (const entity of nodes.topLevel) {
69
+ lines.push(...entityLines(entity, 0, entities));
70
+ }
71
+ for (const relation of model.relations) {
72
+ lines.push(relationLine(relation, entities));
73
+ }
74
+ lines.push('@enduml');
75
+ return lines.join('\n');
76
+ }
77
+ /**
78
+ * Index packages into a tree, attaching each entity to the innermost package it
79
+ * names. An entity whose `packageId` names no declared package is emitted at
80
+ * the top level instead of being dropped.
81
+ */
82
+ function packageNodes(model) {
83
+ const byId = new Map();
84
+ const all = [];
85
+ for (const pkg of model.packages) {
86
+ // A duplicated id keeps its first package; the parser never produces one.
87
+ if (byId.has(pkg.id))
88
+ continue;
89
+ const node = { pkg, children: [], entities: [] };
90
+ byId.set(pkg.id, node);
91
+ all.push(node);
92
+ }
93
+ const roots = [];
94
+ for (const node of all) {
95
+ const parentId = node.pkg.parentId;
96
+ const parent = parentId === undefined ? undefined : byId.get(parentId);
97
+ if (parent !== undefined && parent !== node)
98
+ parent.children.push(node);
99
+ else
100
+ roots.push(node);
101
+ }
102
+ const topLevel = [];
103
+ for (const entity of model.entities) {
104
+ const owner = entity.packageId === undefined ? undefined : byId.get(entity.packageId);
105
+ if (owner !== undefined)
106
+ owner.entities.push(entity);
107
+ else
108
+ topLevel.push(entity);
109
+ }
110
+ return { roots, all, topLevel };
111
+ }
112
+ function emitPackage(node, depth, lines, entities, packages, emitted) {
113
+ if (emitted.has(node.pkg.id))
114
+ return;
115
+ emitted.add(node.pkg.id);
116
+ const indent = INDENT.repeat(depth);
117
+ const alias = packages.alias(node.pkg.id, node.pkg.name);
118
+ lines.push(`${indent}package ${quoteName(node.pkg.name)} as ${alias} {`);
119
+ for (const entity of node.entities) {
120
+ lines.push(...entityLines(entity, depth + 1, entities));
121
+ }
122
+ for (const child of node.children) {
123
+ emitPackage(child, depth + 1, lines, entities, packages, emitted);
124
+ }
125
+ lines.push(`${indent}}`);
126
+ }
127
+ function entityKeyword(entity) {
128
+ return entity.kind === 'abstract' ? 'abstract class' : entity.kind;
129
+ }
130
+ function entityLines(entity, depth, aliases) {
131
+ const indent = INDENT.repeat(depth);
132
+ const alias = aliases.alias(entity.id, entity.name);
133
+ const head = `${entityKeyword(entity)} ${quoteName(entity.name)}${genericSuffix(entity)} as ${alias}`;
134
+ if (entity.members.length === 0)
135
+ return [`${indent}${head}`];
136
+ const lines = [`${indent}${head} {`];
137
+ for (const member of entity.members) {
138
+ lines.push(`${indent}${INDENT}${memberLine(member)}`);
139
+ }
140
+ lines.push(`${indent}}`);
141
+ return lines;
142
+ }
143
+ function genericSuffix(entity) {
144
+ const generics = entity.generics ?? [];
145
+ return generics.length === 0 ? '' : `~${generics.join(',')}~`;
146
+ }
147
+ /**
148
+ * One member row. The authored `raw` text is authoritative (it carries the
149
+ * visibility symbol and the exact signature), except for synthesized rows —
150
+ * the detail pipeline's `… +N more (M members)` truncation marker has no
151
+ * visibility symbol, so it gets one prefixed to stay parseable as a member
152
+ * row while keeping the marker text verbatim.
153
+ */
154
+ function memberLine(member) {
155
+ const raw = (member.raw ?? '').trim();
156
+ const symbol = VISIBILITY_SYMBOL[member.visibility] ?? '+';
157
+ if (raw === '')
158
+ return symbol;
159
+ if (/^[+\-#~]/.test(raw))
160
+ return raw;
161
+ return `${symbol} ${raw}`;
162
+ }
163
+ function relationLine(relation, aliases) {
164
+ const source = aliases.alias(relation.source, relation.source);
165
+ const target = aliases.alias(relation.target, relation.target);
166
+ const parts = [source];
167
+ if (relation.sourceCardinality !== undefined)
168
+ parts.push(quoteName(relation.sourceCardinality));
169
+ parts.push(RELATION_ARROW[relation.type] ?? '-->');
170
+ if (relation.targetCardinality !== undefined)
171
+ parts.push(quoteName(relation.targetCardinality));
172
+ parts.push(target);
173
+ let line = parts.join(' ');
174
+ if (relation.label !== undefined && relation.label !== '') {
175
+ line += ` : ${relation.label.replace(/[\r\n]+/g, ' ')}`;
176
+ }
177
+ return line;
178
+ }
179
+ /** Quote a display name; a `"` inside a PlantUML name is written as `'`. */
180
+ function quoteName(name) {
181
+ const safe = name.replace(/[\r\n\t]+/g, ' ').replace(/"/g, "'");
182
+ return `"${safe}"`;
183
+ }
184
+ /**
185
+ * Deterministic, collision-free alias assignment, in emission order. A
186
+ * plant-legal id is kept verbatim (so a parsed model round-trips its ids); an
187
+ * id PlantUML cannot spell (view-internal ids such as `ov::src`, `stub::x`)
188
+ * falls back to an identifier derived from the display name.
189
+ */
190
+ class AliasBook {
191
+ used = new Set();
192
+ byId = new Map();
193
+ fallback;
194
+ constructor(fallback) {
195
+ this.fallback = fallback;
196
+ }
197
+ alias(id, name) {
198
+ const existing = this.byId.get(id);
199
+ if (existing !== undefined)
200
+ return existing;
201
+ const base = PLANTUML_ID.test(id) ? id : identifierFrom(name, this.fallback);
202
+ let alias = base;
203
+ let suffix = 2;
204
+ while (this.used.has(alias))
205
+ alias = `${base}_${suffix++}`;
206
+ this.used.add(alias);
207
+ this.byId.set(id, alias);
208
+ return alias;
209
+ }
210
+ }
211
+ /** Derive a bare identifier from a display name (`Src Layer` → `Src_Layer`). */
212
+ function identifierFrom(name, fallback) {
213
+ const cleaned = name
214
+ .replace(/[^A-Za-z0-9_]+/g, '_')
215
+ .replace(/^_+|_+$/g, '');
216
+ const base = cleaned === '' ? fallback : cleaned;
217
+ return /^[A-Za-z_]/.test(base) ? base : `_${base}`;
218
+ }
@@ -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'>;