Skill Tracer
Skills › Archify › archify › render-class.mjs

render-class.mjs

1095 lines · archify/renderers/class/render-class.mjs at a406594

1import path from 'node:path';
2import { fileURLToPath } from 'node:url';
3import { esc, renderDefinitions, textUnits } from '../shared/utils.mjs';
4import {
5 animateAttr,
6 focusEdgeAttrs,
7 focusNodeAttrs,
8 focusNodeTitle,
9 loadDiagram,
10 svgAccessibleText,
11 svgRootAttrs,
12 writeDiagram,
13} from '../shared/cli.mjs';
14import { throwDiagnosticProblems } from '../shared/diagnostics.mjs';
15import {
16 legendFootprint,
17 measureLegend,
18 relationshipLegendObstacles,
19 resolveLegend,
20 renderLegend as renderResolvedLegend,
21} from '../shared/legend.mjs';
22import { nodeTextFit } from '../shared/text-fit.mjs';
23import { translateMessage as i18nText } from '../shared/i18n.mjs';
24import { bandedLayout, resolveEntityPos } from '../erd/grid.mjs';
25import {
26 asArray,
27 cleanAmbiguousCorridorProblems,
28 cleanCrossingProblems,
29 cleanEndpointSideProblems,
30 cleanFlowProblems,
31 cleanLabelRouteClearanceProblems,
32 cleanRouteRhythmProblems,
33 collectAmbiguousCorridors,
34 legacyDefaultFromSide,
35 legacyDefaultToSide,
36 properSegmentIntersection,
37 rectsOverlap,
38 roundedPath,
39 routePointsValue,
40 segmentIntersectsRect,
41 suggestComponentSeparation,
42 suggestLabelObstacleFix,
43} from '../shared/geometry.mjs';
44import { createRouter } from '../architecture/routing.mjs';
45import { placeAutomaticLabels } from '../shared/automatic-labels.mjs';
46import { connectionPath as relationshipPath } from '../shared/layout-report.mjs';
48const __dirname = path.dirname(fileURLToPath(import.meta.url));
49const layoutJsonMode = process.argv.includes('--layout-json');
50const cliArgs = process.argv.filter((arg) => arg !== '--layout-json');
51const { diagram: cd, template, outPath, sourceEvidence } = loadDiagram({
52 rendererDir: __dirname,
53 diagramType: 'class',
54 defaultExample: 'payments.class.json',
55 argv: cliArgs,
56});
57const locale = cd.meta.locale;
59// The grid is always on: a type without `pos` is placed by its row/col cell.
60// Gaps are wider than the ERD's because a relationship here carries a marker
61// at one end and usually a label, and both need a corridor to sit in.
62const grid = {
63 origin: [32, 40],
64 gapX: 128,
65 gapY: 84,
66 typeW: 180,
67 typeMaxW: 300,
68 ...(cd.layout || {}),
69};
71const layout = {
72 margin: 24,
73 legendH: 34,
74 padX: 12,
75 headerH: 44,
76 headerPlainH: 34,
77 nameFont: 13.5,
78 stereotypeFont: 9.5,
79 memberFont: 11,
80 rowH: 20,
81 compartmentPad: 5,
82 minRelationshipLength: 28,
83};
85// ---- Type kinds --------------------------------------------------------------
86// The kind owns the box's colour family and its header keyword. A plain class
87// carries no keyword, which is the UML default; every other kind states it, so
88// an interface can never be read as a class that merely has no fields.
89const KIND_TONE = {
90 class: 'backend',
91 abstract: 'backend',
92 interface: 'frontend',
93 enum: 'messagebus',
94 record: 'database',
95};
96const STEREOTYPE = {
97 abstract: '«abstract»',
98 interface: '«interface»',
99 enum: '«enumeration»',
100 record: '«record»',
101};
103const VISIBILITY_GLYPH = { public: '+', protected: '#', package: '~', private: '-' };
105function visibilityPrefix(member) {
106 return member.visibility ? `${VISIBILITY_GLYPH[member.visibility]} ` : '';
109function attributeText(attribute) {
110 return `${visibilityPrefix(attribute)}${attribute.name}${attribute.type ? `: ${attribute.type}` : ''}`;
113function methodText(method) {
114 return `${visibilityPrefix(method)}${method.name}(${method.parameters ?? ''})${method.returns ? `: ${method.returns}` : ''}`;
117// Member rows are set in a monospace face, whose ASCII advance is the shared
118// 0.6em estimate; wide glyphs count as two units, so the measurement never
119// under-reserves space and a member is never clipped by its own box.
120const MEMBER_ADVANCE = layout.memberFont * nodeTextFit.widthFactor;
121const CONTINUATION_INDENT = 2;
122// The visibility chip is wider than the two-character `+ ` prefix the member
123// text is measured with; every box reserves the difference.
124const VIS_CHIP = 14;
125const VIS_GAP = 6;
126const VIS_EXTRA = Math.ceil(VIS_CHIP + VIS_GAP - 2 * MEMBER_ADVANCE);
128function nameWidth(type) {
129 // Bold display text runs wider than the regular estimate.
130 return textUnits(type.label) * layout.nameFont * nodeTextFit.widthFactor * 1.08;
133function memberRows(type) {
134 return [
135 { kind: 'attributes', rows: asArray(type.attributes).map((attribute) => ({ member: attribute, text: attributeText(attribute) })) },
136 { kind: 'methods', rows: asArray(type.methods).map((method) => ({ member: method, text: methodText(method) })) },
137 ].filter((compartment) => compartment.rows.length);
140// A member longer than its box wraps instead of being cut: breaks fall after
141// an opening parenthesis or a parameter comma, or before the return type, and
142// continuation lines are indented so they read as one signature. A method
143// that wraps is set the way a formatter sets it: the open parenthesis ends the
144// first line, parameters hang one step deeper, and `): Return` closes it, so
145// every wrapped signature in a diagram has the same shape. A single token
146// longer than a whole line is split by character, never dropped.
147const PARAMETER_INDENT = 4;
149function fillLines(pieces, limit, firstIndent, indent) {
150 const lines = [];
151 let current = '';
152 const room = () => limit - (lines.length ? indent : firstIndent);
153 const push = (text) => lines.push({ text, indent: lines.length ? indent : firstIndent });
154 for (const piece of pieces) {
155 if (textUnits(current + piece) <= room()) {
156 current += piece;
157 continue;
158 }
159 if (current) push(current.trimEnd());
160 current = piece.trimStart();
161 while (textUnits(current) > room()) {
162 const chars = Array.from(current);
163 let cut = 0;
164 for (let units = 0; cut < chars.length && units + textUnits(chars[cut]) <= room(); cut += 1) units += textUnits(chars[cut]);
165 push(chars.slice(0, Math.max(1, cut)).join(''));
166 current = chars.slice(Math.max(1, cut)).join('');
167 }
168 }
169 if (current) push(current.trimEnd());
170 return lines;
173function wrapMember(text, availablePx) {
174 const limit = Math.max(8, Math.floor(availablePx / MEMBER_ADVANCE));
175 if (textUnits(text) <= limit) return [{ text, indent: 0 }];
176 const lines = fillLines(text.split(/(?<=\(|, )|(?=\): )/), limit, 0, CONTINUATION_INDENT);
177 const open = text.indexOf('(');
178 const close = text.lastIndexOf(')');
179 if (open === -1 || close < open) return lines;
180 const head = text.slice(0, open + 1);
181 const parameters = text.slice(open + 1, close).split(/(?<=, )/);
182 const tail = text.slice(close);
183 return [
184 ...fillLines([head], limit, 0, CONTINUATION_INDENT),
185 ...fillLines(parameters, limit, PARAMETER_INDENT, PARAMETER_INDENT),
186 ...fillLines([tail], limit, CONTINUATION_INDENT, CONTINUATION_INDENT),
187 ];
190function headerHeight(type) {
191 return STEREOTYPE[type.kind] ? layout.headerH : layout.headerPlainH;
194// Header badge (a lettered kind marker) reserves room on the left of the name.
195const BADGE_SIZE = 16;
196const BADGE_PORCH = BADGE_SIZE + 8;
198function headerWidth(type) {
199 return Math.ceil(layout.padX * 2 + BADGE_PORCH * 2 + Math.max(
200 nameWidth(type),
201 STEREOTYPE[type.kind] ? textUnits(STEREOTYPE[type.kind]) * layout.stereotypeFont * nodeTextFit.widthFactor : 0,
202 ));
205// An authored width is authoritative. An omitted one grows to the widest
206// member up to `layout.typeMaxW`; past that, members wrap. The title never
207// wraps, so a box is always at least as wide as its header needs.
208function resolvedWidth(type) {
209 if (Number.isFinite(type.width)) return type.width;
210 const members = memberRows(type).flatMap((compartment) => compartment.rows)
211 .reduce((max, row) => Math.max(max, textUnits(row.text) * MEMBER_ADVANCE), 0);
212 const wanted = Math.ceil(members + layout.padX * 2 + VIS_EXTRA);
213 return Math.max(headerWidth(type), Math.min(grid.typeMaxW, Math.max(grid.typeW, wanted)));
216function compartments(type) {
217 const available = type.width - layout.padX * 2 - VIS_EXTRA;
218 return memberRows(type).map((compartment) => ({
219 ...compartment,
220 rows: compartment.rows.map((row) => ({ ...row, lines: wrapMember(row.text, available) })),
221 }));
224function typeHeight(type) {
225 return headerHeight(type) + compartments(type).reduce((sum, compartment) => sum
226 + compartment.rows.reduce((lines, row) => lines + row.lines.length, 0) * layout.rowH
227 + layout.compartmentPad * 2, 0);
230const measuredTypes = asArray(cd.types).map((type) => {
231 const width = resolvedWidth(type);
232 return { ...type, width, height: typeHeight({ ...type, width }) };
233});
234const bands = bandedLayout(measuredTypes, { ...grid, entityW: grid.typeW });
235// Types are centred in their row band, so a relationship between two types of
236// one row runs straight across instead of jogging between their centres.
237const types = new Map(measuredTypes.map((type) => {
238 const [x, top] = resolveEntityPos(type, { ...grid, entityW: grid.typeW }, bands);
239 const y = placedByGrid(type) ? top + ((bands.heights.get(type.row) ?? type.height) - type.height) / 2 : top;
240 return [type.id, { ...type, x, y, cx: x + type.width / 2, cy: y + type.height / 2 }];
241}));
243// Routing runs at module load, so a type without a position is reported before
244// any geometry is computed from it.
246 const unplaced = [...types.values()].filter((type) => !Number.isFinite(type.x) || !Number.isFinite(type.y));
247 if (unplaced.length) {
248 throwDiagnosticProblems('Class diagram placement failed',
249 unplaced.map((type) => `Type "${type.id}" needs grid row/col or an absolute pos [x,y].`),
250 { code: 'layout/constraint', subject: { diagramType: 'class' } });
251 }
254const relationships = asArray(cd.relationships).map((relationship) => {
255 if (!Array.isArray(relationship.via) || relationship.via.length) return relationship;
256 // 空路径与省略字段等价,统一主干和普通路由的判断,同时保留作者原始文档。
257 const automatic = { ...relationship };
258 delete automatic.via;
259 return automatic;
260});
262const typeSteps = new Map();
263for (const [index, relationship] of relationships.entries()) {
264 if (!typeSteps.has(relationship.to)) typeSteps.set(relationship.to, index);
265 if (!typeSteps.has(relationship.from)) typeSteps.set(relationship.from, index + 1);
267for (const [index, type] of asArray(cd.types).entries()) {
268 if (!typeSteps.has(type.id)) typeSteps.set(type.id, index);
271// ---- Relationship notation -----------------------------------------------------
272// One notation table owns the line dash, the marker at each end, and the legend
273// swatch, so the legend can never teach a different symbol than the lines draw.
274//
275// Every relationship reads `from` -> `to`:
276// - dependency: `from` uses `to` dashed, open arrow at `to`
277// - association: `from` holds a `to` solid, open arrow at `to`
278// - inheritance: `from` extends `to` solid, hollow triangle at `to`
279// - realization: `from` implements `to` dashed, hollow triangle at `to`
280// - composition: `from` owns `to` (lifetime) solid, filled diamond at `from`
281// - aggregation: `from` groups `to` solid, hollow diamond at `from`
282const NOTATION = {
283 dependency: { dashed: true, end: 'open' },
284 association: { dashed: false, end: 'open' },
285 inheritance: { dashed: false, end: 'triangle' },
286 realization: { dashed: true, end: 'triangle' },
287 composition: { dashed: false, start: 'diamond-filled' },
288 aggregation: { dashed: false, start: 'diamond-hollow' },
289};
290const RELATIONSHIP_KINDS = Object.keys(NOTATION);
291const GENERALIZATION = new Set(['inheritance', 'realization']);
293// Marker glyphs, in marker space. The path end sits on the type border at refX,
294// so a triangle's tip and a diamond's point touch the type they describe.
295const GLYPH = {
296 open: { width: 12, height: 12, refX: 11, refY: 6, body: '<path d="M 1 1 L 11 6 L 1 11" fill="none"/>' },
297 triangle: { width: 16, height: 16, refX: 15, refY: 8, body: '<path d="M 1 1.5 L 15 8 L 1 14.5 Z" class="cl-marker-hollow"/>' },
298 'diamond-filled': { width: 20, height: 12, refX: 1, refY: 6, body: '<path d="M 1 6 L 10 1 L 19 6 L 10 11 Z" class="cl-marker-filled"/>' },
299 'diamond-hollow': { width: 20, height: 12, refX: 1, refY: 6, body: '<path d="M 1 6 L 10 1 L 19 6 L 10 11 Z" class="cl-marker-hollow"/>' },
300};
301// A hierarchy bus is drawn from the supertype outward (see renderRelationshipPath),
302// so its triangle is a start marker turned back toward the supertype.
303GLYPH['triangle-start'] = { ...GLYPH.triangle, orient: 'auto-start-reverse' };
304// The tallest glyph; ports on one side closer than this would draw overlapping
305// markers that read as one shape.
306const MARKER_HEIGHT = 16;
307const PORT_SPACING = 26;
309function renderMarkerDefs() {
310 const used = new Set();
311 for (const relationship of relationships) {
312 const notation = NOTATION[relationship.kind];
313 if (notation?.end) used.add(notation.end);
314 if (notation?.start) used.add(notation.start);
315 }
316 if (busPaths.size) used.add('triangle-start');
317 const markup = Object.entries(GLYPH).filter(([id]) => used.has(id)).map(([id, glyph]) => (
318 ` <marker id="cl-${id}" markerWidth="${glyph.width}" markerHeight="${glyph.height}" refX="${glyph.refX}" refY="${glyph.refY}" orient="${glyph.orient || 'auto'}" markerUnits="userSpaceOnUse" class="cl-marker" stroke-width="1.5" stroke-linejoin="round">
319 ${glyph.body}
320 </marker>`));
321 return markup.length ? `\n <!-- UML relationship markers -->\n${markup.join('\n')}` : '';
324// ---- Layout --------------------------------------------------------------------
325function placedByGrid(type) {
326 return !(Array.isArray(type.pos) && type.pos.length === 2);
329const LEGEND_CATALOG = RELATIONSHIP_KINDS
330 .map((kind) => ({ kind, label: i18nText(locale, `legend.class.${kind}`), interactive: false }));
331const presentKinds = new Set(relationships.map((relationship) => relationship.kind));
332const LEGEND_SWATCH = 34;
333const LEGEND_BASELINE_GAP = 16;
334const LEGEND_FONT_SIZE = 10;
335const legendEntries = resolveLegend(cd.meta?.legend, LEGEND_CATALOG, presentKinds)
336 .map((entry) => ({ ...entry, swatchWidth: LEGEND_SWATCH }));
338function contentBounds() {
339 const placed = [...types.values()].filter((type) => Number.isFinite(type.x) && Number.isFinite(type.y));
340 return {
341 maxX: Math.max(0, ...placed.map((type) => type.x + type.width)),
342 maxY: Math.max(0, ...placed.map((type) => type.y + type.height)),
343 };
346function legendLayout(width, height, { obstacles = [], unfit = 'error' } = {}) {
347 return {
348 x: layout.margin,
349 baselineY: height - LEGEND_BASELINE_GAP,
350 width: width - layout.margin * 2,
351 fontSize: LEGEND_FONT_SIZE,
352 minTitleY: contentBounds().maxY + 8,
353 obstacles,
354 unfit,
355 diagramType: 'class',
356 };
359function autoViewBox() {
360 const { maxX, maxY } = contentBounds();
361 let width = Math.ceil(maxX + layout.margin);
362 let footprint = legendFootprint(legendEntries, { width: Math.max(1, width - layout.margin * 2) });
363 if (footprint.minWidth > width - layout.margin * 2) {
364 width = Math.ceil(footprint.minWidth + layout.margin * 2);
365 footprint = legendFootprint(legendEntries, { width: width - layout.margin * 2 });
366 }
367 let height = Math.ceil(maxY + layout.margin + (legendEntries.length ? layout.legendH + footprint.extraHeight : 0));
368 for (let attempt = 0; legendEntries.length && attempt < 4; attempt += 1) {
369 if (measureLegend(legendEntries, legendLayout(width, height, { unfit: 'hide' }))) break;
370 height += layout.legendH;
371 }
372 return [Math.max(320, width), Math.max(240, height)];
375const viewBox = Array.isArray(cd.meta?.viewBox) ? cd.meta.viewBox : autoViewBox();
377// ---- Routing -------------------------------------------------------------------
378// A generalization is drawn vertically whenever the two types are not side by
379// side: the supertype above, its subtypes below, which is how a reader expects
380// a hierarchy to stand. Other relationships keep the shared horizontal-first
381// inference, and stacked types always use their facing top/bottom sides.
382function sideFor(relationship, endpoint) {
383 const from = types.get(relationship.from);
384 const to = types.get(relationship.to);
385 if (!from || !to) return undefined;
386 const overlapX = from.x < to.x + to.width && to.x < from.x + from.width;
387 const overlapY = from.y < to.y + to.height && to.y < from.y + from.height;
388 const vertical = overlapX || (!overlapY && GENERALIZATION.has(relationship.kind));
389 if (!vertical) return endpoint === 'source' ? legacyDefaultFromSide(from, to) : legacyDefaultToSide(from, to);
390 const down = to.cy > from.cy;
391 if (endpoint === 'source') return down ? 'bottom' : 'top';
392 return down ? 'top' : 'bottom';
395function renderableRelationship(relationship) {
396 return types.has(relationship.from) && types.has(relationship.to) && relationship.from !== relationship.to;
399const routable = relationships.filter(renderableRelationship);
401// ---- Hierarchy bus -------------------------------------------------------------
402// Two or more automatic generalizations of one kind into one supertype, all
403// from subtypes placed below it, draw as one tree: a single triangle and trunk
404// under the supertype, a horizontal bus, and a drop to each subtype. That is
405// the conventional UML shape, and it reads as "these all implement X" instead
406// of as N unrelated arrows. A bus that would cross another type is not drawn;
407// its members fall back to ordinary routes.
408const BUS_MIN_GAP = 40;
410function autoRouted(relationship) {
411 return !relationship.via?.length && (!relationship.route || relationship.route === 'auto')
412 && !relationship.fromSide && !relationship.toSide && !relationship.labelAt;
415const busPaths = new Map();
416const busGroups = new Map();
418 const groups = new Map();
419 for (const relationship of routable) {
420 if (!GENERALIZATION.has(relationship.kind) || !autoRouted(relationship)) continue;
421 const key = `${relationship.to}\u0000${relationship.kind}`;
422 if (!groups.has(key)) groups.set(key, []);
423 groups.get(key).push(relationship);
424 }
425 for (const members of groups.values()) {
426 if (members.length < 2) continue;
427 const target = types.get(members[0].to);
428 const bottom = target.y + target.height;
429 const sources = members.map((relationship) => types.get(relationship.from));
430 const firstTop = Math.min(...sources.map((source) => source.y));
431 if (firstTop - bottom < BUS_MIN_GAP) continue;
432 const busY = Math.round(bottom + (firstTop - bottom) / 2);
433 const paths = members.map((relationship, index) => {
434 const source = sources[index];
435 return Math.abs(source.cx - target.cx) < 0.5
436 ? [[source.cx, source.y], [target.cx, bottom]]
437 : [[source.cx, source.y], [source.cx, busY], [target.cx, busY], [target.cx, bottom]];
438 });
439 // Each drop may touch only its own subtype and the supertype; a deeper
440 // subtype's drop must not run through a sibling placed above it.
441 const clear = paths.every((points, member) => {
442 const others = [...types.values()].filter((type) => type !== target && type !== sources[member]);
443 return points.slice(1).every((point, index) => others
444 .every((type) => !segmentIntersectsRect({ start: points[index], end: point }, type)));
445 });
446 if (!clear) continue;
447 members.forEach((relationship, index) => {
448 busPaths.set(relationship, paths[index]);
449 busGroups.set(relationship, members);
450 });
451 }
454let separateFallbackRoutes = false;
455function ordinaryRouter() {
456 return createRouter(types, routable.filter((relationship) => !busPaths.has(relationship)), {
457 sideFor,
458 maxPortSpacing: PORT_SPACING,
459 distinctAutomaticPorts: separateFallbackRoutes,
460 // As for the ERD: re-plan a route against the complete scene when it crosses
461 // or bends needlessly, and look for a detour that crosses nothing before the
462 // obstacle search that may cross earlier routes. The showcase gate rejects
463 // any proper crossing, so the shorter crossing route only buys a repair.
464 preferReadableRoutes: true,
465 crossingFreeGridFirst: true,
466 // A detour keeps a readable gap from every type it only passes instead of
467 // running along its border at the grid's 2-unit clearance.
468 componentGapPx: 10,
469 });
472function busRouteConflict(left, right) {
473 for (const [leftField, leftPoint] of [['from', left.points[0]], ['to', left.points.at(-1)]]) {
474 for (const [rightField, rightPoint] of [['from', right.points[0]], ['to', right.points.at(-1)]]) {
475 if (left.relation[leftField] === right.relation[rightField]
476 && Math.hypot(leftPoint[0] - rightPoint[0], leftPoint[1] - rightPoint[1]) < MARKER_HEIGHT - 0.0001) return true;
477 }
478 }
479 if (collectAmbiguousCorridors({
480 routedRelations: [left, right],
481 includeSharedEndpoints: () => true,
482 }).length) return true;
483 return left.points.slice(1).some((end, index) => right.points.slice(1)
484 .some((otherEnd, otherIndex) => properSegmentIntersection(
485 left.points[index], end, right.points[otherIndex], otherEnd,
486 )));
489let router;
490for (;;) {
491 router = ordinaryRouter();
492 if (!busPaths.size) break;
493 const routes = routable.map((relation) => ({
494 relation, points: busPaths.get(relation) || router.pathFor(relation).points,
495 }));
496 const rejected = new Set();
497 for (let leftIndex = 0; leftIndex < routes.length; leftIndex += 1) {
498 const left = routes[leftIndex];
499 const leftGroup = busGroups.get(left.relation);
500 for (let rightIndex = leftIndex + 1; rightIndex < routes.length; rightIndex += 1) {
501 const right = routes[rightIndex];
502 const rightGroup = busGroups.get(right.relation);
503 // 只有同一主干组可以共享端口和线段;其他路线仍保留各自的关系符号。
504 if ((!leftGroup && !rightGroup) || leftGroup === rightGroup) continue;
505 if (!busRouteConflict(left, right)) continue;
506 if (leftGroup) rejected.add(leftGroup);
507 if (rightGroup) rejected.add(rightGroup);
508 }
509 }
510 if (!rejected.size) break;
511 // 回退后的独立关系即使共享目标,也不能重新合并成没有语义归属的走廊。
512 separateFallbackRoutes = true;
513 // 整组回退后重新规划,避免新路线撞到剩余主干;每轮只删除组,次数有界。
514 for (const group of rejected) {
515 for (const relationship of group) {
516 busPaths.delete(relationship);
517 busGroups.delete(relationship);
518 }
519 }
521function pathFor(relationship) {
522 const points = busPaths.get(relationship);
523 return points ? { points, d: roundedPath(points, 8) } : router.pathFor(relationship);
525// A bus drop leaves the subtype's top and the trunk enters the supertype's
526// bottom; every other relationship keeps the router's resolved sides.
527function connectionEndpointSide(relationship, endpoint) {
528 if (busPaths.has(relationship)) return endpoint === 'source' ? 'top' : 'bottom';
529 return router.connectionEndpointSide(relationship, endpoint);
531function inferredSides(relationship) {
532 if (busPaths.has(relationship)) return { fromSide: 'top', toSide: 'bottom' };
533 return router.inferredSides(relationship);
536const RELATIONSHIP_LABEL_FONT = 9.5;
537const RELATIONSHIP_LABEL_ADVANCE = 0.62;
538function relationshipLabelWidth(relationship) {
539 return Math.max(30, textUnits(relationship.label) * RELATIONSHIP_LABEL_FONT * RELATIONSHIP_LABEL_ADVANCE + 10);
541// A label sits on the segment with the most room unless the author picks one:
542// above a horizontal run, beside a vertical one, so it never covers its own
543// line or lands on the box the relationship leaves.
544const resolvedLabelPoints = new Map();
545function relationshipLabelPoint(relationship) {
546 return resolvedLabelPoints.get(relationship) ?? defaultRelationshipLabelPoint(relationship);
548function defaultRelationshipLabelPoint(relationship) {
549 if (relationship.labelAt) return relationship.labelAt;
550 const points = pathFor(relationship).points;
551 const width = relationshipLabelWidth(relationship);
552 const vertical = (index) => Math.abs(points[index][0] - points[index + 1][0]) < Math.abs(points[index][1] - points[index + 1][1]);
553 const room = (index) => {
554 const length = Math.hypot(points[index + 1][0] - points[index][0], points[index + 1][1] - points[index][1]);
555 return length - (vertical(index) ? 22 : width + 8);
556 };
557 const index = Number.isInteger(relationship.labelSegment)
558 ? Math.min(points.length - 2, relationship.labelSegment)
559 : points.slice(1).reduce((best, _, candidate) => (room(candidate) > room(best) ? candidate : best), 0);
560 const [a, b] = [points[index], points[index + 1]];
561 const dx = relationship.labelDx || 0;
562 const dy = relationship.labelDy || 0;
563 if (vertical(index)) return [a[0] + width / 2 + 6 + dx, (a[1] + b[1]) / 2 + 4 + dy];
564 return [(a[0] + b[0]) / 2 + dx, a[1] - 6 + dy];
566function relationshipLabelBox(relationship) {
567 const [lx, ly] = relationshipLabelPoint(relationship);
568 const width = relationshipLabelWidth(relationship);
569 return { x: lx - width / 2, y: ly - 10, width, height: 14 };
572// The default point picks the roomiest segment but knows nothing about other
573// routes, markers, or types near it. A colliding unpinned label goes through the
574// shared bounded placer (architecture, dataflow, and erd use it too), with
575// every end marker reserved, so it moves beside its own route instead of
576// failing the gate; a clear default is kept as it is.
577const MARKER_REACH = 20;
578// Half the tallest marker plus the 2-unit overlap the placer tolerates.
579const MARKER_HALF = 10;
580function markerBox([x, y], side) {
581 if (side === 'left') return { x: x - MARKER_REACH, y: y - MARKER_HALF, width: MARKER_REACH, height: MARKER_HALF * 2 };
582 if (side === 'right') return { x, y: y - MARKER_HALF, width: MARKER_REACH, height: MARKER_HALF * 2 };
583 if (side === 'top') return { x: x - MARKER_HALF, y: y - MARKER_REACH, width: MARKER_HALF * 2, height: MARKER_REACH };
584 return { x: x - MARKER_HALF, y, width: MARKER_HALF * 2, height: MARKER_REACH };
587 const labels = routable.filter((relationship) => relationship.label).map((relationship) => {
588 const [lx, ly] = defaultRelationshipLabelPoint(relationship);
589 return { relation: relationship, relationIndex: relationships.indexOf(relationship), label: relationship.label,
590 ...relationshipLabelBox(relationship), lx, ly };
591 });
592 if (labels.length) {
593 const markers = routable.flatMap((relationship) => {
594 const { points } = pathFor(relationship);
595 return [
596 markerBox(points[0], connectionEndpointSide(relationship, 'source')),
597 markerBox(points.at(-1), connectionEndpointSide(relationship, 'target')),
598 ];
599 });
600 const footprint = legendEntries.length
601 ? layout.legendH + legendFootprint(legendEntries, { width: Math.max(1, viewBox[0] - layout.margin * 2) }).extraHeight
602 : 0;
603 const placed = placeAutomaticLabels({
604 labels,
605 routes: routable.map((relationship) => ({ relationIndex: relationships.indexOf(relationship), points: pathFor(relationship).points })),
606 components: [...types.values(), ...markers],
607 titles: [],
608 viewBox,
609 placementBottom: viewBox[1] - footprint,
610 keepFallbackNearRoute: true,
611 });
612 placed.forEach((rect, index) => {
613 if (rect !== labels[index]) resolvedLabelPoints.set(rect.relation, [rect.lx, rect.ly]);
614 });
615 }
618// ---- Validation ----------------------------------------------------------------
619function detail(code, message, subject, evidence, supportedFixes) {
620 return { code, severity: 'error', message, subject: { diagramType: 'class', ...subject }, evidence, supportedFixes };
623function generalizationCycle() {
624 const parents = new Map();
625 for (const relationship of relationships) {
626 if (relationship.kind !== 'inheritance' || !renderableRelationship(relationship)) continue;
627 if (!parents.has(relationship.from)) parents.set(relationship.from, []);
628 parents.get(relationship.from).push(relationship.to);
629 }
630 const state = new Map();
631 const stack = [];
632 const visit = (id) => {
633 if (state.get(id) === 'done') return null;
634 if (state.get(id) === 'open') return [...stack.slice(stack.indexOf(id)), id];
635 state.set(id, 'open');
636 stack.push(id);
637 for (const parent of parents.get(id) || []) {
638 const cycle = visit(parent);
639 if (cycle) return cycle;
640 }
641 stack.pop();
642 state.set(id, 'done');
643 return null;
644 };
645 for (const id of parents.keys()) {
646 const cycle = visit(id);
647 if (cycle) return cycle;
648 }
649 return null;
652function validateClassDiagram() {
653 const problems = [];
654 const details = [];
655 const fail = (entry) => {
656 problems.push(entry.message);
657 details.push(entry);
658 };
660 const seen = new Set();
661 const cells = new Map();
662 for (const [index, type] of asArray(cd.types).entries()) {
663 if (seen.has(type.id)) problems.push(`Type ids must be unique; "${type.id}" is declared twice.`);
664 seen.add(type.id);
665 if (placedByGrid(type)) {
666 if (!Number.isInteger(type.row) || !Number.isInteger(type.col * 2)) {
667 problems.push(`Type "${type.id}" needs grid row/col (col may be a half step, e.g. 1.5) or an absolute pos [x,y].`);
668 } else {
669 // A half column straddles the two columns beside it.
670 for (const col of new Set([Math.floor(type.col), Math.ceil(type.col)])) {
671 const key = `${type.row},${col}`;
672 if (cells.has(key)) problems.push(`Types "${cells.get(key)}" and "${type.id}" share grid cell row ${type.row} col ${col}.`);
673 else cells.set(key, type.id);
674 }
675 }
676 }
677 const attributeNames = new Set();
678 for (const attribute of asArray(type.attributes)) {
679 if (attributeNames.has(attribute.name)) problems.push(`Type "${type.id}" declares attribute "${attribute.name}" twice.`);
680 attributeNames.add(attribute.name);
681 }
682 // Members wrap, but the title is one line: a box must be wide enough for it.
683 const placed = types.get(type.id);
684 const needed = headerWidth(type);
685 if (placed && needed > placed.width) {
686 fail(detail('class/title-text-capacity',
687 `Type "${type.id}" title "${type.label}" needs ${needed}px but the type is ${placed.width}px wide — remove or raise the authored width, or shorten the title.`,
688 { path: `/types/${index}/width`, typeId: type.id },
689 { label: type.label, width: placed.width, requiredWidth: needed },
690 ['Remove the authored width so the type sizes itself.', `Set width to at least ${needed}.`, 'Shorten the type title.']));
691 }
692 }
694 for (const type of types.values()) {
695 for (const other of types.values()) {
696 if (type.id >= other.id || !rectsOverlap(type, other, 0)) continue;
697 problems.push(`Type "${type.id}" overlaps type "${other.id}".\n${suggestComponentSeparation(type, other)}`);
698 }
699 }
701 for (const [index, relationship] of relationships.entries()) {
702 const id = relationship.id ? ` id "${relationship.id}"` : '';
703 const from = types.get(relationship.from);
704 const to = types.get(relationship.to);
705 if (!from || !to) {
706 problems.push(`Relationship ${index}${id} references unknown type "${from ? relationship.to : relationship.from}".`);
707 continue;
708 }
709 if (relationship.from === relationship.to) {
710 problems.push(`Relationship ${index}${id} connects type "${relationship.from}" to itself — describe the self-reference as a member type or in a card.`);
711 continue;
712 }
713 // The hollow triangle claims a supertype; the claim has to agree with the
714 // kinds, or the diagram states an impossible hierarchy.
715 if (relationship.kind === 'realization' && (to.kind !== 'interface' || from.kind === 'interface')) {
716 fail(detail('class/realization-target',
717 `Relationship ${index}${id} "${relationship.from}" realizes "${relationship.to}", but realization runs from a non-interface type to an interface (here ${from.kind} -> ${to.kind}). Use inheritance for class-to-class or interface-to-interface extension.`,
718 { path: `/relationships/${index}`, relationshipIndex: index },
719 { from: relationship.from, fromKind: from.kind, to: relationship.to, toKind: to.kind },
720 ['Use kind "inheritance" for extension between two classes or two interfaces.', 'Point the realization at the interface it implements.']));
721 }
722 if (relationship.kind === 'inheritance' && (from.kind === 'interface') !== (to.kind === 'interface')) {
723 fail(detail('class/inheritance-kind',
724 `Relationship ${index}${id} "${relationship.from}" inherits from "${relationship.to}" across an interface boundary (${from.kind} -> ${to.kind}). A class implementing an interface is a realization.`,
725 { path: `/relationships/${index}`, relationshipIndex: index },
726 { from: relationship.from, fromKind: from.kind, to: relationship.to, toKind: to.kind },
727 ['Use kind "realization" for a class implementing an interface.']));
728 }
729 const routed = pathFor(relationship);
730 const [start, end] = [routed.points[0], routed.points.at(-1)];
731 const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
732 if (distance < layout.minRelationshipLength) {
733 problems.push(`Relationship ${index}${id} "${relationship.from}" -> "${relationship.to}" is too short (${Math.round(distance)}px; minimum ${layout.minRelationshipLength}px) — place the types farther apart.`);
734 }
735 }
737 const cycle = generalizationCycle();
738 if (cycle) {
739 fail(detail('class/inheritance-cycle',
740 `Inheritance forms a cycle: ${cycle.map((id) => `"${id}"`).join(' -> ')}. A type cannot be its own supertype.`,
741 { path: '/relationships', typeId: cycle[0] },
742 { cycle },
743 ['Remove or reverse one inheritance relationship in the cycle.']));
744 }
746 // Busy sides can leave less room than a marker is tall: the router spreads
747 // ports by min(PORT_SPACING, available / (n - 1)).
748 for (const [typeId, type] of types) {
749 const sides = new Map();
750 // A bus enters its supertype through one shared trunk, which is one port.
751 for (const relationship of routable.filter((candidate) => !busPaths.has(candidate))) {
752 const { fromSide, toSide } = inferredSides(relationship);
753 if (relationship.from === typeId) sides.set(fromSide, (sides.get(fromSide) || 0) + 1);
754 if (relationship.to === typeId) sides.set(toSide, (sides.get(toSide) || 0) + 1);
755 }
756 for (const [side, count] of sides) {
757 if (count < 2) continue;
758 const extent = side === 'left' || side === 'right' ? type.height : type.width;
759 const spacing = Math.min(PORT_SPACING, Math.max(0, extent - 32) / (count - 1));
760 if (spacing >= MARKER_HEIGHT) continue;
761 fail({
762 ...detail('layout/marker-capacity',
763 `Type "${typeId}" shares its ${side} side with ${count} relationship ends ${Math.round(spacing)}px apart, under the ${MARKER_HEIGHT}px a relationship marker needs, so the markers overlap. Spread the relationships across sides or place the related types so they arrive from different directions.`,
764 { path: `/types/${typeId}` },
765 { type: typeId, side, relationshipEnds: count, spacingPx: Math.round(spacing), markerHeightPx: MARKER_HEIGHT, sideExtentPx: extent },
766 ['Spread the relationships across sides with room.', 'Move related types so they approach from different sides.']),
767 });
768 }
769 }
771 const shared = {
772 relations: routable,
773 endpointIds: new Set(types.keys()),
774 pathFor,
775 diagramType: 'class',
776 relationCollection: 'relationships',
777 profile: cd.meta?.quality_profile,
778 };
779 problems.push(...cleanEndpointSideProblems({
780 ...shared,
781 fromSideFor: (relationship) => connectionEndpointSide(relationship, 'source'),
782 toSideFor: (relationship) => connectionEndpointSide(relationship, 'target'),
783 routeHint: 'keep automatic routing, or set truthful fromSide/toSide',
784 }));
785 problems.push(...cleanFlowProblems({
786 relations: routable,
787 obstacles: types.values(),
788 pathFor,
789 diagramType: 'class',
790 relationCollection: 'relationships',
791 obstacleKind: 'type',
792 routeHint: 'adjust fromSide/toSide, set route/via, or move the type',
793 }));
794 problems.push(...cleanCrossingProblems({ ...shared, routeHint: 'move the types so unrelated relationships use separate corridors' }));
795 problems.push(...cleanAmbiguousCorridorProblems({ ...shared, routeHint: 'give the relationships different sides, or move a type so their corridors differ' }));
796 problems.push(...cleanRouteRhythmProblems({ ...shared, routeHint: 'widen layout.gapX/gapY so every turn has room to read' }));
798 const labels = [];
799 for (const [index, relationship] of relationships.entries()) {
800 if (!relationship.label || !renderableRelationship(relationship)) continue;
801 const [lx, ly] = relationshipLabelPoint(relationship);
802 labels.push({ relation: relationship, relationIndex: index, label: relationship.label, ...relationshipLabelBox(relationship), lx, ly });
803 }
804 for (const rect of labels) {
805 for (const type of types.values()) {
806 if (!rectsOverlap(rect, type, -2)) continue;
807 problems.push(`Label "${rect.label}" overlaps type "${type.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, type, 'type')}`);
808 }
809 }
810 problems.push(...cleanLabelRouteClearanceProblems({ ...shared, labels }));
812 if (problems.length) {
813 throwDiagnosticProblems('Class diagram validation failed', problems, {
814 code: 'layout/constraint',
815 subject: { diagramType: 'class' },
816 diagnostics: details,
817 });
818 }
821function buildLayoutReport() {
822 return {
823 diagram_type: 'class',
824 viewBox,
825 types: [...types.values()].map((type) => ({
826 id: type.id,
827 label: type.label,
828 kind: type.kind,
829 x: Math.round(type.x),
830 y: Math.round(type.y),
831 width: type.width,
832 height: type.height,
833 ...(Number.isInteger(type.row) ? { row: type.row } : {}),
834 ...(Number.isInteger(type.col) ? { col: type.col } : {}),
835 })),
836 relationships: routable.map((relationship) => ({
837 ...relationshipPath(relationship, pathFor(relationship), relationship.labelAt),
838 kind: relationship.kind,
839 })),
840 };
843// ---- Rendering -----------------------------------------------------------------
844// A member reads like a signature in an editor: the visibility glyph and every
845// type annotation are quiet, so the names carry the row. Wrapping never splits
846// an annotation, so each line is coloured on its own.
847const VISIBILITY_CLASS = { '+': 'public', '#': 'protected', '~': 'package', '-': 'private' };
849function punctuated(text) {
850 return esc(text).replace(/[(),]/g, (mark) => `<tspan class="t-muted cl-punct">${mark}</tspan>`);
853function memberSpans(text, first) {
854 let out = '';
855 let rest = text;
856 // A continuation line with no annotation of its own is the rest of a type
857 // (`PaymentProcessor>`), so it is set like the start of that type.
858 if (!first && !rest.includes(':') && !rest.startsWith(')')) return `<tspan class="t-muted cl-type-ref">${esc(rest)}</tspan>`;
859 const glyph = first ? rest.match(/^([+#~-]) /) : null;
860 if (glyph) {
861 out += `<tspan class="t-muted cl-vis cl-vis-${VISIBILITY_CLASS[glyph[1]]}">${esc(glyph[1])}</tspan> `;
862 rest = rest.slice(2);
863 }
864 const annotation = /:(\s*)([^,()]+)/g;
865 let last = 0;
866 for (const match of rest.matchAll(annotation)) {
867 out += `${punctuated(rest.slice(last, match.index))}<tspan class="t-muted cl-punct">:</tspan>${esc(match[1])}<tspan class="t-muted cl-type-ref">${esc(match[2])}</tspan>`;
868 last = match.index + match[0].length;
869 }
870 return out + punctuated(rest.slice(last));
873// The visibility glyph is set as a chip in its own column, like a key badge in
874// an ERD; a one-line member puts its type in a right-aligned column so the
875// names and the types each read down one edge.
876function visibilityChip(glyph, x, baseline) {
877 const kind = VISIBILITY_CLASS[glyph];
878 return `<rect x="${x}" y="${baseline - 10.5}" width="${VIS_CHIP}" height="${VIS_CHIP}" rx="4" class="cl-vis-chip cl-vis-${kind}"/><text x="${x + VIS_CHIP / 2}" y="${baseline}" class="t-muted cl-vis cl-vis-${kind}" font-size="${layout.memberFont}" text-anchor="middle">${esc(glyph)}</text>`;
881function memberColumns(member, kind) {
882 if (kind === 'attributes') return { left: punctuated(member.name), right: member.type };
883 const parameters = member.parameters
884 ? String(member.parameters).split(/(,\s*)/).map((part) => {
885 if (/^,\s*$/.test(part)) return `<tspan class="t-muted cl-punct">,</tspan> `;
886 const [name, ...annotation] = part.split(':');
887 return annotation.length
888 ? `<tspan class="cl-param">${esc(name)}</tspan><tspan class="t-muted cl-punct">:</tspan><tspan class="t-muted cl-type-ref">${esc(annotation.join(':'))}</tspan>`
889 : `<tspan class="cl-param">${esc(part)}</tspan>`;
890 }).join('')
891 : '';
892 return {
893 left: `${esc(member.name)}<tspan class="t-muted cl-punct">(</tspan>${parameters}<tspan class="t-muted cl-punct">)</tspan>`,
894 right: member.returns,
895 };
898function renderMembers(type) {
899 let top = type.y + headerHeight(type);
900 let stripe = 0;
901 return compartments(type).map((compartment) => {
902 const rule = `<line x1="${type.x}" y1="${top}" x2="${type.x + type.width}" y2="${top}" class="cl-rule" stroke-width="1"/>`;
903 let line = 0;
904 const rows = compartment.rows.flatMap(({ member, lines }) => {
905 const style = [
906 member.static ? 'text-decoration: underline' : '',
907 member.abstract ? 'font-style: italic' : '',
908 ].filter(Boolean).join('; ');
909 const styleAttr = style ? ` style="${style}"` : '';
910 const rowTop = top + layout.compartmentPad + line * layout.rowH;
911 const zebra = stripe++ % 2
912 ? `<rect x="${type.x + 4}" y="${rowTop + 1}" width="${type.width - 8}" height="${lines.length * layout.rowH - 2}" rx="4" class="cl-zebra"/>`
913 : '';
914 const glyph = member.visibility ? VISIBILITY_GLYPH[member.visibility] : '';
915 const x0 = type.x + layout.padX;
916 const chip = glyph ? visibilityChip(glyph, x0, rowTop + layout.rowH - 5) : '';
917 const textX = glyph ? x0 + VIS_CHIP + VIS_GAP : x0;
918 if (lines.length === 1) {
919 const baseline = top + layout.compartmentPad + (line += 1) * layout.rowH - 5;
920 const { left, right } = memberColumns(member, compartment.kind);
921 const typeText = right
922 ? `<text data-detail="context" x="${type.x + type.width - layout.padX}" y="${baseline}" class="t-muted cl-member cl-type-ref" font-size="${layout.memberFont}" text-anchor="end"${styleAttr}>${esc(right)}</text>`
923 : '';
924 return [`${zebra}${chip}<text data-detail="context" data-class-member="${compartment.kind}" x="${textX}" y="${baseline}" class="t-primary cl-member" font-size="${layout.memberFont}"${styleAttr}>${left}</text>${typeText}`];
925 }
926 return lines.map((entry, index) => {
927 const baseline = top + layout.compartmentPad + (line += 1) * layout.rowH - 5;
928 const text = index || !glyph ? entry.text : entry.text.slice(2);
929 const x = index || !glyph ? x0 + (glyph ? VIS_CHIP + VIS_GAP - 2 * MEMBER_ADVANCE : 0) + entry.indent * MEMBER_ADVANCE : textX;
930 const continued = index ? ' data-class-continuation=""' : '';
931 return `${index ? '' : `${zebra}${chip}`}<text data-detail="context" data-class-member="${compartment.kind}"${continued} x="${x}" y="${baseline}" class="t-primary cl-member" font-size="${layout.memberFont}"${styleAttr}>${memberSpans(text, !index)}</text>`;
932 });
933 });
934 top += line * layout.rowH + layout.compartmentPad * 2;
935 return ` <g data-detail="context" data-class-compartment="${compartment.kind}">
936 ${rule}
937 ${rows.join('\n ')}
938 </g>`;
939 }).join('\n');
942function renderType(type) {
943 const tone = KIND_TONE[type.kind] || 'backend';
944 const header = headerHeight(type);
945 const stereotype = STEREOTYPE[type.kind];
946 const kindLabel = i18nText(locale, `node.context.class.${type.kind}`);
947 const passport = { kind: type.kind, sublabel: type.sublabel, context: kindLabel };
948 const cx = type.x + type.width / 2;
949 const nameY = stereotype ? type.y + 34 : type.y + 22;
950 const badgeX = type.x + layout.padX;
951 const badgeY = type.y + (stereotype ? 14 : 9);
952 const italic = type.kind === 'abstract' ? ' font-style="italic"' : '';
953 const r = 8;
954 return ` <g ${focusNodeAttrs(type.id, type.label, passport, locale)} data-class-kind="${esc(type.kind)}" class="cl-hue-${tone}">
955 ${focusNodeTitle(type.label, passport)}
956 <rect x="${type.x}" y="${type.y}" width="${type.width}" height="${type.height}" rx="${r}" class="c-mask"/>
957 <rect x="${type.x}" y="${type.y}" width="${type.width}" height="${type.height}" rx="${r}" class="c-${tone} cl-type"${animateAttr(cd.meta, 'node', typeSteps.get(type.id))} stroke-width="1.2"/>
958 <path class="cl-header c-${tone}" stroke="none" d="M ${type.x + r} ${type.y} H ${type.x + type.width - r} A ${r} ${r} 0 0 1 ${type.x + type.width} ${type.y + r} V ${type.y + header} H ${type.x} V ${type.y + r} A ${r} ${r} 0 0 1 ${type.x + r} ${type.y} Z"/>
959 <path class="cl-cap" d="M ${type.x + 12} ${type.y + 1.4} H ${type.x + type.width - 12}" stroke-width="2.6" stroke-linecap="round" fill="none"/>
960 <g aria-hidden="true" class="cl-badge">
961 <rect x="${badgeX}" y="${badgeY}" width="${BADGE_SIZE}" height="${BADGE_SIZE}" rx="${BADGE_SIZE * 0.3}" class="cl-badge-chip"/>
962 <text x="${badgeX + BADGE_SIZE / 2}" y="${badgeY + BADGE_SIZE / 2 + 3.5}" class="t-${tone} cl-badge-letter" font-size="10" font-weight="800" text-anchor="middle">${esc(type.kind[0].toUpperCase())}</text>
963 </g>
964 ${stereotype ? `<text x="${cx}" y="${type.y + 16}" class="t-muted cl-stereotype" font-size="${layout.stereotypeFont}" text-anchor="middle">${esc(stereotype)}</text>` : ''}
965 <text data-node-label="" x="${cx}" y="${nameY}" class="t-primary" font-size="${layout.nameFont}" font-weight="700" text-anchor="middle"${italic}>${esc(type.label)}</text>
966${renderMembers(type)}
967 </g>`;
970function markerAttrs(kind, { reversed = false } = {}) {
971 const notation = NOTATION[kind];
972 if (reversed) return ` marker-start="url(#cl-${notation.end}-start)"`;
973 return `${notation.start ? ` marker-start="url(#cl-${notation.start})"` : ''}${notation.end ? ` marker-end="url(#cl-${notation.end})"` : ''}`;
976function relationshipClass(kind) {
977 return `a-default cl-relationship${NOTATION[kind].dashed ? ' cl-dashed' : ''}`;
980// Bus members share their trunk and bus, so each is drawn starting at the
981// supertype: the overlapping stretches then begin at one point and their dash
982// patterns coincide instead of beating into a smeared line.
983function renderRelationshipPath(relationship, index) {
984 const routed = pathFor(relationship);
985 const reversed = busPaths.has(relationship);
986 const d = reversed ? roundedPath([...routed.points].reverse(), 8) : routed.d;
987 const bus = reversed ? ` data-class-bus="${esc(relationship.to)}" data-motion-path="${esc(routed.d)}"` : '';
988 return ` <path ${focusEdgeAttrs(relationship.from, relationship.to, relationship.label, index, relationship.id)} data-class-relationship="${relationship.kind}"${bus} data-composition-points="${routePointsValue(routed.points)}" d="${d}" class="${relationshipClass(relationship.kind)}"${animateAttr(cd.meta, 'edge', index)} stroke-width="1.5"${markerAttrs(relationship.kind, { reversed })}/>`;
991function renderRelationshipLabel(relationship, index) {
992 if (!relationship.label) return '';
993 const [lx, ly] = relationshipLabelPoint(relationship);
994 const box = relationshipLabelBox(relationship);
995 return ` <g data-detail="context" ${focusEdgeAttrs(relationship.from, relationship.to, relationship.label, index, relationship.id)} data-class-relationship="${relationship.kind}">
996 <rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="${box.height / 2}" class="c-mask cl-label-box"/>
997 <text x="${lx}" y="${ly}" class="t-muted" font-size="${RELATIONSHIP_LABEL_FONT}" text-anchor="middle">${esc(relationship.label)}</text>
998 </g>`;
1001// A swatch is a short relationship drawn in the same notation: dash, line, and
1002// the marker at the end it belongs to.
1003function legendSwatch(entry) {
1004 const notation = NOTATION[entry.kind];
1005 const y = entry.baseline - 4;
1006 const x0 = entry.x + 2;
1007 const x1 = entry.x + LEGEND_SWATCH - 2;
1008 const hit = `<rect class="cl-legend-hit" tabindex="0" role="img" aria-label="${esc(entry.label)}" x="${entry.x - 6}" y="${entry.baseline - 15}" width="${entry.width + 12}" height="22" rx="11"/>`;
1009 return `${hit}<path d="M ${x0} ${y} L ${x1} ${y}" class="${relationshipClass(entry.kind)}" stroke-width="1.5"${markerAttrs(entry.kind)}/>`;
1012const LEGEND_HOVER_RULES = RELATIONSHIP_KINDS.map((kind) => ` svg[data-class-ui]:has([data-legend-semantic-kind="${kind}"]:is(:hover, :focus-within)) [data-class-relationship]:not([data-class-relationship="${kind}"]) { opacity: .15; }`).join('\n');
1014function renderLegend() {
1015 const obstacles = relationshipLegendObstacles(routable, {
1016 pointsFor: (relationship) => pathFor(relationship).points,
1017 labelRectFor: (relationship) => (relationship.label ? relationshipLabelBox(relationship) : null),
1018 });
1019 return renderResolvedLegend({
1020 entries: legendEntries,
1021 locale,
1022 layout: legendLayout(viewBox[0], viewBox[1], { obstacles }),
1023 renderSwatch: legendSwatch,
1024 });
1027function renderSvg() {
1028 // Like the ERD, an automatic canvas lets the reader scroll a tall model
1029 // instead of shrinking member rows past legibility.
1030 const readerFit = cd.meta?.viewBox ? '' : ' data-reader-fit="intrinsic-height" data-reader-min-text="7.5"';
1031 return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(cd.meta)} data-class-ui=""${readerFit}>
1032${svgAccessibleText(cd.meta, 'class')}
1033${renderDefinitions(renderMarkerDefs())}
1034 <style>
1035 svg[data-class-ui] .cl-zebra { fill: color-mix(in srgb, var(--cl-hue) 6%, transparent); }
1036 svg[data-class-ui] .cl-param { fill: color-mix(in srgb, var(--text) 72%, var(--text-muted)); }
1037${[...new Set(Object.values(KIND_TONE))].map((tone) => ` svg[data-class-ui] .cl-hue-${tone} { --cl-hue: var(--${tone}-stroke); }`).join('\n')}
1038 svg[data-class-ui] .cl-type { fill: var(--mask); stroke: color-mix(in srgb, var(--cl-hue) 46%, var(--lane-stroke)); }
1039 svg[data-class-ui] [data-class-kind="abstract"] .cl-type { stroke-dasharray: 5 3; stroke: color-mix(in srgb, var(--cl-hue) 64%, var(--lane-stroke)); }
1040 svg[data-class-ui] .cl-header { stroke: none; }
1041 svg[data-class-ui] .cl-cap { stroke: var(--cl-hue); pointer-events: none; }
1042 svg[data-class-ui] .cl-badge-chip { fill: var(--cl-hue); }
1043 svg[data-class-ui] .cl-badge-letter { fill: var(--mask); }
1044 svg[data-class-ui] .cl-stereotype { fill: var(--text-muted); letter-spacing: .04em; }
1045 svg[data-class-ui] .cl-rule { stroke: color-mix(in srgb, var(--cl-hue) 22%, var(--lane-stroke)); }
1046 svg[data-class-ui] .cl-vis { font-weight: 700; }
1047 svg[data-class-ui] .cl-vis-chip { fill: color-mix(in srgb, var(--text) 6%, transparent); }
1048 svg[data-class-ui] .cl-type-ref { fill: var(--text-muted); }
1049 svg[data-class-ui] .cl-punct { fill: var(--text-muted); opacity: .8; }
1050 svg[data-class-ui] .cl-label-box { stroke: var(--lane-stroke); stroke-width: .8; }
1051 svg[data-class-ui] .cl-member { font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, 'DejaVu Sans Mono', 'Liberation Mono', 'Noto Sans Mono CJK SC', 'PingFang SC', 'Microsoft YaHei', monospace; }
1052 svg[data-class-ui] .cl-dashed { stroke-dasharray: 6 4; }
1053 svg[data-class-ui] .cl-marker { stroke: var(--arrow); }
1054 svg[data-class-ui] .cl-marker-hollow { fill: var(--mask); }
1055 svg[data-class-ui] .cl-marker-filled { fill: var(--arrow); }
1056 svg[data-class-ui] .cl-legend-hit { fill: transparent; transition: fill .15s; }
1057 svg[data-class-ui] .cl-legend-hit:focus { outline: none; }
1058 svg[data-class-ui] .cl-legend-hit:focus-visible { stroke: var(--arrow); stroke-width: 1.5; }
1059 svg[data-class-ui] [data-legend-semantic-kind]:is(:hover, :focus-within) .cl-legend-hit { fill: var(--lane-fill); }
1060 svg[data-class-ui] [data-class-relationship] { transition: opacity .15s; }
1061${LEGEND_HOVER_RULES}
1062 @media (prefers-reduced-motion: reduce) { svg[data-class-ui] * { transition: none !important; } }
1063 </style>
1065 <!-- Background Grid -->
1066 <rect width="100%" height="100%" fill="url(#grid)" />
1068 <!-- Relationships (before types for correct z-order) -->
1069${relationships.map((relationship, index) => (renderableRelationship(relationship) ? renderRelationshipPath(relationship, index) : '')).filter(Boolean).join('\n')}
1071 <!-- Types -->
1072${[...types.values()].map(renderType).join('\n\n')}
1074 <!-- Relationship labels -->
1075${relationships.map((relationship, index) => (renderableRelationship(relationship) ? renderRelationshipLabel(relationship, index) : '')).filter(Boolean).join('\n')}
1077 <!-- Legend -->
1078${renderLegend()}
1079 </svg>`;
1082validateClassDiagram();
1083if (layoutJsonMode) {
1084 console.log(JSON.stringify(buildLayoutReport(), null, 2));
1085 process.exit(0);
1087writeDiagram({
1088 outPath,
1089 template,
1090 diagramType: 'class',
1091 meta: cd.meta,
1092 svg: renderSvg(),
1093 cards: cd.cards,
1094 sourceEvidence,
1095});