@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.
- package/README.md +20 -0
- package/dist/core.d.ts +102 -9
- package/dist/core.js +91 -51
- 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 +59 -0
- package/dist/layout/compound.js +1178 -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 +63 -33
- package/dist/layout/elk.js +215 -321
- 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/layout-metrics.d.ts +183 -0
- package/dist/layout/layout-metrics.js +766 -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/parser.d.ts +27 -1
- package/dist/parser.js +111 -5
- package/dist/render/labels.d.ts +44 -0
- package/dist/render/labels.js +231 -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/serialize.d.ts +32 -0
- package/dist/serialize.js +218 -0
- package/dist/transform.d.ts +55 -1
- package/dist/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/{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 → foundation/render-dot.d.ts} +0 -0
- /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
|
-
|
|
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.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
|
-
"
|
|
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;
|
package/dist/layout-metrics.js
DELETED
|
@@ -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
|
-
}
|