@yalehwang/plantuml-js 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +20 -0
  2. package/dist/core.d.ts +102 -9
  3. package/dist/core.js +91 -51
  4. package/dist/foundation/layers.d.ts +25 -0
  5. package/dist/foundation/layers.js +101 -0
  6. package/dist/foundation/layout.d.ts +116 -0
  7. package/dist/foundation/layout.js +298 -0
  8. package/dist/{model.d.ts → foundation/model.d.ts} +20 -0
  9. package/dist/foundation/relation-semantics.d.ts +49 -0
  10. package/dist/foundation/relation-semantics.js +112 -0
  11. package/dist/{text-measure.d.ts → foundation/text-measure.d.ts} +2 -0
  12. package/dist/{text-measure.js → foundation/text-measure.js} +20 -1
  13. package/dist/index.d.ts +2 -2
  14. package/dist/index.js +1 -1
  15. package/dist/layout/compound.d.ts +59 -0
  16. package/dist/layout/compound.js +1178 -0
  17. package/dist/layout/elk-graph.d.ts +36 -0
  18. package/dist/layout/elk-graph.js +220 -0
  19. package/dist/layout/elk-result.d.ts +18 -0
  20. package/dist/layout/elk-result.js +127 -0
  21. package/dist/layout/elk.d.ts +63 -33
  22. package/dist/layout/elk.js +215 -321
  23. package/dist/layout/fanout-trunk.d.ts +18 -0
  24. package/dist/layout/fanout-trunk.js +380 -0
  25. package/dist/layout/grid-router.d.ts +69 -0
  26. package/dist/layout/grid-router.js +615 -0
  27. package/dist/layout/lane-spread.d.ts +11 -0
  28. package/dist/layout/lane-spread.js +302 -0
  29. package/dist/layout/layout-metrics.d.ts +183 -0
  30. package/dist/layout/layout-metrics.js +766 -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/parser.d.ts +27 -1
  42. package/dist/parser.js +111 -5
  43. package/dist/render/labels.d.ts +44 -0
  44. package/dist/render/labels.js +231 -0
  45. package/dist/render/legend.d.ts +15 -0
  46. package/dist/render/legend.js +42 -0
  47. package/dist/render/svg.d.ts +60 -3
  48. package/dist/render/svg.js +326 -98
  49. package/dist/render/theme.d.ts +109 -0
  50. package/dist/render/theme.js +99 -0
  51. package/dist/serialize.d.ts +32 -0
  52. package/dist/serialize.js +218 -0
  53. package/dist/transform.d.ts +55 -1
  54. package/dist/transform.js +277 -29
  55. package/package.json +9 -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/{edge-router.d.ts → foundation/edge-router.d.ts} +0 -0
  61. /package/dist/{edge-router.js → foundation/edge-router.js} +0 -0
  62. /package/dist/{model.js → foundation/model.js} +0 -0
  63. /package/dist/{render-dot.d.ts → foundation/render-dot.d.ts} +0 -0
  64. /package/dist/{render-dot.js → foundation/render-dot.js} +0 -0
package/dist/parser.d.ts CHANGED
@@ -7,7 +7,9 @@
7
7
  * generics (`List~Item~`, `BaseEntity<T>`), package/namespace grouping,
8
8
  * aliases, and tolerant skipping of skinparam/hide/note directives.
9
9
  */
10
- import { PlantUmlModel } from './model.js';
10
+ import { PlantUmlModel } from './foundation/model.js';
11
+ export declare const ENTITY_LINE: RegExp;
12
+ export declare const ENTITY_KEYWORD: RegExp;
11
13
  /**
12
14
  * Parse a PlantUML source string into a PlantUmlModel.
13
15
  *
@@ -15,3 +17,27 @@ import { PlantUmlModel } from './model.js';
15
17
  * legend, …) are tolerated and skipped rather than failing the parse.
16
18
  */
17
19
  export declare function parsePlantUml(source: string): PlantUmlModel;
