@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.
Files changed (65) hide show
  1. package/README.md +20 -0
  2. package/dist/core.d.ts +106 -13
  3. package/dist/core.js +92 -52
  4. package/dist/foundation/labels.d.ts +44 -0
  5. package/dist/foundation/labels.js +231 -0
  6. package/dist/foundation/layers.d.ts +25 -0
  7. package/dist/foundation/layers.js +101 -0
  8. package/dist/foundation/layout.d.ts +116 -0
  9. package/dist/foundation/layout.js +298 -0
  10. package/dist/{model.d.ts → foundation/model.d.ts} +20 -0
  11. package/dist/foundation/relation-semantics.d.ts +49 -0
  12. package/dist/foundation/relation-semantics.js +112 -0
  13. package/dist/{text-measure.d.ts → foundation/text-measure.d.ts} +2 -0
  14. package/dist/{text-measure.js → foundation/text-measure.js} +20 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +1 -1
  17. package/dist/layout/compound.d.ts +74 -0
  18. package/dist/layout/compound.js +1204 -0
  19. package/dist/layout/elk-graph.d.ts +36 -0
  20. package/dist/layout/elk-graph.js +220 -0
  21. package/dist/layout/elk-result.d.ts +18 -0
  22. package/dist/layout/elk-result.js +127 -0
  23. package/dist/layout/elk.d.ts +80 -33
  24. package/dist/layout/elk.js +229 -320
  25. package/dist/layout/fanout-trunk.d.ts +18 -0
  26. package/dist/layout/fanout-trunk.js +380 -0
  27. package/dist/layout/grid-router.d.ts +69 -0
  28. package/dist/layout/grid-router.js +615 -0
  29. package/dist/layout/lane-spread.d.ts +11 -0
  30. package/dist/layout/lane-spread.js +302 -0
  31. package/dist/layout/package-endpoints.d.ts +10 -0
  32. package/dist/layout/package-endpoints.js +54 -0
  33. package/dist/layout/placement-strategy.d.ts +98 -0
  34. package/dist/layout/placement-strategy.js +26 -0
  35. package/dist/layout/strategy.d.ts +27 -0
  36. package/dist/layout/strategy.js +19 -0
  37. package/dist/layout/trunk-lanes.d.ts +29 -0
  38. package/dist/layout/trunk-lanes.js +308 -0
  39. package/dist/layout/viz.d.ts +3 -3
  40. package/dist/layout/viz.js +13 -17
  41. package/dist/metrics/layout-metrics.d.ts +183 -0
  42. package/dist/metrics/layout-metrics.js +766 -0
  43. package/dist/parse/parser.d.ts +43 -0
  44. package/dist/{parser.js → parse/parser.js} +111 -5
  45. package/dist/parse/serialize.d.ts +32 -0
  46. package/dist/parse/serialize.js +218 -0
  47. package/dist/render/legend.d.ts +15 -0
  48. package/dist/render/legend.js +42 -0
  49. package/dist/render/svg.d.ts +60 -3
  50. package/dist/render/svg.js +326 -98
  51. package/dist/render/theme.d.ts +109 -0
  52. package/dist/render/theme.js +99 -0
  53. package/dist/{transform.d.ts → transform/transform.d.ts} +55 -1
  54. package/dist/{transform.js → transform/transform.js} +277 -29
  55. package/package.json +4 -3
  56. package/dist/layout-metrics.d.ts +0 -42
  57. package/dist/layout-metrics.js +0 -241
  58. package/dist/layout.d.ts +0 -75
  59. package/dist/layout.js +0 -51
  60. package/dist/parser.d.ts +0 -17
  61. /package/dist/{edge-router.d.ts → foundation/edge-router.d.ts} +0 -0
  62. /package/dist/{edge-router.js → foundation/edge-router.js} +0 -0
  63. /package/dist/{model.js → foundation/model.js} +0 -0
  64. /package/dist/{render-dot.d.ts → layout/render-dot.d.ts} +0 -0
  65. /package/dist/{render-dot.js → layout/render-dot.js} +0 -0
