@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,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trunk lanes. A router that has to dodge a cluster of nodes can leave an edge
|
|
3
|
+
* that overshoots — out sideways, down, back across, down again — and stacked
|
|
4
|
+
* edges doing this draw the concentric rectangles of a coil. When the two end
|
|
5
|
+
* segments run the same axis, the edge is re-drawn as a Z: leave the source,
|
|
6
|
+
* cross over on a lane between the rows (the gap between the two end points),
|
|
7
|
+
* and enter the target. It runs after lane spreading and fan-out merging, so only
|
|
8
|
+
* what those passes left as an overshoot is touched. A Z replaces a detour only when it is shorter, passes
|
|
9
|
+
* no unrelated node or package frame, and adds at most [[EXTRA_CROSSINGS]] crossings.
|
|
10
|
+
*/
|
|
11
|
+
import { routeOrthogonal } from './grid-router.js';
|
|
12
|
+
import { findParallelBundles } from '../metrics/layout-metrics.js';
|
|
13
|
+
/** Path length ÷ Manhattan distance above which an edge is considered for a Z. */
|
|
14
|
+
const DETOUR_FACTOR = 2;
|
|
15
|
+
/** Clearance between a Z segment and an unrelated node box. */
|
|
16
|
+
const NODE_CLEARANCE = 10;
|
|
17
|
+
/** The lane must sit at least this far from both end points along the main axis. */
|
|
18
|
+
const END_MARGIN = 14;
|
|
19
|
+
/** Extra crossings a straightened route may add: a few crossings read better than a coil. */
|
|
20
|
+
const EXTRA_CROSSINGS = 2;
|
|
21
|
+
/** Spacing of the candidate lanes tried between the end points. */
|
|
22
|
+
const LANE_STEP = 10;
|
|
23
|
+
/** Spacing of the candidate channels tried for an overshooting S-route. */
|
|
24
|
+
const CHANNEL_STEP = 6;
|
|
25
|
+
const length = (pts) => pts.slice(1).reduce((sum, p, i) => sum + Math.abs(p.x - pts[i].x) + Math.abs(p.y - pts[i].y), 0);
|
|
26
|
+
/** Segment touches the box inflated by `pad` (axis-aligned segments only). */
|
|
27
|
+
function hitsBox(a, b, r, pad) {
|
|
28
|
+
const x0 = Math.min(a.x, b.x);
|
|
29
|
+
const x1 = Math.max(a.x, b.x);
|
|
30
|
+
const y0 = Math.min(a.y, b.y);
|
|
31
|
+
const y1 = Math.max(a.y, b.y);
|
|
32
|
+
return x1 > r.x - pad && x0 < r.x + r.width + pad && y1 > r.y - pad && y0 < r.y + r.height + pad;
|
|
33
|
+
}
|
|
34
|
+
function crosses(a, b, c, d) {
|
|
35
|
+
const o = (p, q, r) => Math.sign((q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x));
|
|
36
|
+
return o(a, b, c) * o(a, b, d) < 0 && o(c, d, a) * o(c, d, b) < 0;
|
|
37
|
+
}
|
|
38
|
+
function segmentsOf(pts) {
|
|
39
|
+
return pts.slice(1).map((p, i) => [pts[i], p]);
|
|
40
|
+
}
|
|
41
|
+
function crossingCount(route, others) {
|
|
42
|
+
let n = 0;
|
|
43
|
+
for (const s of segmentsOf(route)) {
|
|
44
|
+
for (const other of others)
|
|
45
|
+
for (const t of segmentsOf(other))
|
|
46
|
+
if (crosses(s[0], s[1], t[0], t[1]))
|
|
47
|
+
n++;
|
|
48
|
+
}
|
|
49
|
+
return n;
|
|
50
|
+
}
|
|
51
|
+
const centreInside = (n, f) => {
|
|
52
|
+
const cx = n.x + n.width / 2;
|
|
53
|
+
const cy = n.y + n.height / 2;
|
|
54
|
+
return cx > f.x && cx < f.x + f.width && cy > f.y && cy < f.y + f.height;
|
|
55
|
+
};
|
|
56
|
+
/** Z-route with its lane at `lane` on the main axis; `vertical` = the end segments are vertical. */
|
|
57
|
+
function zRoute(a, b, lane, vertical) {
|
|
58
|
+
return vertical
|
|
59
|
+
? [a, { x: a.x, y: lane }, { x: b.x, y: lane }, b]
|
|
60
|
+
: [a, { x: lane, y: a.y }, { x: lane, y: b.y }, b];
|
|
61
|
+
}
|
|
62
|
+
export function straightenTrunkDetours(layout) {
|
|
63
|
+
const nodeById = new Map(layout.nodes.map((n) => [n.id, n]));
|
|
64
|
+
const frames = layout.packages ?? [];
|
|
65
|
+
const routes = layout.edges.map((e) => e.points);
|
|
66
|
+
let changed = false;
|
|
67
|
+
layout.edges.forEach((edge, index) => {
|
|
68
|
+
const pts = edge.points;
|
|
69
|
+
if (pts.length < 5)
|
|
70
|
+
return;
|
|
71
|
+
const a = pts[0];
|
|
72
|
+
const b = pts[pts.length - 1];
|
|
73
|
+
const manhattan = Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
|
|
74
|
+
if (length(pts) <= DETOUR_FACTOR * Math.max(manhattan, 1))
|
|
75
|
+
return;
|
|
76
|
+
const verticalEnds = Math.abs(pts[0].x - pts[1].x) < 1e-6 && Math.abs(pts[pts.length - 2].x - b.x) < 1e-6;
|
|
77
|
+
const horizontalEnds = Math.abs(pts[0].y - pts[1].y) < 1e-6 && Math.abs(pts[pts.length - 2].y - b.y) < 1e-6;
|
|
78
|
+
if (verticalEnds === horizontalEnds)
|
|
79
|
+
return;
|
|
80
|
+
const vertical = verticalEnds;
|
|
81
|
+
const from = vertical ? a.y : a.x;
|
|
82
|
+
const to = vertical ? b.y : b.x;
|
|
83
|
+
const lo = Math.min(from, to) + END_MARGIN;
|
|
84
|
+
const hi = Math.max(from, to) - END_MARGIN;
|
|
85
|
+
if (hi < lo)
|
|
86
|
+
return;
|
|
87
|
+
const source = nodeById.get(edge.source);
|
|
88
|
+
const target = nodeById.get(edge.target);
|
|
89
|
+
const endFrames = frames.filter((f) => (source && centreInside(source, f)) || (target && centreInside(target, f)));
|
|
90
|
+
const obstacles = layout.nodes.filter((n) => n.id !== edge.source && n.id !== edge.target);
|
|
91
|
+
const others = routes.filter((_, i) => i !== index);
|
|
92
|
+
const oldCrossings = crossingCount(pts, others);
|
|
93
|
+
const mid = (lo + hi) / 2;
|
|
94
|
+
const lanes = [];
|
|
95
|
+
for (let d = 0; mid - d >= lo || mid + d <= hi; d += LANE_STEP) {
|
|
96
|
+
if (mid - d >= lo)
|
|
97
|
+
lanes.push(mid - d);
|
|
98
|
+
if (d > 0 && mid + d <= hi)
|
|
99
|
+
lanes.push(mid + d);
|
|
100
|
+
}
|
|
101
|
+
const candidates = lanes.map((lane) => zRoute(a, b, lane, vertical));
|
|
102
|
+
if (pts.length === 6) {
|
|
103
|
+
// Overshoot through a gap in the row between: keep the two cross-over
|
|
104
|
+
// lanes and slide the channel to the gap nearest the end points.
|
|
105
|
+
const c0 = vertical ? pts[2].x : pts[2].y;
|
|
106
|
+
const span = [vertical ? a.x : a.y, vertical ? b.x : b.y, c0];
|
|
107
|
+
for (let c = Math.min(...span); c <= Math.max(...span); c += CHANNEL_STEP) {
|
|
108
|
+
candidates.push(vertical
|
|
109
|
+
? [a, pts[1], { x: c, y: pts[1].y }, { x: c, y: pts[3].y }, { x: b.x, y: pts[3].y }, b]
|
|
110
|
+
: [a, pts[1], { x: pts[1].x, y: c }, { x: pts[3].x, y: c }, { x: pts[3].x, y: b.y }, b]);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
let best;
|
|
114
|
+
for (const route of candidates) {
|
|
115
|
+
if (best && length(route) >= length(best.route))
|
|
116
|
+
continue;
|
|
117
|
+
const segs = segmentsOf(route);
|
|
118
|
+
if (obstacles.some((n) => segs.some(([p, q]) => hitsBox(p, q, n, NODE_CLEARANCE))))
|
|
119
|
+
continue;
|
|
120
|
+
// a frame that neither end sits in must stay unentered
|
|
121
|
+
if (frames.some((f) => !endFrames.includes(f) && segs.some(([p, q]) => hitsBox(p, q, f, -1))))
|
|
122
|
+
continue;
|
|
123
|
+
const crossings = crossingCount(route, others);
|
|
124
|
+
if (crossings > oldCrossings + EXTRA_CROSSINGS)
|
|
125
|
+
continue;
|
|
126
|
+
best = { route, crossings };
|
|
127
|
+
}
|
|
128
|
+
if (!best || length(best.route) >= length(pts))
|
|
129
|
+
return;
|
|
130
|
+
routes[index] = best.route;
|
|
131
|
+
changed = true;
|
|
132
|
+
});
|
|
133
|
+
if (!changed)
|
|
134
|
+
return layout;
|
|
135
|
+
return { ...layout, edges: layout.edges.map((e, i) => ({ ...e, points: routes[i] })) };
|
|
136
|
+
}
|
|
137
|
+
/** A segment this close to a side of an end node (and at least [[FLUSH_MIN_LENGTH]] long) hugs the box. */
|
|
138
|
+
const FLUSH_DISTANCE = 4;
|
|
139
|
+
const FLUSH_MIN_LENGTH = 40;
|
|
140
|
+
/** Distances off the node side tried for a lifted segment, nearest first. */
|
|
141
|
+
const FLUSH_LIFTS = Array.from({ length: 36 }, (_, i) => i + 8);
|
|
142
|
+
/** A lifted segment keeps this far from other edges' parallel segments it runs beside. */
|
|
143
|
+
const FLUSH_LANE_CLEARANCE = 9;
|
|
144
|
+
/** Another route has a segment parallel to a–b, closer than [[FLUSH_LANE_CLEARANCE]] and overlapping it in extent. */
|
|
145
|
+
function besideParallel(a, b, other) {
|
|
146
|
+
const horizontal = Math.abs(a.y - b.y) < 1e-6;
|
|
147
|
+
for (let j = 0; j + 1 < other.length; j++) {
|
|
148
|
+
const c = other[j];
|
|
149
|
+
const d = other[j + 1];
|
|
150
|
+
if (horizontal) {
|
|
151
|
+
if (Math.abs(c.y - d.y) > 1e-6 || Math.abs(c.y - a.y) >= FLUSH_LANE_CLEARANCE)
|
|
152
|
+
continue;
|
|
153
|
+
if (Math.min(Math.max(a.x, b.x), Math.max(c.x, d.x)) - Math.max(Math.min(a.x, b.x), Math.min(c.x, d.x)) > 1e-6)
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
if (Math.abs(c.x - d.x) > 1e-6 || Math.abs(c.x - a.x) >= FLUSH_LANE_CLEARANCE)
|
|
158
|
+
continue;
|
|
159
|
+
if (Math.min(Math.max(a.y, b.y), Math.max(c.y, d.y)) - Math.max(Math.min(a.y, b.y), Math.min(c.y, d.y)) > 1e-6)
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Lift segments that run flush along the side of a node the edge belongs to —
|
|
167
|
+
* hard to tell from the box outline — a few pixels off that side. The segment
|
|
168
|
+
* moves outwards; when it was the first or last one, the original end point is
|
|
169
|
+
* kept and joined by a short stub. The nearest lift that clears every node and
|
|
170
|
+
* every parallel neighbour edge is used; none, and the segment stays.
|
|
171
|
+
*/
|
|
172
|
+
export function liftFlushSegments(layout) {
|
|
173
|
+
const nodeById = new Map(layout.nodes.map((n) => [n.id, n]));
|
|
174
|
+
let changed = false;
|
|
175
|
+
const edges = layout.edges.map((edge) => {
|
|
176
|
+
let pts = edge.points;
|
|
177
|
+
for (let i = 0; i + 1 < pts.length; i++) {
|
|
178
|
+
const a = pts[i];
|
|
179
|
+
const b = pts[i + 1];
|
|
180
|
+
const horizontal = Math.abs(a.y - b.y) < 1e-6;
|
|
181
|
+
const vertical = Math.abs(a.x - b.x) < 1e-6;
|
|
182
|
+
if (horizontal === vertical || Math.abs(a.x - b.x) + Math.abs(a.y - b.y) < FLUSH_MIN_LENGTH)
|
|
183
|
+
continue;
|
|
184
|
+
for (const id of [edge.source, edge.target]) {
|
|
185
|
+
const n = nodeById.get(id);
|
|
186
|
+
if (!n)
|
|
187
|
+
continue;
|
|
188
|
+
const lo = horizontal ? Math.min(a.x, b.x) : Math.min(a.y, b.y);
|
|
189
|
+
const hi = horizontal ? Math.max(a.x, b.x) : Math.max(a.y, b.y);
|
|
190
|
+
const n0 = horizontal ? n.x : n.y;
|
|
191
|
+
const n1 = horizontal ? n.x + n.width : n.y + n.height;
|
|
192
|
+
if (Math.min(hi, n1) - Math.max(lo, n0) <= 1e-6)
|
|
193
|
+
continue;
|
|
194
|
+
const at = horizontal ? a.y : a.x;
|
|
195
|
+
const side0 = horizontal ? n.y : n.x;
|
|
196
|
+
const side1 = horizontal ? n.y + n.height : n.x + n.width;
|
|
197
|
+
const nearLow = Math.abs(at - side0) < FLUSH_DISTANCE;
|
|
198
|
+
const nearHigh = Math.abs(at - side1) < FLUSH_DISTANCE;
|
|
199
|
+
if (!nearLow && !nearHigh)
|
|
200
|
+
continue;
|
|
201
|
+
let lifted;
|
|
202
|
+
// First pass keeps clear of every neighbouring lane; the second takes the
|
|
203
|
+
// nearest lift that merely clears the nodes — a lane beside another lane
|
|
204
|
+
// reads better than a line that is part of a box outline.
|
|
205
|
+
for (const strict of [true, false])
|
|
206
|
+
for (const lift of lifted ? [] : FLUSH_LIFTS) {
|
|
207
|
+
const to = nearLow ? side0 - lift : side1 + lift;
|
|
208
|
+
const a2 = horizontal ? { x: a.x, y: to } : { x: to, y: a.y };
|
|
209
|
+
const b2 = horizontal ? { x: b.x, y: to } : { x: to, y: b.y };
|
|
210
|
+
if (layout.nodes.some((o) => hitsBox(a2, b2, o, 2)))
|
|
211
|
+
continue;
|
|
212
|
+
if (strict && layout.edges.some((o) => o !== edge && besideParallel(a2, b2, o.points)))
|
|
213
|
+
continue;
|
|
214
|
+
lifted = [...pts.slice(0, i), ...(i === 0 ? [a] : []), a2, b2, ...(i + 2 === pts.length ? [b] : []), ...pts.slice(i + 2)];
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
if (!lifted)
|
|
218
|
+
continue;
|
|
219
|
+
pts = lifted;
|
|
220
|
+
changed = true;
|
|
221
|
+
break;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return pts === edge.points ? edge : { ...edge, points: pts };
|
|
225
|
+
});
|
|
226
|
+
return changed ? { ...layout, edges } : layout;
|
|
227
|
+
}
|
|
228
|
+
/** Crossing prices an un-coil re-route is tried with, dearest first, and the crossings it may add. */
|
|
229
|
+
const UNCOIL_CROSSING_COSTS = [6, 3, 1.5];
|
|
230
|
+
const UNCOIL_EXTRA_CROSSINGS = 5;
|
|
231
|
+
/** Nodes (other than its ends) enclosed by the boxes of the route's U-turns (outer segments opposite), summed. */
|
|
232
|
+
function wrapCount(pts, nodes, ends) {
|
|
233
|
+
let wrapped = 0;
|
|
234
|
+
for (let i = 0; i + 3 < pts.length; i++) {
|
|
235
|
+
const [p0, p1, p2, p3] = pts.slice(i, i + 4);
|
|
236
|
+
const vertical = Math.abs(p0.x - p1.x) < 1e-6 && Math.abs(p0.y - p1.y) > 1e-6;
|
|
237
|
+
const horizontal = Math.abs(p0.y - p1.y) < 1e-6 && Math.abs(p0.x - p1.x) > 1e-6;
|
|
238
|
+
if (!vertical && !horizontal)
|
|
239
|
+
continue;
|
|
240
|
+
const d1 = vertical ? p1.y - p0.y : p1.x - p0.x;
|
|
241
|
+
const d3 = vertical ? p3.y - p2.y : p3.x - p2.x;
|
|
242
|
+
const middleOk = vertical ? Math.abs(p1.y - p2.y) < 1e-6 : Math.abs(p1.x - p2.x) < 1e-6;
|
|
243
|
+
const lastOk = vertical ? Math.abs(p2.x - p3.x) < 1e-6 : Math.abs(p2.y - p3.y) < 1e-6;
|
|
244
|
+
if (!middleOk || !lastOk || d1 * d3 >= 0)
|
|
245
|
+
continue;
|
|
246
|
+
const x0 = Math.min(p0.x, p1.x, p2.x, p3.x);
|
|
247
|
+
const x1 = Math.max(p0.x, p1.x, p2.x, p3.x);
|
|
248
|
+
const y0 = Math.min(p0.y, p1.y, p2.y, p3.y);
|
|
249
|
+
const y1 = Math.max(p0.y, p1.y, p2.y, p3.y);
|
|
250
|
+
wrapped += nodes.filter((n) => !ends.includes(n.id) && n.x > x0 && n.x + n.width < x1 && n.y > y0 && n.y + n.height < y1).length;
|
|
251
|
+
}
|
|
252
|
+
return wrapped;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Un-coil: an edge whose route wraps round other nodes (the U of a trunk that
|
|
256
|
+
* circles a cluster — stacked, the concentric rectangles of a coil) is routed
|
|
257
|
+
* again on the lattice with cheaper crossings, against every other edge as a
|
|
258
|
+
* fixed wire. The new route is kept when it wraps fewer nodes, adds at most
|
|
259
|
+
* [[UNCOIL_EXTRA_CROSSINGS]] crossings and opens no new comb at either end.
|
|
260
|
+
* Runs after fan-out merging, so a hub's trunk has been settled by then.
|
|
261
|
+
*/
|
|
262
|
+
export function uncoilWraps(layout) {
|
|
263
|
+
const nodeById = new Map(layout.nodes.map((n) => [n.id, n]));
|
|
264
|
+
const routes = layout.edges.map((e) => e.points);
|
|
265
|
+
const combKeys = () => new Set(findParallelBundles(layout.edges.map((e, i) => ({ ...e, points: routes[i] }))).map((b) => `${b.end}\u0000${b.node}`));
|
|
266
|
+
const combsBefore = combKeys();
|
|
267
|
+
let changed = false;
|
|
268
|
+
layout.edges.forEach((edge, index) => {
|
|
269
|
+
const pts = routes[index];
|
|
270
|
+
const source = nodeById.get(edge.source);
|
|
271
|
+
const target = nodeById.get(edge.target);
|
|
272
|
+
const ends = [edge.source, edge.target];
|
|
273
|
+
const wrapped = source && target && pts.length >= 4 ? wrapCount(pts, layout.nodes, ends) : 0;
|
|
274
|
+
if (!source || !target || wrapped === 0)
|
|
275
|
+
return;
|
|
276
|
+
const others = routes.filter((_, i) => i !== index);
|
|
277
|
+
const oldCrossings = crossingCount(pts, others);
|
|
278
|
+
const existing = layout.edges
|
|
279
|
+
.map((e, i) => ({ source: e.source, target: e.target, points: routes[i] }))
|
|
280
|
+
.filter((_, i) => i !== index);
|
|
281
|
+
for (const crossingCost of UNCOIL_CROSSING_COSTS) {
|
|
282
|
+
const route = routeOrthogonal([{ id: 'uncoil', source, target }], layout.nodes, {
|
|
283
|
+
width: layout.width,
|
|
284
|
+
height: layout.height,
|
|
285
|
+
frames: layout.packages,
|
|
286
|
+
existing,
|
|
287
|
+
crossingCost,
|
|
288
|
+
ripupPasses: 0,
|
|
289
|
+
}).get('uncoil');
|
|
290
|
+
if (!route || route.length < 2 || wrapCount(route, layout.nodes, ends) >= wrapped)
|
|
291
|
+
continue;
|
|
292
|
+
if (crossingCount(route, others) > oldCrossings + UNCOIL_EXTRA_CROSSINGS)
|
|
293
|
+
continue;
|
|
294
|
+
const previous = routes[index];
|
|
295
|
+
routes[index] = route;
|
|
296
|
+
const opened = [...combKeys()].some((k) => !combsBefore.has(k));
|
|
297
|
+
if (opened) {
|
|
298
|
+
routes[index] = previous;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
changed = true;
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
if (!changed)
|
|
306
|
+
return layout;
|
|
307
|
+
return { ...layout, edges: layout.edges.map((e, i) => ({ ...e, points: routes[i] })) };
|
|
308
|
+
}
|
package/dist/layout/viz.d.ts
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
* bounding-box origin so the result matches the top-down pixel space the ELK
|
|
12
12
|
* engine and the SVG renderer already speak.
|
|
13
13
|
*/
|
|
14
|
-
import { PlantUmlModel } from '../model.js';
|
|
15
|
-
import { LayoutEngine, LayoutResult } from '../layout.js';
|
|
16
|
-
import { GraphvizEngine } from '
|
|
14
|
+
import { PlantUmlModel } from '../foundation/model.js';
|
|
15
|
+
import { LayoutEngine, LayoutResult } from '../foundation/layout.js';
|
|
16
|
+
import { GraphvizEngine } from './render-dot.js';
|
|
17
17
|
export declare class VizLayoutEngine implements LayoutEngine {
|
|
18
18
|
private readonly graphvizEngine;
|
|
19
19
|
constructor(graphvizEngine?: GraphvizEngine);
|
package/dist/layout/viz.js
CHANGED
|
@@ -11,9 +11,10 @@
|
|
|
11
11
|
* bounding-box origin so the result matches the top-down pixel space the ELK
|
|
12
12
|
* engine and the SVG renderer already speak.
|
|
13
13
|
*/
|
|
14
|
-
import { PACKAGE_LABEL, displayName, estimateEntitySize, } from '../layout.js';
|
|
15
|
-
import { getViz } from '
|
|
16
|
-
import { measureTextWidth } from '../text-measure.js';
|
|
14
|
+
import { PACKAGE_LABEL, displayName, estimateEntitySize, } from '../foundation/layout.js';
|
|
15
|
+
import { getViz } from './render-dot.js';
|
|
16
|
+
import { measureTextWidth } from '../foundation/text-measure.js';
|
|
17
|
+
import { relationSemantics } from '../foundation/relation-semantics.js';
|
|
17
18
|
const POINTS_PER_INCH = 72;
|
|
18
19
|
const CLUSTER_PREFIX = 'cluster_';
|
|
19
20
|
export class VizLayoutEngine {
|
|
@@ -159,28 +160,23 @@ function clusterIndex(name) {
|
|
|
159
160
|
return null;
|
|
160
161
|
return Number(tail);
|
|
161
162
|
}
|
|
163
|
+
/** Translate the relation's semantic facts into Graphviz DOT attributes. */
|
|
162
164
|
function edgeAttrs(relation) {
|
|
163
165
|
const attrs = [];
|
|
164
|
-
|
|
165
|
-
|
|
166
|
+
const semantics = relationSemantics(relation.type);
|
|
167
|
+
switch (semantics.arrowKind()) {
|
|
168
|
+
case 'triangle':
|
|
166
169
|
attrs.push('arrowhead="empty"');
|
|
167
170
|
break;
|
|
168
|
-
case '
|
|
169
|
-
attrs.push('arrowhead="empty"', 'style="dashed"');
|
|
170
|
-
break;
|
|
171
|
-
case 'dependency':
|
|
172
|
-
attrs.push('arrowhead="vee"', 'style="dashed"');
|
|
173
|
-
break;
|
|
174
|
-
case 'association':
|
|
171
|
+
case 'chevron':
|
|
175
172
|
attrs.push('arrowhead="vee"');
|
|
176
173
|
break;
|
|
177
|
-
case '
|
|
178
|
-
attrs.push(
|
|
179
|
-
break;
|
|
180
|
-
case 'composition':
|
|
181
|
-
attrs.push('arrowhead="diamond"');
|
|
174
|
+
case 'diamond':
|
|
175
|
+
attrs.push(`arrowhead="${semantics.isFilled() ? 'diamond' : 'odiamond'}"`);
|
|
182
176
|
break;
|
|
183
177
|
}
|
|
178
|
+
if (semantics.isDashed())
|
|
179
|
+
attrs.push('style="dashed"');
|
|
184
180
|
if (relation.label)
|
|
185
181
|
attrs.push(`label=${quoteDotLabel(relation.label)}`);
|
|
186
182
|
return attrs.length > 0 ? ` [${attrs.join(', ')}]` : '';
|
|
@@ -0,0 +1,183 @@
|
|
|
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 '../foundation/model.js';
|
|
12
|
+
import { LayoutResult, EdgeLayout, Point } from '../foundation/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
|
+
/**
|
|
19
|
+
* Edges drawn with at least one diagonal segment (or no usable polyline at
|
|
20
|
+
* all) — the straight-line fallback an engine takes when its router returns
|
|
21
|
+
* no route. Zero means every edge is an orthogonal polyline.
|
|
22
|
+
*/
|
|
23
|
+
nonOrthogonalEdgeCount: number;
|
|
24
|
+
/** Summed polyline length of every edge, in canvas pixels. */
|
|
25
|
+
totalEdgeLength: number;
|
|
26
|
+
/** Sum of node areas divided by canvas area (0..1). */
|
|
27
|
+
areaUtilization: number;
|
|
28
|
+
/** Canvas width / height. */
|
|
29
|
+
aspectRatio: number;
|
|
30
|
+
/** Entity text lines wider than the box they are drawn in. */
|
|
31
|
+
textOverflowCount: number;
|
|
32
|
+
/** Package label tabs that overlap content or are narrower than their text. */
|
|
33
|
+
labelOverlapCount: number;
|
|
34
|
+
/**
|
|
35
|
+
* Summed overlap length of *nearly* parallel segments from different edges —
|
|
36
|
+
* segments within [[PARALLEL_PROXIMITY]] px of each other. The orthogonal
|
|
37
|
+
* router's "parallel-line wall" is invisible to [[edgeCrossings]] (segments
|
|
38
|
+
* that merely run alongside never *cross*), so this counts that hidden
|
|
39
|
+
* clutter in canvas pixels.
|
|
40
|
+
*/
|
|
41
|
+
overlappingEdgeLength: number;
|
|
42
|
+
/** Hub badges whose circle covers any of the owning node's text. */
|
|
43
|
+
badgeLabelOverlap: number;
|
|
44
|
+
/**
|
|
45
|
+
* Edge labels (`×N` weights and plain labels) that overlap another edge label
|
|
46
|
+
* or sit on top of a node box.
|
|
47
|
+
*/
|
|
48
|
+
edgeLabelOverlap: number;
|
|
49
|
+
/** Edge labels whose rectangle intersects any node rectangle (should be 0). */
|
|
50
|
+
labelNodeOverlap: number;
|
|
51
|
+
/** Edge labels whose rectangle is crossed by a segment of an edge other than their own. */
|
|
52
|
+
labelEdgeOverlap: number;
|
|
53
|
+
/** Pairs of edge-label rectangles that intersect each other. */
|
|
54
|
+
labelLabelOverlap: number;
|
|
55
|
+
/**
|
|
56
|
+
* Incoming arrowheads stacked beyond a threshold on the same side of the
|
|
57
|
+
* same node — the "everything points at this hub" pile-up that
|
|
58
|
+
* [[edgeCrossings]] cannot see either.
|
|
59
|
+
*/
|
|
60
|
+
hubArrowStack: number;
|
|
61
|
+
/**
|
|
62
|
+
* Area of the largest axis-aligned rectangle on the canvas that holds neither
|
|
63
|
+
* a node nor a package frame, divided by the canvas area (0..1). A big value
|
|
64
|
+
* is the "empty quadrant" a row-packed root layer leaves behind.
|
|
65
|
+
*/
|
|
66
|
+
maxEmptyBlockRatio: number;
|
|
67
|
+
/**
|
|
68
|
+
* Edge segments running parallel to, and within [[EDGE_FLUSH_DISTANCE]] px
|
|
69
|
+
* of, a side of a node they do not end at or of a package frame border they
|
|
70
|
+
* do not end at — the edge then reads as part of the box outline.
|
|
71
|
+
*/
|
|
72
|
+
edgeFlushCount: number;
|
|
73
|
+
/**
|
|
74
|
+
* "Combs": groups of at least [[BUNDLE_MIN_EDGES]] edges that share a source
|
|
75
|
+
* (or a target), run the same way, and have segments laid side by side
|
|
76
|
+
* within [[BUNDLE_LATERAL_DISTANCE]] px for at least [[BUNDLE_MIN_RUN]] px.
|
|
77
|
+
* Edges drawn exactly on top of each other (a merged trunk) are one line and
|
|
78
|
+
* do not form a comb. [[overlappingEdgeLength]] cannot see these: the lines
|
|
79
|
+
* sit just outside its proximity.
|
|
80
|
+
*/
|
|
81
|
+
parallelBundleCount: number;
|
|
82
|
+
/**
|
|
83
|
+
* Edges that pass through the interior of a package frame neither endpoint
|
|
84
|
+
* belongs to (counted once per edge and frame) — a corridor should run in the
|
|
85
|
+
* gutters between frames, not across unrelated packages.
|
|
86
|
+
*/
|
|
87
|
+
edgeFrameCrossings: number;
|
|
88
|
+
/**
|
|
89
|
+
* Edges whose polyline is longer than [[TRUNK_DETOUR_FACTOR]] times the
|
|
90
|
+
* Manhattan distance between their end points — a trunk that wanders round a
|
|
91
|
+
* cluster instead of running the lane between the rows.
|
|
92
|
+
*/
|
|
93
|
+
trunkDetourCount: number;
|
|
94
|
+
/**
|
|
95
|
+
* Nested rectangular loops inside one package frame (edges between two nodes of the same frame): U-turns (three segments,
|
|
96
|
+
* the outer two running in opposite directions) whose bounding box encloses a
|
|
97
|
+
* node the edge does not end at (the route wraps a cluster) and lies inside
|
|
98
|
+
* another such U-turn's — counted once per inner loop. The concentric "coil" a trunk makes when it keeps
|
|
99
|
+
* circling the nodes it avoids.
|
|
100
|
+
*/
|
|
101
|
+
nestedLoopCount: number;
|
|
102
|
+
/**
|
|
103
|
+
* Edge segments at least [[EDGE_NODE_FLUSH_MIN_LENGTH]] px long running
|
|
104
|
+
* parallel to a side of the node they end at, within
|
|
105
|
+
* [[EDGE_NODE_FLUSH_DISTANCE]] px — the line hugs the box it belongs to and
|
|
106
|
+
* is hard to tell from its outline.
|
|
107
|
+
*/
|
|
108
|
+
edgeNodeFlush: number;
|
|
109
|
+
/**
|
|
110
|
+
* Smallest node-area ÷ frame-area over the package frames holding at least
|
|
111
|
+
* [[FRAME_FILL_MIN_NODES]] nodes (1 when no frame qualifies): low means a
|
|
112
|
+
* stretched, mostly empty frame.
|
|
113
|
+
*/
|
|
114
|
+
frameFillRatio: number;
|
|
115
|
+
/**
|
|
116
|
+
* Smallest distance from any edge point to a canvas side (the shorter canvas
|
|
117
|
+
* side when there are no edges): a line hugging the canvas edge is easily
|
|
118
|
+
* cropped.
|
|
119
|
+
*/
|
|
120
|
+
edgeCanvasMargin: number;
|
|
121
|
+
/**
|
|
122
|
+
* Layout-engine wall time in milliseconds (layout only: no transforms, SVG
|
|
123
|
+
* or rasterisation). Present only when the caller passes the measured time.
|
|
124
|
+
*/
|
|
125
|
+
layoutMs?: number;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* A named, pure measurement over a layout. The metrics registry is the single
|
|
129
|
+
* source of truth for which measurements [[computeLayoutMetrics]] reports:
|
|
130
|
+
* adding a metric means registering a `Metric`, not editing the function's
|
|
131
|
+
* return literal.
|
|
132
|
+
*/
|
|
133
|
+
export interface Metric {
|
|
134
|
+
/** Key the value is exposed under on [[LayoutMetrics]]. */
|
|
135
|
+
readonly name: string;
|
|
136
|
+
compute(model: PlantUmlModel, layout: LayoutResult): number;
|
|
137
|
+
}
|
|
138
|
+
/** Register a metric; the next [[computeLayoutMetrics]] call reports it. */
|
|
139
|
+
export declare function registerMetric(metric: Metric): void;
|
|
140
|
+
/** Remove a registered metric by name; returns whether one was removed. */
|
|
141
|
+
export declare function unregisterMetric(name: string): boolean;
|
|
142
|
+
/** Names of the currently registered metrics, in evaluation order. */
|
|
143
|
+
export declare function listMetrics(): readonly string[];
|
|
144
|
+
/**
|
|
145
|
+
* Computes every registered metric. The registry drives the assembly, so a new
|
|
146
|
+
* measurement participates without touching this function; [[LayoutMetrics]]
|
|
147
|
+
* remains the strong, hand-written shape the registered names must cover.
|
|
148
|
+
* `layoutMs` is not a measurement of the layout — it is the caller's wall-time
|
|
149
|
+
* reading — so it is attached directly rather than registered.
|
|
150
|
+
*/
|
|
151
|
+
export declare function computeLayoutMetrics(model: PlantUmlModel, layout: LayoutResult, layoutMs?: number): LayoutMetrics;
|
|
152
|
+
/** Polyline length ÷ Manhattan distance above which an edge counts as a detour. */
|
|
153
|
+
export declare const TRUNK_DETOUR_FACTOR = 2;
|
|
154
|
+
/** Distance (px) and run (px) for [[LayoutMetrics.edgeNodeFlush]]. */
|
|
155
|
+
export declare const EDGE_NODE_FLUSH_DISTANCE = 4;
|
|
156
|
+
export declare const EDGE_NODE_FLUSH_MIN_LENGTH = 40;
|
|
157
|
+
/** Frames with at least this many nodes count for [[LayoutMetrics.frameFillRatio]]. */
|
|
158
|
+
export declare const FRAME_FILL_MIN_NODES = 6;
|
|
159
|
+
/** For each polyline, the number of crossings it takes part in (each crossing counts for both edges). */
|
|
160
|
+
export declare function perEdgeCrossings(polylines: Point[][]): number[];
|
|
161
|
+
/** Lateral distance (px) within which two parallel segments count as overlapping. */
|
|
162
|
+
export declare const PARALLEL_PROXIMITY = 8;
|
|
163
|
+
/** Edge-to-box distance (px) below which a parallel segment counts as flush. */
|
|
164
|
+
export declare const EDGE_FLUSH_DISTANCE = 6;
|
|
165
|
+
/**
|
|
166
|
+
* Rasterises nodes and package frames onto a coarse grid and returns the
|
|
167
|
+
* largest all-empty rectangle (maximal-rectangle-in-histogram) over the canvas.
|
|
168
|
+
*/
|
|
169
|
+
export declare function computeMaxEmptyBlockRatio(layout: LayoutResult): number;
|
|
170
|
+
/** Edges per comb needed to count it. */
|
|
171
|
+
export declare const BUNDLE_MIN_EDGES = 5;
|
|
172
|
+
/** Lateral distance (px) within which two same-way segments sit "side by side". */
|
|
173
|
+
export declare const BUNDLE_LATERAL_DISTANCE = 16;
|
|
174
|
+
/** Shared run (px) two side-by-side segments need to count. */
|
|
175
|
+
export declare const BUNDLE_MIN_RUN = 120;
|
|
176
|
+
/** One comb: the shared end node and the ids of the edges laid side by side. */
|
|
177
|
+
export interface ParallelBundle {
|
|
178
|
+
end: 'source' | 'target';
|
|
179
|
+
node: string;
|
|
180
|
+
edges: string[];
|
|
181
|
+
}
|
|
182
|
+
/** Lists source-sharing and target-sharing combs; see [[LayoutMetrics.parallelBundleCount]]. */
|
|
183
|
+
export declare function findParallelBundles(edges: EdgeLayout[]): ParallelBundle[];
|