20
+ /** An undeclared relation endpoint that the parser resolved on the author's behalf. */
21
+ export interface EndpointDiagnostic {
22
+ /** 1-based source line of the relation. */
23
+ line: number;
24
+ message: string;
25
+ }
26
+ /**
27
+ * Parse and also report how relation endpoints were resolved: endpoints naming
28
+ * a package are re-attached to a representative entity inside it, and endpoints
29
+ * naming nothing declared become implicit (empty) classes, as PlantUML does.
30
+ */
31
+ export declare function parseWithDiagnostics(source: string): {
32
+ model: PlantUmlModel;
33
+ diagnostics: EndpointDiagnostic[];
34
+ };
35
+ /** Strip PlantUML `'` line comments (a leading `'` or a trailing inline `'`). */
36
+ export declare function stripComment(line: string): string;
37
+ /**
38
+ * Extract a declaration name from an entity line's tail, mirroring the parser:
39
+ * accepts a bare token (`class A`) or a quoted name (`class "User Repo"`), and
40
+ * tolerates a trailing `as <alias>`. Returns null when the name is missing or
41
+ * the quoted name is unterminated.
42
+ */
43
+ export declare function extractDeclaredName(rest: string): string | null;
package/dist/parser.js CHANGED
@@ -7,8 +7,9 @@
7
7
  * generics (`List~Item~`, `BaseEntity<T>`), package/namespace grouping,
8
8
  * aliases, and tolerant skipping of skinparam/hide/note directives.
9
9
  */
