@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
package/dist/render/svg.js
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
* The renderer consumes only the LayoutResult (positions/sizes) plus the
|
|
4
4
|
* model's display fields; it never reaches back into the parser.
|
|
5
5
|
*/
|
|
6
|
-
import { METRICS, PACKAGE_LABEL, displayName, } from '../layout.js';
|
|
7
|
-
import { measureTextWidth } from '../text-measure.js';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
6
|
+
import { METRICS, PACKAGE_LABEL, displayName, } from '../foundation/layout.js';
|
|
7
|
+
import { measureTextWidth } from '../foundation/text-measure.js';
|
|
8
|
+
import { hubBadgeCircle, placeEdgeLabels, EDGE_LABEL_FONT_SIZE } from '../foundation/labels.js';
|
|
9
|
+
import { renderLegend, LEGEND_DEFAULT_NODE_THRESHOLD } from './legend.js';
|
|
10
|
+
import { resolveTheme } from './theme.js';
|
|
11
|
+
import { packageLayer } from '../foundation/layers.js';
|
|
12
|
+
import { relationSemantics } from '../foundation/relation-semantics.js';
|
|
12
13
|
export function escapeXml(text) {
|
|
13
14
|
return text
|
|
14
15
|
.replace(/&/g, '&')
|
|
@@ -21,193 +22,420 @@ function fmt(n) {
|
|
|
21
22
|
const r = Math.round(n * 100) / 100;
|
|
22
23
|
return Object.is(r, -0) ? '0' : String(r);
|
|
23
24
|
}
|
|
24
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The built-in {@link RenderEngine}: the SVG backend. Theme and legend
|
|
27
|
+
* configuration are fixed at construction; the per-frame `hiddenTransitive`
|
|
28
|
+
* count is passed to [[SvgRenderer.render]].
|
|
29
|
+
*/
|
|
30
|
+
export class SvgRenderer {
|
|
31
|
+
legend;
|
|
32
|
+
theme;
|
|
33
|
+
constructor(options = {}) {
|
|
34
|
+
this.legend = options.legend;
|
|
35
|
+
this.theme = options.theme;
|
|
36
|
+
}
|
|
37
|
+
render(model, layout, frame = {}) {
|
|
38
|
+
return drawSvg(model, layout, {
|
|
39
|
+
legend: this.legend,
|
|
40
|
+
theme: this.theme,
|
|
41
|
+
hiddenTransitive: frame.hiddenTransitive,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Convenience wrapper over {@link SvgRenderer}, kept for callers that build the
|
|
47
|
+
* SVG directly from a model + layout (the renderer class is preferred when
|
|
48
|
+
* injecting into `RendererOptions.renderer`).
|
|
49
|
+
*/
|
|
50
|
+
export function renderSvg(model, layout, options = {}) {
|
|
51
|
+
return new SvgRenderer(options).render(model, layout, {
|
|
52
|
+
hiddenTransitive: options.hiddenTransitive,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
function drawSvg(model, layout, options) {
|
|
56
|
+
const theme = resolveTheme(options.theme);
|
|
25
57
|
const nodesById = new Map(layout.nodes.map((n) => [n.id, n]));
|
|
26
58
|
const packagesById = new Map((layout.packages ?? []).map((p) => [p.id, p]));
|
|
27
59
|
const edgesById = new Map((layout.edges ?? []).map((e) => [e.id, e]));
|
|
28
60
|
const body = [];
|
|
61
|
+
const pkgById = new Map(model.packages.map((p) => [p.id, p]));
|
|
62
|
+
const layerNames = [];
|
|
63
|
+
for (const pkg of model.packages) {
|
|
64
|
+
const layer = packageLayer(pkg);
|
|
65
|
+
if (layer !== undefined && !layerNames.includes(layer))
|
|
66
|
+
layerNames.push(layer);
|
|
67
|
+
}
|
|
29
68
|
for (const pkg of model.packages) {
|
|
30
69
|
const box = packagesById.get(pkg.id);
|
|
31
|
-
if (box)
|
|
32
|
-
|
|
70
|
+
if (!box)
|
|
71
|
+
continue;
|
|
72
|
+
let depth = 0;
|
|
73
|
+
for (let p = pkg.parentId ? pkgById.get(pkg.parentId) : undefined; p; p = p.parentId ? pkgById.get(p.parentId) : undefined)
|
|
74
|
+
depth++;
|
|
75
|
+
const layer = packageLayer(pkg);
|
|
76
|
+
const tone = (layer !== undefined ? layerNames.indexOf(layer) : 0) + depth;
|
|
77
|
+
body.push(renderPackage(pkg, box, theme, tone));
|
|
78
|
+
}
|
|
79
|
+
const weightLabels = new Map();
|
|
80
|
+
const plainLabels = new Map();
|
|
81
|
+
for (const label of placeEdgeLabels(model, layout)) {
|
|
82
|
+
if (label.kind === 'weight')
|
|
83
|
+
weightLabels.set(label.edge, label);
|
|
84
|
+
else
|
|
85
|
+
plainLabels.set(label.edge, label);
|
|
86
|
+
}
|
|
87
|
+
const bundles = findEdgeBundles(model, edgesById);
|
|
88
|
+
const bundled = new Set(bundles.flat());
|
|
89
|
+
for (const group of bundles) {
|
|
90
|
+
body.push(renderBundle(group.map((i) => ({ rel: model.relations[i], edge: edgesById.get(`rel-${i}`), weightLabel: weightLabels.get(i) })), theme));
|
|
33
91
|
}
|
|
34
92
|
for (let i = 0; i < model.relations.length; i++) {
|
|
35
93
|
const edge = edgesById.get(`rel-${i}`);
|
|
36
|
-
if (edge)
|
|
37
|
-
body.push(renderEdge(model.relations[i], edge));
|
|
94
|
+
if (edge && !bundled.has(i))
|
|
95
|
+
body.push(renderEdge(model.relations[i], edge, theme, weightLabels.get(i), plainLabels.get(i)));
|
|
38
96
|
}
|
|
39
97
|
for (const entity of model.entities) {
|
|
40
98
|
const node = nodesById.get(entity.id);
|
|
41
99
|
if (node)
|
|
42
|
-
body.push(renderEntity(entity, node));
|
|
100
|
+
body.push(renderEntity(entity, node, theme));
|
|
101
|
+
}
|
|
102
|
+
let width = layout.width;
|
|
103
|
+
let height = layout.height;
|
|
104
|
+
const hidden = options.hiddenTransitive ?? 0;
|
|
105
|
+
const showLegend = options.legend ?? (hidden > 0 || model.entities.length > LEGEND_DEFAULT_NODE_THRESHOLD);
|
|
106
|
+
if (showLegend) {
|
|
107
|
+
const legend = renderLegend(12, layout.height + 8, theme);
|
|
108
|
+
body.push(legend.svg);
|
|
109
|
+
width = Math.max(width, legend.width + 24);
|
|
110
|
+
height = layout.height + 8 + legend.height + 12;
|
|
111
|
+
if (hidden > 0) {
|
|
112
|
+
const note = `${hidden} transitive ${hidden === 1 ? 'dependency' : 'dependencies'} hidden (A→B→C implies A→C); set edgeReduction: 'none' to show all`;
|
|
113
|
+
body.push(`<g class="uml-legend-note">` +
|
|
114
|
+
textEl(note, 12 + 10, height + 4, 'start', 11, 'normal', theme.color.textMember, theme) +
|
|
115
|
+
`</g>`);
|
|
116
|
+
height += 22;
|
|
117
|
+
width = Math.max(width, 12 + 10 + 7 * note.length * 0.9 + 12);
|
|
118
|
+
}
|
|
43
119
|
}
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
120
|
+
const hiddenAttr = hidden > 0 ? ` data-hidden-transitive="${hidden}"` : '';
|
|
121
|
+
return (`<svg xmlns="http://www.w3.org/2000/svg" width="${fmt(width)}" height="${fmt(height)}"${hiddenAttr}` +
|
|
122
|
+
` viewBox="0 0 ${fmt(width)} ${fmt(height)}">` +
|
|
123
|
+
`<rect class="uml-canvas" x="0" y="0" width="${fmt(width)}" height="${fmt(height)}" fill="${theme.color.canvas}"/>` +
|
|
124
|
+
`${body.join('')}</svg>`);
|
|
48
125
|
}
|
|
49
|
-
function
|
|
126
|
+
function textEl(text, x, y, anchor, size, weight, fill, theme, extra = '') {
|
|
127
|
+
const w = weight === 'normal' ? '' : ` font-weight="${weight}"`;
|
|
128
|
+
return (`<text x="${fmt(x)}" y="${fmt(y)}" text-anchor="${anchor}"` +
|
|
129
|
+
` font-family="${theme.font.family}" font-size="${size}"${w}${extra}` +
|
|
130
|
+
` fill="${fill}">${escapeXml(text)}</text>`);
|
|
131
|
+
}
|
|
132
|
+
function renderEntity(entity, node, theme) {
|
|
50
133
|
const italic = entity.kind === 'interface' || entity.kind === 'abstract';
|
|
51
134
|
const fontStyle = italic ? ' font-style="italic"' : '';
|
|
52
135
|
const stub = entity.stub === true;
|
|
136
|
+
const { color, font } = theme;
|
|
53
137
|
const parts = [];
|
|
54
|
-
|
|
55
|
-
|
|
138
|
+
if (entity.groupMembers)
|
|
139
|
+
parts.push(`<title>${entity.groupMembers.map(escapeXml).join('\n')}</title>`);
|
|
140
|
+
const weak = stub || entity.external === true;
|
|
141
|
+
const stubAttrs = weak
|
|
142
|
+
? ` class="${stub ? 'uml-stub' : 'uml-external'}" stroke-dasharray="${theme.dash.stub}"`
|
|
143
|
+
: '';
|
|
56
144
|
parts.push(`<rect x="${fmt(node.x)}" y="${fmt(node.y)}" width="${fmt(node.width)}"` +
|
|
57
|
-
` height="${fmt(node.height)}" rx="
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
145
|
+
` height="${fmt(node.height)}" rx="${theme.shape.nodeRadius}" ry="${theme.shape.nodeRadius}"` +
|
|
146
|
+
` fill="${weak ? color.stubFill : color.nodeFill}"` +
|
|
147
|
+
`${stubAttrs} stroke="${weak ? color.strokeStub : color.stroke}" stroke-width="${theme.stroke.node}"/>`);
|
|
148
|
+
const headerBottom = node.y + METRICS.paddingY + METRICS.lineHeight;
|
|
149
|
+
const titleBaseline = node.y + METRICS.paddingY + METRICS.fontSize + 1;
|
|
150
|
+
if (entity.members.length > 0) {
|
|
151
|
+
parts.push(`<line x1="${fmt(node.x)}" y1="${fmt(headerBottom)}"` +
|
|
152
|
+
` x2="${fmt(node.x + node.width)}" y2="${fmt(headerBottom)}"` +
|
|
153
|
+
` stroke="${color.stroke}" stroke-width="${theme.stroke.node}"/>`);
|
|
154
|
+
}
|
|
155
|
+
parts.push(textEl(displayName(entity), node.x + node.width / 2, titleBaseline, 'middle', font.size.title, font.weight.title, color.textTitle, theme, fontStyle));
|
|
66
156
|
entity.members.forEach((member, i) => {
|
|
67
|
-
const baseline = node.y + METRICS.paddingY + METRICS.lineHeight * (i + 1) + METRICS.fontSize
|
|
68
|
-
parts.push(
|
|
69
|
-
` text-anchor="start" font-family="${METRICS.fontFamily}" font-size="${METRICS.fontSize}"` +
|
|
70
|
-
` fill="#000000">${escapeXml(member.raw)}</text>`);
|
|
157
|
+
const baseline = node.y + METRICS.paddingY + METRICS.lineHeight * (i + 1) + METRICS.fontSize + 1;
|
|
158
|
+
parts.push(textEl(member.raw, node.x + METRICS.paddingX, baseline, 'start', font.size.member, font.weight.member, color.textMember, theme));
|
|
71
159
|
});
|
|
72
160
|
if (entity.usageCount && entity.usageCount > 0) {
|
|
73
|
-
parts.push(renderHubBadge(node, entity.usageCount));
|
|
161
|
+
parts.push(renderHubBadge(node, entity.usageCount, theme));
|
|
74
162
|
}
|
|
75
|
-
// Each entity is a single DOM group carrying its stable id
|
|
76
|
-
// can bind hover/click handlers
|
|
77
|
-
|
|
163
|
+
// Each entity is a single DOM group carrying its stable id and its innermost
|
|
164
|
+
// package, so a browser host can bind hover/click handlers, cross-panel
|
|
165
|
+
// linkage, and package-containment queries without geometric hit-testing.
|
|
166
|
+
const packageAttr = entity.packageId !== undefined
|
|
167
|
+
? ` data-package-id="${escapeXml(entity.packageId)}"`
|
|
168
|
+
: '';
|
|
169
|
+
return (`<g data-entity-id="${escapeXml(entity.id)}"${packageAttr} class="uml-node${entity.external ? ' uml-external' : ''}">` +
|
|
78
170
|
parts.join('') +
|
|
79
171
|
`</g>`);
|
|
80
172
|
}
|
|
81
173
|
/**
|
|
82
|
-
* Hub badge — the "used by N packages" marker drawn
|
|
83
|
-
* a node whose incoming edges were summarized away (see [[../transform]]).
|
|
174
|
+
* Hub badge — the "used by N packages" marker drawn on the top-right corner of
|
|
175
|
+
* a node whose incoming edges were summarized away (see [[../transform]]). It
|
|
176
|
+
* is centred on the corner, so half of it sits outside the box and never covers
|
|
177
|
+
* the node's name. Neutral accent colour: red is reserved for feedback edges.
|
|
84
178
|
*/
|
|
85
|
-
function renderHubBadge(node, count) {
|
|
86
|
-
const cx
|
|
87
|
-
const cy = node.y + 13;
|
|
179
|
+
function renderHubBadge(node, count, theme) {
|
|
180
|
+
const { cx, cy, r } = hubBadgeCircle(node);
|
|
88
181
|
return (`<g class="uml-hub-badge">` +
|
|
89
|
-
`<circle cx="${fmt(cx)}" cy="${fmt(cy)}" r="
|
|
90
|
-
|
|
91
|
-
|
|
182
|
+
`<circle cx="${fmt(cx)}" cy="${fmt(cy)}" r="${r}" fill="${theme.color.accent}"` +
|
|
183
|
+
` stroke="${theme.color.canvas}" stroke-width="1"/>` +
|
|
184
|
+
textEl(String(count), cx, cy + 3.5, 'middle', theme.font.size.badge, 'normal', theme.color.accentText, theme) +
|
|
92
185
|
`</g>`);
|
|
93
186
|
}
|
|
94
|
-
function renderPackage(pkg, box) {
|
|
187
|
+
function renderPackage(pkg, box, theme, tone) {
|
|
188
|
+
const { color } = theme;
|
|
189
|
+
const fills = color.packageFills;
|
|
190
|
+
const fill = fills[((tone % fills.length) + fills.length) % fills.length];
|
|
95
191
|
const tabWidth = Math.min(box.width, Math.max(24, measureTextWidth(pkg.name, PACKAGE_LABEL.fontSize) + PACKAGE_LABEL.tabPadding));
|
|
96
192
|
const parts = [];
|
|
97
193
|
parts.push(`<rect x="${fmt(box.x)}" y="${fmt(box.y)}" width="${fmt(box.width)}"` +
|
|
98
|
-
` height="${fmt(box.height)}"
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
`
|
|
194
|
+
` height="${fmt(box.height)}" rx="${theme.shape.packageRadius}" ry="${theme.shape.packageRadius}"` +
|
|
195
|
+
` fill="${fill}" stroke="${color.packageStroke}" stroke-width="${theme.stroke.package}"/>`);
|
|
196
|
+
parts.push(`<line class="uml-package-tab" x1="${fmt(box.x)}" y1="${fmt(box.y + PACKAGE_LABEL.tabHeight)}"` +
|
|
197
|
+
` x2="${fmt(box.x + tabWidth)}" y2="${fmt(box.y + PACKAGE_LABEL.tabHeight)}"` +
|
|
198
|
+
` stroke="${color.packageStroke}" stroke-width="${theme.stroke.package}"/>`);
|
|
199
|
+
parts.push(textEl(pkg.name, box.x + 8, box.y + PACKAGE_LABEL.tabHeight - 8, 'start', theme.font.size.packageLabel, theme.font.weight.packageLabel, color.textPackage, theme));
|
|
103
200
|
return (`<g data-package-id="${escapeXml(pkg.id)}" class="uml-package">` +
|
|
104
201
|
parts.join('') +
|
|
105
202
|
`</g>`);
|
|
106
203
|
}
|
|
107
|
-
|
|
204
|
+
/** Edges sharing a source and exit direction beyond this count are drawn as one bundle. */
|
|
205
|
+
export const EDGE_BUNDLE_MIN = 5;
|
|
206
|
+
/**
|
|
207
|
+
* Groups of ≥ [[EDGE_BUNDLE_MIN]] unlabelled bundlable edges that leave one
|
|
208
|
+
* source in the same direction — the "comb" of near-parallel lines a hub-like
|
|
209
|
+
* source otherwise draws. Returns relation indexes per group.
|
|
210
|
+
*/
|
|
211
|
+
function findEdgeBundles(model, edgesById) {
|
|
212
|
+
const groups = new Map();
|
|
213
|
+
model.relations.forEach((rel, i) => {
|
|
214
|
+
const edge = edgesById.get(`rel-${i}`);
|
|
215
|
+
if (!edge || edge.points.length < 2 || rel.label || rel.feedback || rel.descriptions)
|
|
216
|
+
return;
|
|
217
|
+
if (!relationSemantics(rel.type).isBundlable())
|
|
218
|
+
return;
|
|
219
|
+
const from = edge.points[0];
|
|
220
|
+
const next = edge.points[1];
|
|
221
|
+
const dx = next.x - from.x;
|
|
222
|
+
const dy = next.y - from.y;
|
|
223
|
+
const dir = Math.abs(dx) >= Math.abs(dy) ? (dx >= 0 ? 'E' : 'W') : dy >= 0 ? 'S' : 'N';
|
|
224
|
+
const key = `${rel.source}\u0000${rel.type}\u0000${dir}`;
|
|
225
|
+
const list = groups.get(key);
|
|
226
|
+
if (list)
|
|
227
|
+
list.push(i);
|
|
228
|
+
else
|
|
229
|
+
groups.set(key, [i]);
|
|
230
|
+
});
|
|
231
|
+
return [...groups.values()].filter((g) => g.length >= EDGE_BUNDLE_MIN);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* One visible line for a fan-out: the union of the member polylines with
|
|
235
|
+
* shared segments drawn once, tagged with the summed `data-weight`. Each member
|
|
236
|
+
* keeps its own arrowhead (carrying `data-edge-target`).
|
|
237
|
+
*/
|
|
238
|
+
function renderBundle(members, theme) {
|
|
239
|
+
const first = members[0].rel;
|
|
240
|
+
const semantics = relationSemantics(first.type);
|
|
241
|
+
const color = semantics.isWeak() ? theme.color.edgeWeak : theme.color.edge;
|
|
242
|
+
const dash = semantics.isDashed() ? theme.dash.weak : undefined;
|
|
243
|
+
const totalWeight = members.reduce((sum, m) => sum + (m.rel.weight && m.rel.weight > 1 ? m.rel.weight : 1), 0);
|
|
244
|
+
const strokeWidth = Math.min(theme.stroke.edge + Math.log2(members.length) * theme.stroke.weightStep, theme.stroke.edgeWidthMax);
|
|
245
|
+
const seen = new Set();
|
|
246
|
+
const segments = [];
|
|
247
|
+
const heads = [];
|
|
248
|
+
for (const { rel, edge, weightLabel } of members) {
|
|
249
|
+
if (weightLabel)
|
|
250
|
+
heads.push(renderWeightLabel(weightLabel, theme));
|
|
251
|
+
const line = trimPolyline(edge.points, 0, theme.arrow.chevron);
|
|
252
|
+
for (let i = 0; i + 1 < line.length; i++) {
|
|
253
|
+
const a = line[i];
|
|
254
|
+
const b = line[i + 1];
|
|
255
|
+
const [p, q] = a.x < b.x || (a.x === b.x && a.y <= b.y) ? [a, b] : [b, a];
|
|
256
|
+
const key = `${fmt(p.x)},${fmt(p.y)}-${fmt(q.x)},${fmt(q.y)}`;
|
|
257
|
+
if (seen.has(key))
|
|
258
|
+
continue;
|
|
259
|
+
seen.add(key);
|
|
260
|
+
segments.push(`M${fmt(p.x)} ${fmt(p.y)}L${fmt(q.x)} ${fmt(q.y)}`);
|
|
261
|
+
}
|
|
262
|
+
heads.push(`<g data-edge-target="${escapeXml(rel.target)}" class="uml-bundle-head">` +
|
|
263
|
+
renderChevron(edge.points, rel.type, color, theme) +
|
|
264
|
+
`</g>`);
|
|
265
|
+
}
|
|
266
|
+
return (`<g data-edge-source="${escapeXml(first.source)}" data-edge-type="${first.type}"` +
|
|
267
|
+
` data-bundle-size="${members.length}" data-weight="${totalWeight}" class="uml-edge uml-edge-bundle">` +
|
|
268
|
+
`<path d="${segments.join('')}" fill="none" stroke="${color}" stroke-width="${fmt(strokeWidth)}"` +
|
|
269
|
+
` stroke-opacity="${edgeOpacity(totalWeight, false, theme)}"` +
|
|
270
|
+
`${dash ? ` stroke-dasharray="${dash}"` : ''} stroke-linejoin="round"${nonScaling(theme)}/>` +
|
|
271
|
+
heads.join('') +
|
|
272
|
+
`</g>`);
|
|
273
|
+
}
|
|
274
|
+
function nonScaling(theme) {
|
|
275
|
+
return theme.stroke.edgeNonScaling ? ' vector-effect="non-scaling-stroke"' : '';
|
|
276
|
+
}
|
|
277
|
+
/** Opacity tier of an edge: heavier (more merged relations) edges are more opaque. */
|
|
278
|
+
function edgeOpacity(weight, feedback, theme) {
|
|
279
|
+
const { opacity, heavyWeight } = theme.stroke;
|
|
280
|
+
if (feedback)
|
|
281
|
+
return opacity.feedback;
|
|
282
|
+
return weight >= heavyWeight ? opacity.strong : weight > 1 ? opacity.medium : opacity.light;
|
|
283
|
+
}
|
|
284
|
+
function renderEdge(rel, edge, theme, weightLabel, plainLabel) {
|
|
108
285
|
if (edge.points.length < 2)
|
|
109
286
|
return '';
|
|
110
287
|
const points = edge.points;
|
|
111
|
-
const
|
|
112
|
-
const color = rel.feedback ?
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
288
|
+
const semantics = relationSemantics(rel.type);
|
|
289
|
+
const color = rel.feedback ? theme.color.warn : semantics.isWeak() ? theme.color.edgeWeak : theme.color.edge;
|
|
290
|
+
// Line type carries relation kind only: the dashed fact comes from the
|
|
291
|
+
// relation-semantics table; a cycle feedback edge is dash-dot. Weight is
|
|
292
|
+
// width + opacity, never dashes.
|
|
293
|
+
const dash = rel.feedback ? theme.dash.feedback : semantics.isDashed() ? theme.dash.weak : undefined;
|
|
294
|
+
const { arrow } = theme;
|
|
295
|
+
const arrowKind = semantics.arrowKind();
|
|
296
|
+
const sourceTrim = arrowKind === 'diamond' ? arrow.diamond : 0;
|
|
297
|
+
const targetTrim = arrowKind === 'triangle'
|
|
298
|
+
? arrow.triangle
|
|
299
|
+
: arrowKind === 'chevron'
|
|
300
|
+
? arrow.chevron
|
|
118
301
|
: 0;
|
|
119
302
|
const linePoints = trimPolyline(points, sourceTrim, targetTrim);
|
|
120
303
|
const parts = [];
|
|
121
|
-
// Weight encodes the number of aggregated original edges: a thicker
|
|
122
|
-
// plus a `×N` label when more than one relation
|
|
304
|
+
// Weight encodes the number of aggregated original edges: a thicker,
|
|
305
|
+
// semi-transparent line, plus a `×N` label when more than one relation
|
|
306
|
+
// collapsed into this edge.
|
|
123
307
|
const weight = rel.weight && rel.weight > 1 ? rel.weight : 1;
|
|
124
|
-
const
|
|
308
|
+
const weightWidth = Math.min(theme.stroke.edge + 2 * theme.stroke.weightStep * (Math.log(weight) / Math.log(theme.stroke.heavyWeight)), theme.stroke.edgeWidthMax);
|
|
309
|
+
const strokeWidth = rel.feedback ? Math.max(theme.stroke.feedback, weightWidth) : weightWidth;
|
|
310
|
+
const opacity = ` stroke-opacity="${edgeOpacity(weight, !!rel.feedback, theme)}"`;
|
|
125
311
|
const feedbackClass = rel.feedback ? ' class="uml-feedback"' : '';
|
|
126
|
-
parts.push(`<
|
|
127
|
-
` stroke-width="${fmt(strokeWidth)}"${
|
|
128
|
-
|
|
129
|
-
|
|
312
|
+
parts.push(`<path d="${roundedPath(linePoints, theme.shape.edgeCornerRadius)}" fill="none" stroke="${color}"` +
|
|
313
|
+
` stroke-width="${fmt(strokeWidth)}"${opacity}${dash ? ` stroke-dasharray="${dash}"` : ''}` +
|
|
314
|
+
` stroke-linejoin="round"${nonScaling(theme)}${feedbackClass}/>`);
|
|
315
|
+
if (arrowKind === 'diamond') {
|
|
316
|
+
parts.push(renderDiamond(points, semantics.isFilled(), color, theme));
|
|
130
317
|
}
|
|
131
|
-
if (
|
|
132
|
-
parts.push(renderTriangle(points, rel.type, color));
|
|
318
|
+
if (arrowKind === 'triangle') {
|
|
319
|
+
parts.push(renderTriangle(points, rel.type, color, theme));
|
|
133
320
|
}
|
|
134
|
-
if (
|
|
135
|
-
parts.push(renderChevron(points, rel.type, color));
|
|
321
|
+
if (arrowKind === 'chevron') {
|
|
322
|
+
parts.push(renderChevron(points, rel.type, color, theme));
|
|
136
323
|
}
|
|
137
|
-
parts.push(...renderEdgeTexts(rel, points,
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
324
|
+
parts.push(...renderEdgeTexts(rel, points, theme, plainLabel));
|
|
325
|
+
if (weightLabel)
|
|
326
|
+
parts.push(renderWeightLabel(weightLabel, theme));
|
|
327
|
+
if (rel.label && !plainLabel) {
|
|
328
|
+
// No label position clear of the nodes: keep the text reachable as a tooltip.
|
|
329
|
+
parts.unshift(`<title>${escapeXml(rel.label)}</title>`);
|
|
141
330
|
}
|
|
142
331
|
// Each edge is one DOM group tagged with its endpoints + type so hosts can
|
|
143
332
|
// highlight an edge and its neighbours via data attributes.
|
|
333
|
+
if (rel.descriptions) {
|
|
334
|
+
parts.unshift(`<title>${rel.descriptions.map(escapeXml).join('\n')}</title>`);
|
|
335
|
+
}
|
|
144
336
|
return (`<g data-edge-source="${escapeXml(rel.source)}" data-edge-target="${escapeXml(rel.target)}"` +
|
|
145
|
-
` data-edge-type="${rel.type}" class="uml-edge">` +
|
|
337
|
+
` data-edge-type="${rel.type}"${rel.weight && rel.weight > 1 ? ` data-weight="${rel.weight}"` : ''} class="uml-edge">` +
|
|
146
338
|
parts.join('') +
|
|
147
339
|
`</g>`);
|
|
148
340
|
}
|
|
149
|
-
|
|
341
|
+
/** `×N` weight label on a canvas-coloured backing box so it stays legible over its line. */
|
|
342
|
+
function renderWeightLabel(weightLabel, theme) {
|
|
343
|
+
const r = weightLabel.rect;
|
|
344
|
+
return (`<rect class="uml-edge-label-bg" x="${fmt(r.x)}" y="${fmt(r.y)}" width="${fmt(r.width)}"` +
|
|
345
|
+
` height="${fmt(r.height)}" fill="${theme.color.canvas}" fill-opacity="0.92"/>` +
|
|
346
|
+
textEl(weightLabel.text, r.x + r.width / 2, r.y + r.height / 2 + 4, 'middle', EDGE_LABEL_FONT_SIZE, 'normal', theme.color.textMember, theme));
|
|
347
|
+
}
|
|
348
|
+
function renderTriangle(points, type, color, theme) {
|
|
349
|
+
const size = theme.arrow.triangle;
|
|
150
350
|
const { u, tip } = targetDir(points);
|
|
151
|
-
const base = { x: tip.x - u.x *
|
|
351
|
+
const base = { x: tip.x - u.x * size, y: tip.y - u.y * size };
|
|
152
352
|
const px = -u.y;
|
|
153
353
|
const py = u.x;
|
|
154
|
-
const half =
|
|
354
|
+
const half = size / 2;
|
|
155
355
|
const pts = [
|
|
156
356
|
tip,
|
|
157
357
|
{ x: base.x + px * half, y: base.y + py * half },
|
|
158
358
|
{ x: base.x - px * half, y: base.y - py * half },
|
|
159
359
|
];
|
|
160
360
|
return (`<polygon class="uml-arrow uml-arrow-${type}" points="${pointsAttr(pts)}"` +
|
|
161
|
-
` fill="
|
|
361
|
+
` fill="${theme.color.canvas}" stroke="${color}" stroke-width="${theme.stroke.arrow}" stroke-linejoin="round"/>`);
|
|
162
362
|
}
|
|
163
|
-
|
|
363
|
+
/** Solid arrowhead for dependency / association (readable beside small text). */
|
|
364
|
+
function renderChevron(points, type, color, theme) {
|
|
365
|
+
const size = theme.arrow.chevron;
|
|
164
366
|
const { u, tip } = targetDir(points);
|
|
165
|
-
const back = { x: tip.x - u.x *
|
|
367
|
+
const back = { x: tip.x - u.x * size, y: tip.y - u.y * size };
|
|
166
368
|
const px = -u.y;
|
|
167
369
|
const py = u.x;
|
|
168
|
-
const half =
|
|
370
|
+
const half = size * 0.4;
|
|
169
371
|
const pts = [
|
|
170
|
-
{ x: back.x + px * half, y: back.y + py * half },
|
|
171
372
|
tip,
|
|
373
|
+
{ x: back.x + px * half, y: back.y + py * half },
|
|
172
374
|
{ x: back.x - px * half, y: back.y - py * half },
|
|
173
375
|
];
|
|
174
|
-
return (`<
|
|
175
|
-
` fill="
|
|
376
|
+
return (`<polygon class="uml-arrow uml-arrow-${type}" points="${pointsAttr(pts)}"` +
|
|
377
|
+
` fill="${color}" stroke="${color}" stroke-width="${theme.stroke.arrow}" stroke-linejoin="round"/>`);
|
|
176
378
|
}
|
|
177
|
-
function renderDiamond(points, filled, color
|
|
379
|
+
function renderDiamond(points, filled, color, theme) {
|
|
380
|
+
const size = theme.arrow.diamond;
|
|
178
381
|
const { u, tip } = sourceDir(points);
|
|
179
382
|
const px = -u.y;
|
|
180
383
|
const py = u.x;
|
|
181
|
-
const half =
|
|
384
|
+
const half = size / 2;
|
|
182
385
|
const mid = { x: tip.x + u.x * half, y: tip.y + u.y * half };
|
|
183
386
|
const pts = [
|
|
184
387
|
tip,
|
|
185
388
|
{ x: mid.x + px * half, y: mid.y + py * half },
|
|
186
|
-
{ x: tip.x + u.x *
|
|
389
|
+
{ x: tip.x + u.x * size, y: tip.y + u.y * size },
|
|
187
390
|
{ x: mid.x - px * half, y: mid.y - py * half },
|
|
188
391
|
];
|
|
189
392
|
return (`<polygon class="uml-arrow uml-arrow-${filled ? 'composition' : 'aggregation'}"` +
|
|
190
|
-
` points="${pointsAttr(pts)}" fill="${filled ? color :
|
|
191
|
-
` stroke="${color}" stroke-width="
|
|
393
|
+
` points="${pointsAttr(pts)}" fill="${filled ? color : theme.color.canvas}"` +
|
|
394
|
+
` stroke="${color}" stroke-width="${theme.stroke.arrow}" stroke-linejoin="round"/>`);
|
|
192
395
|
}
|
|
193
|
-
function renderEdgeTexts(rel, points,
|
|
396
|
+
function renderEdgeTexts(rel, points, theme, plainLabel) {
|
|
194
397
|
const parts = [];
|
|
398
|
+
const t = (text, x, y, size) => textEl(text, x, y, 'middle', size, 'normal', theme.color.textMember, theme);
|
|
195
399
|
if (rel.sourceCardinality) {
|
|
196
|
-
parts.push(
|
|
400
|
+
parts.push(t(rel.sourceCardinality, points[0].x, points[0].y - 7, 11));
|
|
197
401
|
}
|
|
198
402
|
if (rel.targetCardinality) {
|
|
199
403
|
const last = points[points.length - 1];
|
|
200
|
-
parts.push(
|
|
404
|
+
parts.push(t(rel.targetCardinality, last.x, last.y - 7, 11));
|
|
201
405
|
}
|
|
202
|
-
if (rel.label) {
|
|
203
|
-
const
|
|
204
|
-
parts.push(
|
|
406
|
+
if (rel.label && plainLabel) {
|
|
407
|
+
const r = plainLabel.rect;
|
|
408
|
+
parts.push(textEl(plainLabel.text, r.x + r.width / 2, r.y + 12, 'middle', 12, 'normal', theme.color.textMember, theme, ` class="uml-edge-label" stroke="${theme.color.labelHalo}" stroke-width="${theme.stroke.labelHalo}"` +
|
|
409
|
+
` stroke-linejoin="round" paint-order="stroke"`));
|
|
205
410
|
}
|
|
206
411
|
return parts;
|
|
207
412
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
413
|
+
/**
|
|
414
|
+
* Polyline as an SVG path whose interior corners are rounded with quadratic
|
|
415
|
+
* curves of radius ≤ `radius` (clamped to half of each adjoining segment). The
|
|
416
|
+
* routing — the vertices — is unchanged.
|
|
417
|
+
*/
|
|
418
|
+
export function roundedPath(points, radius) {
|
|
419
|
+
if (points.length < 3 || radius <= 0)
|
|
420
|
+
return 'M' + pointsAttr(points).replace(/ /g, ' L');
|
|
421
|
+
let d = `M${fmt(points[0].x)},${fmt(points[0].y)}`;
|
|
422
|
+
for (let i = 1; i < points.length - 1; i++) {
|
|
423
|
+
const prev = points[i - 1];
|
|
424
|
+
const cur = points[i];
|
|
425
|
+
const next = points[i + 1];
|
|
426
|
+
const l1 = Math.hypot(cur.x - prev.x, cur.y - prev.y);
|
|
427
|
+
const l2 = Math.hypot(next.x - cur.x, next.y - cur.y);
|
|
428
|
+
const r = Math.min(radius, l1 / 2, l2 / 2);
|
|
429
|
+
if (r <= 0) {
|
|
430
|
+
d += ` L${fmt(cur.x)},${fmt(cur.y)}`;
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
const a = { x: cur.x + ((prev.x - cur.x) / l1) * r, y: cur.y + ((prev.y - cur.y) / l1) * r };
|
|
434
|
+
const b = { x: cur.x + ((next.x - cur.x) / l2) * r, y: cur.y + ((next.y - cur.y) / l2) * r };
|
|
435
|
+
d += ` L${fmt(a.x)},${fmt(a.y)} Q${fmt(cur.x)},${fmt(cur.y)} ${fmt(b.x)},${fmt(b.y)}`;
|
|
436
|
+
}
|
|
437
|
+
const last = points[points.length - 1];
|
|
438
|
+
return d + ` L${fmt(last.x)},${fmt(last.y)}`;
|
|
211
439
|
}
|
|
212
440
|
function pointsAttr(points) {
|
|
213
441
|
return points.map((p) => `${fmt(p.x)},${fmt(p.y)}`).join(' ');
|
|
@@ -0,0 +1,109 @@
|
|
|
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 interface Theme {
|
|
8
|
+
font: {
|
|
9
|
+
/** Monospace keeps the pure-JS width table (src/text-measure.ts) exact. */
|
|
10
|
+
family: string;
|
|
11
|
+
/** Size ladder: title/member/package label/edge label/badge. */
|
|
12
|
+
size: {
|
|
13
|
+
title: number;
|
|
14
|
+
member: number;
|
|
15
|
+
packageLabel: number;
|
|
16
|
+
edgeLabel: number;
|
|
17
|
+
badge: number;
|
|
18
|
+
};
|
|
19
|
+
weight: {
|
|
20
|
+
title: string;
|
|
21
|
+
member: string;
|
|
22
|
+
packageLabel: string;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
color: {
|
|
26
|
+
canvas: string;
|
|
27
|
+
/** Node/package outlines (slate, never pure black). */
|
|
28
|
+
stroke: string;
|
|
29
|
+
strokeStub: string;
|
|
30
|
+
nodeFill: string;
|
|
31
|
+
stubFill: string;
|
|
32
|
+
textTitle: string;
|
|
33
|
+
textMember: string;
|
|
34
|
+
textPackage: string;
|
|
35
|
+
edge: string;
|
|
36
|
+
edgeWeak: string;
|
|
37
|
+
/** Halo drawn around edge-label text so a line never strikes through it. */
|
|
38
|
+
labelHalo: string;
|
|
39
|
+
/** Cycle feedback edges — the only use of red. */
|
|
40
|
+
warn: string;
|
|
41
|
+
/** Neutral informational accent (hub badge). */
|
|
42
|
+
accent: string;
|
|
43
|
+
accentText: string;
|
|
44
|
+
/** Low-saturation package fills, cycled by layer / nesting depth. */
|
|
45
|
+
packageFills: string[];
|
|
46
|
+
packageStroke: string;
|
|
47
|
+
legendStroke: string;
|
|
48
|
+
};
|
|
49
|
+
shape: {
|
|
50
|
+
nodeRadius: number;
|
|
51
|
+
packageRadius: number;
|
|
52
|
+
edgeCornerRadius: number;
|
|
53
|
+
};
|
|
54
|
+
stroke: {
|
|
55
|
+
node: number;
|
|
56
|
+
package: number;
|
|
57
|
+
edge: number;
|
|
58
|
+
arrow: number;
|
|
59
|
+
/** Width grows by two steps per ×`heavyWeight` merged relations (log-scaled); `edgeWidthMax` caps it. */
|
|
60
|
+
weightStep: number;
|
|
61
|
+
edgeWidthMax: number;
|
|
62
|
+
/** Width of a cycle feedback edge (weight can only widen it). */
|
|
63
|
+
feedback: number;
|
|
64
|
+
/** Weight (merged relations) from which an edge counts as heavy. */
|
|
65
|
+
heavyWeight: number;
|
|
66
|
+
/** Edge opacity tiers: light = weight 1, medium = weight 2, strong = heavy, feedback = cycle back edge. */
|
|
67
|
+
opacity: {
|
|
68
|
+
light: number;
|
|
69
|
+
medium: number;
|
|
70
|
+
strong: number;
|
|
71
|
+
feedback: number;
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Edge strokes keep their width in screen pixels (`vector-effect:
|
|
75
|
+
* non-scaling-stroke`) so a thumbnail of a large diagram still shows every
|
|
76
|
+
* edge at >= 1px instead of dropping below a pixel.
|
|
77
|
+
*/
|
|
78
|
+
edgeNonScaling: boolean;
|
|
79
|
+
/** Width of the label halo stroke. */
|
|
80
|
+
labelHalo: number;
|
|
81
|
+
};
|
|
82
|
+
/** Line types: weak = dashed relation kinds (dependency, implementation); feedback = cycle back edge (dash-dot). Every other relation kind is solid. */
|
|
83
|
+
dash: {
|
|
84
|
+
weak: string;
|
|
85
|
+
stub: string;
|
|
86
|
+
feedback: string;
|
|
87
|
+
};
|
|
88
|
+
arrow: {
|
|
89
|
+
triangle: number;
|
|
90
|
+
chevron: number;
|
|
91
|
+
diamond: number;
|
|
92
|
+
};
|
|
93
|
+
/** Spacing grid (px); node sizes snap to it. */
|
|
94
|
+
grid: number;
|
|
95
|
+
}
|
|
96
|
+
export declare const defaultTheme: Theme;
|
|
97
|
+
export type ThemeOverrides = {
|
|
98
|
+
[K in keyof Theme]?: Theme[K] extends readonly unknown[] ? Theme[K] : Theme[K] extends object ? {
|
|
99
|
+
[P in keyof Theme[K]]?: Theme[K][P] extends object ? Partial<Theme[K][P]> : Theme[K][P];
|
|
100
|
+
} : Theme[K];
|
|
101
|
+
};
|
|
102
|
+
/** Merge a partial theme over the defaults (arrays replace, objects merge). */
|
|
103
|
+
export declare function resolveTheme(overrides?: ThemeOverrides): Theme;
|
|
104
|
+
/** Every colour value of a theme (for token-membership assertions). */
|
|
105
|
+
export declare function themeColors(theme: Theme): Set<string>;
|
|
106
|
+
/** `fg` at `alpha` composited over `bg` (both `#rrggbb`), as a `#rrggbb` colour. */
|
|
107
|
+
export declare function blendOver(fg: string, bg: string, alpha: number): string;
|
|
108
|
+
/** WCAG relative luminance contrast ratio between two `#rrggbb` colours. */
|
|
109
|
+
export declare function contrastRatio(a: string, b: string): number;
|