@@ -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
- const STROKE = '#333333';
9
- const TRIANGLE_SIZE = 10;
10
- const CHEVRON_SIZE = 9;
11
- const DIAMOND_SIZE = 13;
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
- export function renderSvg(model, layout) {
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
- body.push(renderPackage(pkg, box));
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 width = layout.width;
45
- const height = layout.height;
46
- return (`<svg xmlns="http://www.w3.org/2000/svg" width="${fmt(width)}" height="${fmt(height)}"` +
47
- ` viewBox="0 0 ${fmt(width)} ${fmt(height)}">${body.join('')}</svg>`);
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 renderEntity(entity, node) {
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
- const fill = stub ? '#f6f8fa' : '#ffffff';
55
- const stubAttrs = stub ? ` class="uml-stub" stroke-dasharray="5,3"` : '';
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="2" ry="2" fill="${fill}"` +
58
- `${stubAttrs} stroke="${STROKE}" stroke-width="1.5"/>`);
59
- const titleBaseline = node.y + METRICS.paddingY + METRICS.fontSize - 2;
60
- parts.push(`<line x1="${fmt(node.x)}" y1="${fmt(node.y + METRICS.paddingY + METRICS.lineHeight)}"` +
61
- ` x2="${fmt(node.x + node.width)}" y2="${fmt(node.y + METRICS.paddingY + METRICS.lineHeight)}"` +
62
- ` stroke="${STROKE}" stroke-width="1"/>`);
63
- parts.push(`<text x="${fmt(node.x + node.width / 2)}" y="${fmt(titleBaseline)}"` +
64
- ` text-anchor="middle" font-family="${METRICS.fontFamily}" font-size="${METRICS.fontSize}"` +
65
- `${fontStyle} fill="#000000">${escapeXml(displayName(entity))}</text>`);
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 - 2;
68
- parts.push(`<text x="${fmt(node.x + METRICS.paddingX)}" y="${fmt(baseline)}"` +
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, so a browser host
76
- // can bind hover/click handlers and cross-panel linkage by `data-entity-id`.
77
- return (`<g data-entity-id="${escapeXml(entity.id)}" class="uml-node">` +
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 at the top-right corner of
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 = node.x + node.width - 12;
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="9" fill="#c0392b"/>` +
90
- `<text x="${fmt(cx)}" y="${fmt(cy + 3.5)}" text-anchor="middle"` +
91
- ` font-family="${METRICS.fontFamily}" font-size="11" fill="#ffffff">${count}</text>` +
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)}" fill="none" stroke="${STROKE}" stroke-width="1.5"/>`);
99
- parts.push(`<rect x="${fmt(box.x)}" y="${fmt(box.y)}" width="${fmt(tabWidth)}"` +
100
- ` height="${PACKAGE_LABEL.tabHeight}" fill="#ffffff" stroke="${STROKE}" stroke-width="1.5"/>`);
101
- parts.push(`<text x="${fmt(box.x + 6)}" y="${fmt(box.y + 13)}" text-anchor="start"` +
102
- ` font-family="${METRICS.fontFamily}" font-size="${PACKAGE_LABEL.fontSize}" fill="#000000">${escapeXml(pkg.name)}</text>`);
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
- function renderEdge(rel, edge) {
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 dashed = rel.type === 'implementation' || rel.type === 'dependency';
112
- const color = rel.feedback ? '#c0392b' : STROKE;
113
- const sourceTrim = rel.type === 'aggregation' || rel.type === 'composition' ? DIAMOND_SIZE : 0;
114
- const targetTrim = rel.type === 'inheritance' || rel.type === 'implementation'
115
- ? TRIANGLE_SIZE
116
- : rel.type === 'dependency' || rel.type === 'association'
117
- ? CHEVRON_SIZE
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 line,
122
- // plus a `×N` label when more than one relation collapsed into this edge.
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 strokeWidth = Math.min(1.2 + (weight - 1) * 0.4, 6);
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(`<polyline points="${pointsAttr(linePoints)}" fill="none" stroke="${color}"` +
127
- ` stroke-width="${fmt(strokeWidth)}"${dashed ? ' stroke-dasharray="6,4"' : ''}${feedbackClass}/>`);
128
- if (rel.type === 'aggregation' || rel.type === 'composition') {
129
- parts.push(renderDiamond(points, rel.type === 'composition', color));
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 (rel.type === 'inheritance' || rel.type === 'implementation') {
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 (rel.type === 'dependency' || rel.type === 'association') {
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, color));
138
- if (weight > 1) {
139
- const mid = points[Math.floor(points.length / 2)];
140
- parts.push(textAt(`×${weight}`, mid.x, mid.y - (rel.label ? 20 : 10), 11, color));
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
- function renderTriangle(points, type, color = STROKE) {
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 * TRIANGLE_SIZE, y: tip.y - u.y * TRIANGLE_SIZE };
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 = TRIANGLE_SIZE / 2;
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="none" stroke="${color}" stroke-width="1.2"/>`);
361
+ ` fill="${theme.color.canvas}" stroke="${color}" stroke-width="${theme.stroke.arrow}" stroke-linejoin="round"/>`);
162
362
  }
163
- function renderChevron(points, type, color = STROKE) {
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 * CHEVRON_SIZE, y: tip.y - u.y * CHEVRON_SIZE };
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 = CHEVRON_SIZE / 2;
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 (`<polyline class="uml-arrow uml-arrow-${type}" points="${pointsAttr(pts)}"` +
175
- ` fill="none" stroke="${color}" stroke-width="1.2"/>`);
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 = STROKE) {
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 = DIAMOND_SIZE / 2;
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 * DIAMOND_SIZE, y: tip.y + u.y * DIAMOND_SIZE },
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 : 'none'}"` +
191
- ` stroke="${color}" stroke-width="1.2"/>`);
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, color = '#000000') {
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(textAt(rel.sourceCardinality, points[0].x, points[0].y - 7, 11, color));
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(textAt(rel.targetCardinality, last.x, last.y - 7, 11, color));
404
+ parts.push(t(rel.targetCardinality, last.x, last.y - 7, 11));
201
405
  }
202
- if (rel.label) {
203
- const mid = points[Math.floor(points.length / 2)];
204
- parts.push(textAt(rel.label, mid.x, mid.y - 10, 12, color));
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
- function textAt(text, x, y, size, color = '#000000') {
209
- return (`<text x="${fmt(x)}" y="${fmt(y)}" text-anchor="middle"` +
210
- ` font-family="${METRICS.fontFamily}" font-size="${size}" fill="${color}">${escapeXml(text)}</text>`);
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;