10
- import { emptyModel, } from './model.js';
11
- const ENTITY_LINE = /^(abstract\s+)?(class|interface|enum)\s+(.+)$/i;
10
+ import { emptyModel, } from './foundation/model.js';
11
+ export const ENTITY_LINE = /^(abstract\s+)?(class|interface|enum)\s+(.+)$/i;
12
+ export const ENTITY_KEYWORD = /^(?:abstract\s+)?(?:class|interface|enum)\b/i;
12
13
  const ARROWS = {
13
14
  '--|>': { type: 'inheritance' },
14
15
  '<|--': { type: 'inheritance', swap: true },
@@ -31,6 +32,91 @@ const ARROWS = {
31
32
  * legend, …) are tolerated and skipped rather than failing the parse.
32
33
  */
33
34
  export function parsePlantUml(source) {
35
+ return parseWithDiagnostics(source).model;
36
+ }
37
+ /**
38
+ * Parse and also report how relation endpoints were resolved: endpoints naming
39
+ * a package are re-attached to a representative entity inside it, and endpoints
40
+ * naming nothing declared become implicit (empty) classes, as PlantUML does.
41
+ */
42
+ export function parseWithDiagnostics(source) {
43
+ const relationLines = [];
44
+ const model = parseRaw(source, relationLines);
45
+ const diagnostics = resolveEndpoints(model, relationLines);
46
+ return { model, diagnostics };
47
+ }
48
+ function resolveEndpoints(model, relationLines) {
49
+ const diagnostics = [];
50
+ const entityIds = new Set(model.entities.map((e) => e.id));
51
+ const packageIds = new Set(model.packages.map((p) => p.id));
52
+ const parentOf = new Map(model.packages.map((p) => [p.id, p.parentId]));
53
+ const inPackage = (entityPkg, pkg) => {
54
+ for (let cur = entityPkg; cur !== undefined; cur = parentOf.get(cur)) {
55
+ if (cur === pkg)
56
+ return true;
57
+ }
58
+ return false;
59
+ };
60
+ const representative = (pkg) => model.entities.find((e) => inPackage(e.packageId, pkg))?.id;
61
+ // An endpoint may name a package by its id or by the identifier form of its
62
+ // display name (`CLI_Layer` for `"CLI Layer (命令层)"`), as authors write it.
63
+ const packageByKey = new Map();
64
+ for (const p of model.packages) {
65
+ for (const key of [p.id, packageKey(p.name)]) {
66
+ if (!packageByKey.has(key))
67
+ packageByKey.set(key, p.id);
68
+ }
69
+ }
70
+ const asPackage = (id) => packageIds.has(id) ? id : packageByKey.get(id);
71
+ const resolve = (id, line, pkgOut) => {
72
+ if (entityIds.has(id))
73
+ return id;
74
+ const pkg = asPackage(id);
75
+ if (pkg !== undefined) {
76
+ const rep = representative(pkg);
77
+ if (rep !== undefined) {
78
+ diagnostics.push({
79
+ line,
80
+ message: `relation endpoint "${id}" is a package; edge attached to "${rep}" inside it`,
81
+ });
82
+ pkgOut(pkg);
83
+ return rep;
84
+ }
85
+ }
86
+ model.entities.push({
87
+ id,
88
+ name: id,
89
+ generics: [],
90
+ kind: 'class',
91
+ members: [],
92
+ external: true,
93
+ });
94
+ entityIds.add(id);
95
+ diagnostics.push({
96
+ line,
97
+ message: `relation endpoint "${id}" is not declared; implicit class created`,
98
+ });
99
+ return id;
100
+ };
101
+ model.relations.forEach((r, i) => {
102
+ const line = relationLines[i] ?? 0;
103
+ r.source = resolve(r.source, line, (p) => {
104
+ r.sourcePackageId = p;
105
+ });
106
+ r.target = resolve(r.target, line, (p) => {
107
+ r.targetPackageId = p;
108
+ });
109
+ });
110
+ return diagnostics;
111
+ }
112
+ /** Identifier form of a package display name: parenthetical dropped, non-word runs → `_`. */
113
+ function packageKey(name) {
114
+ return name
115
+ .replace(/\s*\([^)]*\)\s*$/, '')
116
+ .trim()
117
+ .replace(/[^\p{L}\p{N}]+/gu, '_');
118
+ }
119
+ function parseRaw(source, relationLines) {
34
120
  const model = emptyModel();
35
121
  const lines = source.split(/\r?\n/);
36
122
  let inDiagram = false;
@@ -40,8 +126,8 @@ export function parsePlantUml(source) {
40
126
  stack.push(frame);
41
127
  lastDeclaration = null;
42
128
  };
43
- for (const raw of lines) {
44
- const line = raw.trim();
129
+ for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
130
+ const line = lines[lineIdx].trim();
45
131
  if (line === '')
46
132
  continue;
47
133
  if (/^@startuml\b/i.test(line)) {
@@ -116,6 +202,7 @@ export function parsePlantUml(source) {
116
202
  const relation = parseRelationLine(code);
117
203
  if (relation) {
118
204
  model.relations.push(relation);
205
+ relationLines.push(lineIdx + 1);
119
206
  lastDeclaration = null;
120
207
  continue;
121
208
  }
@@ -366,9 +453,28 @@ function stripQuotes(token) {
366
453
  return token;
367
454
  }
368
455
  /** Strip PlantUML `'` line comments (a leading `'` or a trailing inline `'`). */
369
- function stripComment(line) {
456
+ export function stripComment(line) {
370
457
  const idx = line.indexOf("'");
371
458
  if (idx === -1)
372
459
  return line.trim();
373
460
  return line.slice(0, idx).trim();
374
461
  }
462
+ /**
463
+ * Extract a declaration name from an entity line's tail, mirroring the parser:
464
+ * accepts a bare token (`class A`) or a quoted name (`class "User Repo"`), and
465
+ * tolerates a trailing `as <alias>`. Returns null when the name is missing or
466
+ * the quoted name is unterminated.
467
+ */
468
+ export function extractDeclaredName(rest) {
469
+ const asIndex = rest.search(/\s+as\s+\S+\s*$/i);
470
+ const core = asIndex === -1 ? rest.trim() : rest.slice(0, asIndex).trim();
471
+ if (core === '')
472
+ return null;
473
+ if (core.startsWith('"')) {
474
+ const close = core.indexOf('"', 1);
475
+ if (close === -1)
476
+ return null;
477
+ return core.slice(1, close);
478
+ }
479
+ return core.split(/\s+/)[0] || null;
480
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Edge-label and hub-badge geometry shared by the SVG renderer and the layout
3
+ * metrics, so a drawn label and a measured label are the same rectangle.
4
+ *
5
+ * Weight labels (`×N`) are placed on the edge itself over a white backing box.
6
+ * Candidate anchors run along the edge's segments, longest first; the first
7
+ * candidate that collides with no node and no previously placed label wins, so
8
+ * labels of neighbouring edges do not pile onto each other.
9
+ */
10
+ import { PlantUmlModel, PlantUmlEntity } from '../foundation/model.js';
11
+ import { LayoutResult, NodeLayout, Point } from '../foundation/layout.js';
12
+ import { Rect } from '../foundation/edge-router.js';
13
+ export declare const EDGE_LABEL_FONT_SIZE = 11;
14
+ export declare const HUB_BADGE_RADIUS = 9;
15
+ export interface EdgeLabelPlacement {
16
+ /** Index into `model.relations` (edge id `rel-<index>`). */
17
+ edge: number;
18
+ kind: 'weight' | 'label';
19
+ text: string;
20
+ rect: Rect;
21
+ }
22
+ /** Hub badge circle: centred on the node's top-right corner, half outside the box. */
23
+ export declare function hubBadgeCircle(node: NodeLayout): {
24
+ cx: number;
25
+ cy: number;
26
+ r: number;
27
+ };
28
+ export declare function hubBadgeRect(node: NodeLayout): Rect;
29
+ /** Bounding boxes of the text lines drawn inside a node (title + members). */
30
+ export declare function entityTextRects(entity: PlantUmlEntity, node: NodeLayout): Rect[];
31
+ export declare function rectsIntersect(a: Rect, b: Rect): boolean;
32
+ /** Same-text labels sharing a source (or target) and the other end's package collapse to one. */
33
+ export declare const LABEL_DEDUPE_MIN = 3;
34
+ /** A collapsed label standing for at least this many edges carries a `×N` count. */
35
+ export declare const LABEL_COUNT_BADGE_MIN = 4;
36
+ /** True when the segment ab touches the rectangle (Liang–Barsky clip). */
37
+ export declare function segmentIntersectsRect(a: Point, b: Point, r: Rect): boolean;
38
+ /** Edge ids (`rel-<i>`) of every segment of every edge other than `ownId` that crosses `rect`. */
39
+ export declare function rectHitsOtherEdge(rect: Rect, edges: {
40
+ id: string;
41
+ points: Point[];
42
+ }[], ownId: string): boolean;
43
+ /** Place every weight label and collect every plain edge label, deterministically. */
44
+ export declare function placeEdgeLabels(model: PlantUmlModel, layout: LayoutResult): EdgeLabelPlacement[];
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Edge-label and hub-badge geometry shared by the SVG renderer and the layout
3
+ * metrics, so a drawn label and a measured label are the same rectangle.
4
+ *
5
+ * Weight labels (`×N`) are placed on the edge itself over a white backing box.
6
+ * Candidate anchors run along the edge's segments, longest first; the first
7
+ * candidate that collides with no node and no previously placed label wins, so
8
+ * labels of neighbouring edges do not pile onto each other.
9
+ */
10
+ import { METRICS, displayName } from '../foundation/layout.js';
11
+ import { nodeRect } from '../foundation/edge-router.js';
12
+ import { measureTextWidth } from '../foundation/text-measure.js';
13
+ export const EDGE_LABEL_FONT_SIZE = 11;
14
+ const EDGE_LABEL_PAD_X = 3;
15
+ const EDGE_LABEL_HEIGHT = 14;
16
+ /** Fractions along a segment tried, in order, for a label anchor. */
17
+ const ANCHOR_FRACTIONS = [0.5, 0.3, 0.7, 0.15, 0.85];
18
+ export const HUB_BADGE_RADIUS = 9;
19
+ /** Hub badge circle: centred on the node's top-right corner, half outside the box. */
20
+ export function hubBadgeCircle(node) {
21
+ return { cx: node.x + node.width, cy: node.y, r: HUB_BADGE_RADIUS };
22
+ }
23
+ export function hubBadgeRect(node) {
24
+ const { cx, cy, r } = hubBadgeCircle(node);
25
+ return { x: cx - r, y: cy - r, width: 2 * r, height: 2 * r };
26
+ }
27
+ /** Bounding boxes of the text lines drawn inside a node (title + members). */
28
+ export function entityTextRects(entity, node) {
29
+ const rects = [];
30
+ const title = measureTextWidth(displayName(entity), METRICS.fontSize);
31
+ rects.push({
32
+ x: node.x + (node.width - title) / 2,
33
+ y: node.y + METRICS.paddingY,
34
+ width: title,
35
+ height: METRICS.lineHeight,
36
+ });
37
+ entity.members.forEach((member, i) => {
38
+ rects.push({
39
+ x: node.x + METRICS.paddingX,
40
+ y: node.y + METRICS.paddingY + METRICS.lineHeight * (i + 1),
41
+ width: measureTextWidth(member.raw, METRICS.fontSize),
42
+ height: METRICS.lineHeight,
43
+ });
44
+ });
45
+ return rects;
46
+ }
47
+ export function rectsIntersect(a, b) {
48
+ return (a.x < b.x + b.width &&
49
+ b.x < a.x + a.width &&
50
+ a.y < b.y + b.height &&
51
+ b.y < a.y + a.height);
52
+ }
53
+ /** Same-text labels sharing a source (or target) and the other end's package collapse to one. */
54
+ export const LABEL_DEDUPE_MIN = 3;
55
+ /** A collapsed label standing for at least this many edges carries a `×N` count. */
56
+ export const LABEL_COUNT_BADGE_MIN = 4;
57
+ const PLAIN_LABEL_FONT_SIZE = 12;
58
+ const PLAIN_LABEL_HEIGHT = 14;
59
+ /** Gap between a beside-the-line label and the line. */
60
+ const LABEL_SIDE_GAP = 2;
61
+ /** True when the segment ab touches the rectangle (Liang–Barsky clip). */
62
+ export function segmentIntersectsRect(a, b, r) {
63
+ let t0 = 0;
64
+ let t1 = 1;
65
+ const dx = b.x - a.x;
66
+ const dy = b.y - a.y;
67
+ const clip = (p, q) => {
68
+ if (p === 0)
69
+ return q >= 0;
70
+ const t = q / p;
71
+ if (p < 0) {
72
+ if (t > t1)
73
+ return false;
74
+ if (t > t0)
75
+ t0 = t;
76
+ }
77
+ else {
78
+ if (t < t0)
79
+ return false;
80
+ if (t < t1)
81
+ t1 = t;
82
+ }
83
+ return true;
84
+ };
85
+ return (clip(-dx, a.x - r.x) &&
86
+ clip(dx, r.x + r.width - a.x) &&
87
+ clip(-dy, a.y - r.y) &&
88
+ clip(dy, r.y + r.height - a.y));
89
+ }
90
+ /** Edge ids (`rel-<i>`) of every segment of every edge other than `ownId` that crosses `rect`. */
91
+ export function rectHitsOtherEdge(rect, edges, ownId) {
92
+ for (const e of edges) {
93
+ if (e.id === ownId)
94
+ continue;
95
+ for (let i = 0; i + 1 < e.points.length; i++) {
96
+ if (segmentIntersectsRect(e.points[i], e.points[i + 1], rect))
97
+ return true;
98
+ }
99
+ }
100
+ return false;
101
+ }
102
+ /**
103
+ * Plain labels that repeat along one bundle (same text, same source — or same
104
+ * target — and the same package at the other end) show on a single
105
+ * representative edge; the rest keep a `<title>` tooltip. Returns the
106
+ * representative index → group size, and the set of suppressed indexes.
107
+ */
108
+ function dedupePlainLabels(model) {
109
+ const pkgOf = new Map(model.entities.map((e) => [e.id, e.packageId ?? e.id]));
110
+ const reps = new Map();
111
+ const suppressed = new Set();
112
+ for (const end of ['source', 'target']) {
113
+ const other = end === 'source' ? 'target' : 'source';
114
+ const groups = new Map();
115
+ model.relations.forEach((rel, i) => {
116
+ if (!rel.label || suppressed.has(i))
117
+ return;
118
+ const key = `${rel[end]}\u0000${rel.label}\u0000${pkgOf.get(rel[other]) ?? rel[other]}`;
119
+ const list = groups.get(key);
120
+ if (list)
121
+ list.push(i);
122
+ else
123
+ groups.set(key, [i]);
124
+ });
125
+ for (const g of groups.values()) {
126
+ if (g.length < LABEL_DEDUPE_MIN)
127
+ continue;
128
+ const total = g.reduce((n, i) => n + (reps.get(i) ?? 1), 0);
129
+ reps.set(g[0], total);
130
+ for (const i of g.slice(1)) {
131
+ suppressed.add(i);
132
+ reps.delete(i);
133
+ }
134
+ }
135
+ }
136
+ return { reps, suppressed };
137
+ }
138
+ /**
139
+ * Candidate rectangles for a plain label: beside each segment (longest first),
140
+ * offset to either side so the text never sits on its own line.
141
+ */
142
+ function plainLabelCandidates(points, width, height) {
143
+ const segs = [];
144
+ for (let i = 0; i + 1 < points.length; i++) {
145
+ const len = Math.hypot(points[i + 1].x - points[i].x, points[i + 1].y - points[i].y);
146
+ if (len > 0)
147
+ segs.push({ a: points[i], b: points[i + 1], len });
148
+ }
149
+ segs.sort((p, q) => q.len - p.len);
150
+ const rects = [];
151
+ for (const { a, b } of segs) {
152
+ const horizontal = Math.abs(b.x - a.x) >= Math.abs(b.y - a.y);
153
+ for (const t of ANCHOR_FRACTIONS) {
154
+ const cx = a.x + (b.x - a.x) * t;
155
+ const cy = a.y + (b.y - a.y) * t;
156
+ for (const side of [-1, 1]) {
157
+ rects.push(horizontal
158
+ ? { x: cx - width / 2, y: side < 0 ? cy - LABEL_SIDE_GAP - height : cy + LABEL_SIDE_GAP, width, height }
159
+ : { x: side < 0 ? cx - LABEL_SIDE_GAP - width : cx + LABEL_SIDE_GAP, y: cy - height / 2, width, height });
160
+ }
161
+ }
162
+ }
163
+ return rects;
164
+ }
165
+ /** Place every weight label and collect every plain edge label, deterministically. */
166
+ export function placeEdgeLabels(model, layout) {
167
+ const edges = layout.edges ?? [];
168
+ const edgesById = new Map(edges.map((e) => [e.id, e]));
169
+ const nodeRects = layout.nodes.map(nodeRect);
170
+ const out = [];
171
+ const placed = [];
172
+ const { reps, suppressed } = dedupePlainLabels(model);
173
+ // A plain label sits beside its own edge, clear of every node, every other
174
+ // edge and every earlier label. A label that fits nowhere is left out (the
175
+ // renderer keeps it as a `<title>` tooltip).
176
+ model.relations.forEach((rel, edge) => {
177
+ const points = edgesById.get(`rel-${edge}`)?.points;
178
+ if (!points || points.length < 2 || !rel.label || suppressed.has(edge))
179
+ return;
180
+ const count = reps.get(edge) ?? 1;
181
+ const text = count >= LABEL_COUNT_BADGE_MIN ? `${rel.label} ×${count}` : rel.label;
182
+ const width = measureTextWidth(text, PLAIN_LABEL_FONT_SIZE);
183
+ const free = plainLabelCandidates(points, width, PLAIN_LABEL_HEIGHT).find((rect) => !nodeRects.some((n) => rectsIntersect(rect, n)) &&
184
+ !placed.some((p) => rectsIntersect(rect, p)) &&
185
+ !rectHitsOtherEdge(rect, edges, `rel-${edge}`) &&
186
+ !ownEdgeTouches(rect, points));
187
+ if (!free)
188
+ return;
189
+ placed.push(free);
190
+ out.push({ edge, kind: 'label', text, rect: free });
191
+ });
192
+ model.relations.forEach((rel, edge) => {
193
+ const points = edgesById.get(`rel-${edge}`)?.points;
194
+ if (!points || points.length < 2 || !rel.weight || rel.weight <= 1)
195
+ return;
196
+ const text = `×${rel.weight}`;
197
+ const width = measureTextWidth(text, EDGE_LABEL_FONT_SIZE) + EDGE_LABEL_PAD_X * 2;
198
+ const candidates = labelCandidates(points, width, EDGE_LABEL_HEIGHT);
199
+ const clear = (rect) => !nodeRects.some((n) => rectsIntersect(rect, n)) && !placed.some((p) => rectsIntersect(rect, p));
200
+ const free = candidates.find((rect) => clear(rect) && !rectHitsOtherEdge(rect, edges, `rel-${edge}`)) ??
201
+ candidates.find(clear);
202
+ const rect = free ?? candidates[0];
203
+ placed.push(rect);
204
+ out.push({ edge, kind: 'weight', text, rect });
205
+ });
206
+ return out;
207
+ }
208
+ function ownEdgeTouches(rect, points) {
209
+ for (let i = 0; i + 1 < points.length; i++) {
210
+ if (segmentIntersectsRect(points[i], points[i + 1], rect))
211
+ return true;
212
+ }
213
+ return false;
214
+ }
215
+ function labelCandidates(points, width, height) {
216
+ const segs = [];
217
+ for (let i = 0; i + 1 < points.length; i++) {
218
+ const len = Math.hypot(points[i + 1].x - points[i].x, points[i + 1].y - points[i].y);
219
+ segs.push({ a: points[i], b: points[i + 1], len });
220
+ }
221
+ segs.sort((p, q) => q.len - p.len);
222
+ const rects = [];
223
+ for (const { a, b } of segs) {
224
+ for (const t of ANCHOR_FRACTIONS) {
225
+ const cx = a.x + (b.x - a.x) * t;
226
+ const cy = a.y + (b.y - a.y) * t;
227
+ rects.push({ x: cx - width / 2, y: cy - height / 2, width, height });
228
+ }
229
+ }
230
+ return rects;
231
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Legend — a small key explaining the visual encodings of a rendered diagram:
3
+ * feedback (back) edges, hub badges, `×N` weights and the dashed/solid node
4
+ * frames. Drawn below the diagram, so it never covers layout geometry.
5
+ */
6
+ import { Theme } from './theme.js';
7
+ /** Entity count above which the legend is drawn by default. */
8
+ export declare const LEGEND_DEFAULT_NODE_THRESHOLD = 30;
9
+ export interface Legend {
10
+ /** `<g class="uml-legend">` markup positioned at (x, y). */
11
+ svg: string;
12
+ width: number;
13
+ height: number;
14
+ }
15
+ export declare function renderLegend(x: number, y: number, theme: Theme): Legend;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Legend — a small key explaining the visual encodings of a rendered diagram:
3
+ * feedback (back) edges, hub badges, `×N` weights and the dashed/solid node
4
+ * frames. Drawn below the diagram, so it never covers layout geometry.
5
+ */
6
+ const ROW_HEIGHT = 20;
7
+ const PAD = 10;
8
+ const SWATCH_WIDTH = 34;
9
+ const FONT_SIZE = 11;
10
+ /** Entity count above which the legend is drawn by default. */
11
+ export const LEGEND_DEFAULT_NODE_THRESHOLD = 30;
12
+ const ITEMS = [
13
+ 'feedback edge (dash-dot; closes a dependency cycle)',
14
+ 'hub badge: number of dependents (edges hidden)',
15
+ '×N: number of merged relations',
16
+ 'dashed frame: grouped / external package',
17
+ 'solid frame: single module',
18
+ ];
19
+ export function renderLegend(x, y, theme) {
20
+ const { color } = theme;
21
+ const width = PAD * 2 + SWATCH_WIDTH + 8 + 300;
22
+ const height = PAD * 2 + ROW_HEIGHT * ITEMS.length;
23
+ const rowY = (i) => y + PAD + ROW_HEIGHT * i + ROW_HEIGHT / 2;
24
+ const sx = x + PAD;
25
+ const tx = sx + SWATCH_WIDTH + 8;
26
+ const swatches = [
27
+ `<line x1="${sx}" y1="${rowY(0)}" x2="${sx + SWATCH_WIDTH}" y2="${rowY(0)}" stroke="${color.warn}" stroke-width="${theme.stroke.edge}" stroke-dasharray="${theme.dash.feedback}"/>`,
28
+ `<circle cx="${sx + SWATCH_WIDTH / 2}" cy="${rowY(1)}" r="8" fill="${color.accent}"/>` +
29
+ `<text x="${sx + SWATCH_WIDTH / 2}" y="${rowY(1) + 3.5}" text-anchor="middle" font-family="${theme.font.family}" font-size="10" fill="${color.accentText}">N</text>`,
30
+ `<line x1="${sx}" y1="${rowY(2)}" x2="${sx + SWATCH_WIDTH}" y2="${rowY(2)}" stroke="${color.edge}" stroke-width="2.5" stroke-opacity="${theme.stroke.opacity.strong}"/>`,
31
+ `<rect x="${sx}" y="${rowY(3) - 7}" width="${SWATCH_WIDTH}" height="14" rx="3" fill="${color.stubFill}" stroke="${color.strokeStub}" stroke-width="${theme.stroke.node}" stroke-dasharray="${theme.dash.stub}"/>`,
32
+ `<rect x="${sx}" y="${rowY(4) - 7}" width="${SWATCH_WIDTH}" height="14" rx="3" fill="${color.nodeFill}" stroke="${color.stroke}" stroke-width="${theme.stroke.node}"/>`,
33
+ ];
34
+ const texts = ITEMS.map((item, i) => `<text x="${tx}" y="${rowY(i) + 4}" text-anchor="start" font-family="${theme.font.family}"` +
35
+ ` font-size="${FONT_SIZE}" fill="${color.textMember}">${item.replace(/&/g, '&amp;').replace(/</g, '&lt;')}</text>`);
36
+ const svg = `<g class="uml-legend">` +
37
+ `<rect x="${x}" y="${y}" width="${width}" height="${height}" rx="6" fill="${color.canvas}" stroke="${color.legendStroke}" stroke-width="${theme.stroke.node}"/>` +
38
+ swatches.join('') +
39
+ texts.join('') +
40
+ `</g>`;
41
+ return { svg, width, height };
42
+ }
@@ -3,7 +3,64 @@
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 { PlantUmlModel } from '../model.js';
7
- import { LayoutResult } from '../layout.js';
6
+ import { PlantUmlModel } from '../foundation/model.js';
7
+ import { LayoutResult, Point } from '../foundation/layout.js';
8
+ import { ThemeOverrides } from './theme.js';
8
9
  export declare function escapeXml(text: string): string;
9
- export declare function renderSvg(model: PlantUmlModel, layout: LayoutResult): string;
10
+ export interface RenderSvgOptions {
11
+ /** Draw a legend of the visual encodings. Default: on for models with more than 30 entities. */
12
+ legend?: boolean;
13
+ /** Theme token overrides over `defaultTheme`. */
14
+ theme?: ThemeOverrides;
15
+ /** Edges hidden by transitive reduction; > 0 forces the legend and is reported there and in `data-hidden-transitive`. */
16
+ hiddenTransitive?: number;
17
+ }
18
+ /**
19
+ * Per-frame values handed to {@link RenderEngine.render}. Only values computed
20
+ * for the frame being rendered belong here; configuration that outlives a
21
+ * frame (theme, legend override) is constructor state of the engine.
22
+ */
23
+ export interface RenderFrameOptions {
24
+ /** Edges hidden by transitive reduction in this frame; > 0 forces the legend and is reported there and in `data-hidden-transitive`. */
25
+ hiddenTransitive?: number;
26
+ }
27
+ /**
28
+ * The rendering counterpart of `LayoutEngine`: draw an already laid-out model.
29
+ * Synchronous because SVG assembly is synchronous, unlike layout. Inject one
30
+ * through `RendererOptions.renderer` to swap the rendering backend.
31
+ */
32
+ export interface RenderEngine {
33
+ render(model: PlantUmlModel, layout: LayoutResult, frame?: RenderFrameOptions): string;
34
+ }
35
+ /** Configuration that outlives a frame — constructor state of {@link SvgRenderer}. */
36
+ export interface SvgRendererOptions {
37
+ /** Draw a legend of the visual encodings. Default: on for models with more than 30 entities. */
38
+ legend?: boolean;
39
+ /** Theme token overrides over `defaultTheme`. */
40
+ theme?: ThemeOverrides;
41
+ }
42
+ /**
43
+ * The built-in {@link RenderEngine}: the SVG backend. Theme and legend
44
+ * configuration are fixed at construction; the per-frame `hiddenTransitive`
45
+ * count is passed to [[SvgRenderer.render]].
46
+ */
47
+ export declare class SvgRenderer implements RenderEngine {
48
+ private readonly legend;
49
+ private readonly theme;
50
+ constructor(options?: SvgRendererOptions);
51
+ render(model: PlantUmlModel, layout: LayoutResult, frame?: RenderFrameOptions): string;
52
+ }
53
+ /**
54
+ * Convenience wrapper over {@link SvgRenderer}, kept for callers that build the
55
+ * SVG directly from a model + layout (the renderer class is preferred when
56
+ * injecting into `RendererOptions.renderer`).
57
+ */
58
+ export declare function renderSvg(model: PlantUmlModel, layout: LayoutResult, options?: RenderSvgOptions): string;
59
+ /** Edges sharing a source and exit direction beyond this count are drawn as one bundle. */
60
+ export declare const EDGE_BUNDLE_MIN = 5;
61
+ /**
62
+ * Polyline as an SVG path whose interior corners are rounded with quadratic
63
+ * curves of radius ≤ `radius` (clamped to half of each adjoining segment). The
64
+ * routing — the vertices — is unchanged.
65
+ */
66
+ export declare function roundedPath(points: Point[], radius: number): string;