@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.
- package/README.md +20 -0
- package/dist/core.d.ts +106 -13
- package/dist/core.js +92 -52
- package/dist/foundation/labels.d.ts +44 -0
- package/dist/foundation/labels.js +231 -0
- package/dist/foundation/layers.d.ts +25 -0
- package/dist/foundation/layers.js +101 -0
- package/dist/foundation/layout.d.ts +116 -0
- package/dist/foundation/layout.js +298 -0
- package/dist/{model.d.ts → foundation/model.d.ts} +20 -0
- package/dist/foundation/relation-semantics.d.ts +49 -0
- package/dist/foundation/relation-semantics.js +112 -0
- package/dist/{text-measure.d.ts → foundation/text-measure.d.ts} +2 -0
- package/dist/{text-measure.js → foundation/text-measure.js} +20 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/layout/compound.d.ts +74 -0
- package/dist/layout/compound.js +1204 -0
- package/dist/layout/elk-graph.d.ts +36 -0
- package/dist/layout/elk-graph.js +220 -0
- package/dist/layout/elk-result.d.ts +18 -0
- package/dist/layout/elk-result.js +127 -0
- package/dist/layout/elk.d.ts +80 -33
- package/dist/layout/elk.js +229 -320
- package/dist/layout/fanout-trunk.d.ts +18 -0
- package/dist/layout/fanout-trunk.js +380 -0
- package/dist/layout/grid-router.d.ts +69 -0
- package/dist/layout/grid-router.js +615 -0
- package/dist/layout/lane-spread.d.ts +11 -0
- package/dist/layout/lane-spread.js +302 -0
- package/dist/layout/package-endpoints.d.ts +10 -0
- package/dist/layout/package-endpoints.js +54 -0
- package/dist/layout/placement-strategy.d.ts +98 -0
- package/dist/layout/placement-strategy.js +26 -0
- package/dist/layout/strategy.d.ts +27 -0
- package/dist/layout/strategy.js +19 -0
- package/dist/layout/trunk-lanes.d.ts +29 -0
- package/dist/layout/trunk-lanes.js +308 -0
- package/dist/layout/viz.d.ts +3 -3
- package/dist/layout/viz.js +13 -17
- package/dist/metrics/layout-metrics.d.ts +183 -0
- package/dist/metrics/layout-metrics.js +766 -0
- package/dist/parse/parser.d.ts +43 -0
- package/dist/{parser.js → parse/parser.js} +111 -5
- package/dist/parse/serialize.d.ts +32 -0
- package/dist/parse/serialize.js +218 -0
- package/dist/render/legend.d.ts +15 -0
- package/dist/render/legend.js +42 -0
- package/dist/render/svg.d.ts +60 -3
- package/dist/render/svg.js +326 -98
- package/dist/render/theme.d.ts +109 -0
- package/dist/render/theme.js +99 -0
- package/dist/{transform.d.ts → transform/transform.d.ts} +55 -1
- package/dist/{transform.js → transform/transform.js} +277 -29
- package/package.json +4 -3
- package/dist/layout-metrics.d.ts +0 -42
- package/dist/layout-metrics.js +0 -241
- package/dist/layout.d.ts +0 -75
- package/dist/layout.js +0 -51
- package/dist/parser.d.ts +0 -17
- /package/dist/{edge-router.d.ts → foundation/edge-router.d.ts} +0 -0
- /package/dist/{edge-router.js → foundation/edge-router.js} +0 -0
- /package/dist/{model.js → foundation/model.js} +0 -0
- /package/dist/{render-dot.d.ts → layout/render-dot.d.ts} +0 -0
- /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 '
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const reachable = (start, goal
|
|
69
|
-
const seen = new Set();
|
|
70
|
-
const
|
|
71
|
-
while (
|
|
72
|
-
const u =
|
|
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
|
-
|
|
232
|
+
stack.push(v);
|
|
81
233
|
}
|
|
82
234
|
}
|
|
83
235
|
}
|
|
84
236
|
return false;
|
|
85
237
|
};
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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.
|
|
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
|
-
"
|
|
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",
|
package/dist/layout-metrics.d.ts
DELETED
|
@@ -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;
|