render-erd.mjs
1588 lines · archify/renderers/erd/render-erd.mjs at a406594
1import path from 'node:path';
2import { fileURLToPath } from 'node:url';
3import { esc, renderDefinitions, renderSemanticSigil, 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, fittedNodeFontSize } from '../shared/text-fit.mjs';
23import { translateMessage as i18nText } from '../shared/i18n.mjs';
24import {
25 DEFAULT_ER_GRID,
26 bandedLayout,
27 entityHeight,
28 erGridLayout,
29 resolveEntityPos,
30 resolvedEntityWidth,
31 validateErGridPlacement,
32} from './grid.mjs';
33import {
34 asArray,
35 arrowClassMap,
36 cleanAmbiguousCorridorProblems,
37 cleanCrossingProblems,
38 cleanEndpointSideProblems,
39 cleanFlowProblems,
40 cleanLabelRouteClearanceProblems,
41 cleanRouteRhythmProblems,
42 labelPoint,
43 legacyDefaultFromSide,
44 legacyDefaultToSide,
45 rectsOverlap,
46 roundedPath,
47 routePointsValue,
48 segmentIntersectsRect,
49 suggestComponentSeparation,
50 suggestLabelObstacleFix,
51 variantAccent,
52} from '../shared/geometry.mjs';
53import { createRouter } from '../architecture/routing.mjs';
54import { placeAutomaticLabels } from '../shared/automatic-labels.mjs';
55import { entityBox, connectionPath as relationshipPath } from '../shared/layout-report.mjs';
57const __dirname = path.dirname(fileURLToPath(import.meta.url));
58const layoutJsonMode = process.argv.includes('--layout-json');
59const cliArgs = process.argv.filter((arg) => arg !== '--layout-json');
60const { diagram: er, template, outPath, sourceEvidence } = loadDiagram({
61 rendererDir: __dirname,
62 diagramType: 'erd',
63 defaultExample: 'orders.erd.json',
64 argv: cliArgs,
65});
67const grid = erGridLayout(er);
69const layout = {
70 margin: 24,
71 legendH: 34,
72 padX: 12,
73 keyWidth: 30,
74 keyFont: 9,
75 headerFont: 13,
76 headerFontMinimum: 10,
77 rowFont: 11,
78 rowFontMinimum: 9,
79 typeFont: 10.5,
80 typeFontMinimum: 9,
81 minRelationshipLength: 24,
82};
84const KEY_ACCENT = { pk: 't-database', fk: 't-messagebus', uk: 't-muted' };
85// Each key role is a tinted badge; the badge carries the role so the stylesheet
86// tints it in the role's own family and the legend hover can find it.
87const KEY_BADGE_W = 22;
88const KEY_BADGE_GAP = 3;
89const KEY_BADGE_INSET = 2;
91// A column can carry more than one key role: a junction table's tenant_id is
92// both part of the primary key and a foreign key to tenant.id, and stating only
93// one of the two roles would claim less than the schema does. `key` is a role or
94// a list of roles, and a role named twice still draws once.
95const KEY_ROLE_GAP = 5;
97function keyRoles(attribute) {
98 const key = attribute?.key;
99 const roles = typeof key === 'string' ? [key] : asArray(key);
100 return [...new Set(roles)];
101}
103// The run of badges one row's roles occupy past the column's start (the first
104// badge sits a small inset to the left of it), so the column can be sized from it.
105function keyGlyphWidth(roles) {
106 if (!roles.length) return 0;
107 return roles.length * KEY_BADGE_W + (roles.length - 1) * KEY_BADGE_GAP - KEY_BADGE_INSET;
108}
110// One key column per table, floored at the shared width so a table that states a
111// single role keeps the alignment every earlier diagram already has.
112function keyColumnWidth(entity) {
113 const widest = asArray(entity?.attributes)
114 .reduce((max, attribute) => Math.max(max, keyGlyphWidth(keyRoles(attribute))), 0);
115 // The trailing gap is the porch between the last marker and the field name.
116 return Math.max(layout.keyWidth, widest ? widest + KEY_ROLE_GAP : 0);
117}
119// One glyph per role, each in its legend's own ink so a composite key reads as
120// two known markers rather than one unfamiliar token.
121function renderKeyBadge(role, x, baseline) {
122 return `<rect x="${x}" y="${baseline - 10}" width="${KEY_BADGE_W}" height="13" rx="3" class="er-key-badge" data-er-badge="${esc(role)}"/><text data-detail="context" x="${x + KEY_BADGE_W / 2}" y="${baseline}" class="${KEY_ACCENT[role] || 't-muted'}" font-size="${layout.keyFont}" font-weight="600">${esc(role.toUpperCase())}</text>`;
123}
125function renderKeyGlyphs(attribute, x0, baseline) {
126 return keyRoles(attribute)
127 .map((role, index) => renderKeyBadge(role, x0 - KEY_BADGE_INSET + index * (KEY_BADGE_W + KEY_BADGE_GAP), baseline))
128 .join('');
129}
131// ---- Measure entities from the banded grid -----------------------------------
132// Header and row metrics live in the grid contract so the banded layout and the
133// renderer can never disagree about a box's height.
134const metrics = grid ?? DEFAULT_ER_GRID;
136// Width resolution lives in the grid module so placement and measurement cannot
137// disagree about an entity that omits `width`.
138const entityWidth = (entity) => resolvedEntityWidth(entity, grid);
140const measuredEntities = asArray(er.entities).map((entity) => {
141 const attributes = asArray(entity.attributes);
142 const width = entityWidth(entity);
143 return {
144 ...entity,
145 attributes,
146 width,
147 height: entityHeight({ ...entity, attributes }, grid ?? DEFAULT_ER_GRID),
148 };
149});
151// A relationship label is semantic data and the smallest text the diagram
152// carries: at the shared 8-unit default it projects to ~7.0px on a tall canvas,
153// under the 7.5px the canvas declares.
154const RELATIONSHIP_LABEL_FONT = 9;
155// The label's advance is measured from that size, not from a literal: the mask
156// the renderer draws, the rect the layout checks measure, and the obstacle the
157// legend measures must be the same rectangle, or a label can sit on a table edge
158// with no diagnostic (the checks would be measuring a narrower box than the ink).
159const RELATIONSHIP_LABEL_ADVANCE = 0.62;
160const relationshipLabelWidth = (label) => Math.max(30, textUnits(label) * RELATIONSHIP_LABEL_FONT * RELATIONSHIP_LABEL_ADVANCE + 10);
162// A label between two neighbouring columns is drawn in the gap between them:
163// on the straight line of a same-row relationship, or across the channel of a
164// bent one. The fixed gap was narrower than an ordinary two-word label plus the
165// cardinality glyph at each table edge, so most first drafts failed with a
166// label drawn over a table. When the grid owns every coordinate (no pinned
167// position, route, channel, or label point refers to absolute geometry), each
168// such gap widens just enough for its widest label to clear both glyphs.
169const LABEL_GLYPH_CLEARANCE = 24 + 4;
170function labelColumnGaps() {
171 if (!grid || Array.isArray(er.meta?.viewBox)) return null;
172 const rawEntities = asArray(er.entities);
173 const rawRelationships = asArray(er.relationships);
174 const absolute = rawEntities.some((entity) => Array.isArray(entity.pos))
175 || rawRelationships.some((relationship) => relationship.via?.length || relationship.labelAt
176 || (relationship.route && relationship.route !== 'auto')
177 || relationship.channelX !== undefined || relationship.channelY !== undefined);
178 if (absolute) return null;
179 const byId = new Map(rawEntities.map((entity) => [entity.id, entity]));
180 const gaps = new Map();
181 for (const relationship of rawRelationships) {
182 if (!relationship.label) continue;
183 const from = byId.get(relationship.from);
184 const to = byId.get(relationship.to);
185 if (![from?.col, to?.col].every(Number.isInteger) || Math.abs(from.col - to.col) !== 1) continue;
186 const gap = Math.min(from.col, to.col);
187 const demand = Math.ceil(relationshipLabelWidth(relationship.label) + LABEL_GLYPH_CLEARANCE * 2);
188 gaps.set(gap, Math.max(gaps.get(gap) || 0, demand));
189 }
190 return gaps.size ? gaps : null;
191}
193const bands = grid ? bandedLayout(measuredEntities, grid, { columnGaps: labelColumnGaps() }) : null;
195const entities = new Map(measuredEntities.map((entity) => {
196 const [x, y] = resolveEntityPos(entity, grid, bands);
197 return [entity.id, { ...entity, x, y, cx: x + entity.width / 2, cy: y + entity.height / 2 }];
198}));
200const relationships = asArray(er.relationships);
202// ---- Domain bands ------------------------------------------------------------
203// A `tag` names a domain, and a domain has to read as one block. The band is
204// measured from the placed boxes of the entities that carry the tag, so it
205// follows the authored grid instead of introducing a second layout pass. Only a
206// block the members actually fill earns a band: a tag whose members are spread
207// across the canvas is a grouping the author did not actually make, and drawing
208// a band around the gap would claim a structure the table placement contradicts.
209// Since the band is the only place a domain name is drawn, an unbandable tag is
210// refused rather than published unnamed; see `erd/domain-not-drawn`.
211const DOMAIN_PAD_X = 12;
212const DOMAIN_PAD_TOP = 18;
213const DOMAIN_PAD_BOTTOM = 8;
215// A domain is drawn as a band when its members fill a solid rectangle of grid
216// cells: every cell of the bounding box holds one of them, so the band encloses
217// no cell the domain does not own. A single row or column, the common case, is
218// that same claim with one dimension of one; an L or diagonal is not contiguous.
219function gridRunIsContiguous(members) {
220 const cols = members.map((entity) => entity.col);
221 const rows = members.map((entity) => entity.row);
222 const cells = new Set(members.map((entity) => `${entity.row}:${entity.col}`));
223 if (cells.size !== members.length) return false;
224 const box = (Math.max(...cols) - Math.min(...cols) + 1) * (Math.max(...rows) - Math.min(...rows) + 1);
225 return cells.size === box;
226}
228// A band is measured from grid cells, so it can only follow a table that is
229// placed by them. A table that also declares `pos` is drawn at that point
230// whatever its cells say, and banding it would wrap a box the domain does not
231// own around wherever the author put it.
232function placedByGrid(entity) {
233 return Number.isInteger(entity.row) && Number.isInteger(entity.col)
234 && !(Array.isArray(entity.pos) && entity.pos.length === 2);
235}
237const domainGroups = (() => {
238 const byTag = new Map();
239 for (const entity of entities.values()) {
240 if (!entity.tag) continue;
241 if (!byTag.has(entity.tag)) byTag.set(entity.tag, []);
242 byTag.get(entity.tag).push(entity);
243 }
244 const groups = [];
245 for (const [tag, members] of byTag) {
246 if (!members.every(placedByGrid)) continue;
247 if (!gridRunIsContiguous(members)) continue;
248 groups.push({ tag, members });
249 }
250 return groups;
251})();
253// A band is the only place a domain name is drawn, because the header carries
254// the table title alone. A tag whose members cannot form one therefore loses its
255// name from the canvas while the tables look complete, so the render refuses the
256// document instead of drawing a domain the reader cannot name.
257const unlabelableDomains = (() => {
258 const banded = new Set(domainGroups.map((group) => group.tag));
259 const byTag = new Map();
260 for (const entity of entities.values()) {
261 if (!entity.tag || banded.has(entity.tag)) continue;
262 if (!byTag.has(entity.tag)) byTag.set(entity.tag, []);
263 byTag.get(entity.tag).push(entity);
264 }
265 return [...byTag.entries()].map(([tag, members]) => {
266 const gridPlaced = members.every(placedByGrid);
267 // Two members can share a cell, which the grid placement check already
268 // reports with the pair that has to move; do not also blame the block.
269 const sharedCell = gridPlaced
270 && new Set(members.map((entity) => `${entity.row}:${entity.col}`)).size !== members.length;
271 return {
272 tag,
273 members,
274 reason: sharedCell ? 'shared-cell' : gridPlaced ? 'scattered' : 'no-grid',
275 offGrid: members.filter((entity) => !placedByGrid(entity)).map((entity) => entity.id),
276 };
277 });
278})();
280// The band box and the caption the band draws, measured once so the drawing and
281// the capacity check cannot disagree about where the domain name lands.
282function domainBandBox(members) {
283 const minX = Math.min(...members.map((entity) => entity.x));
284 const minY = Math.min(...members.map((entity) => entity.y));
285 const maxX = Math.max(...members.map((entity) => entity.x + entity.width));
286 const maxY = Math.max(...members.map((entity) => entity.y + entity.height));
287 return {
288 x: minX - DOMAIN_PAD_X,
289 y: minY - DOMAIN_PAD_TOP,
290 width: maxX - minX + DOMAIN_PAD_X * 2,
291 height: maxY - minY + DOMAIN_PAD_TOP + DOMAIN_PAD_BOTTOM,
292 };
293}
295function domainBandCaption(box) {
296 return { x: box.x + 10, y: box.y + 13 };
297}
299// Each domain owns one hue that its band and its tables share, so a table's
300// domain reads from its header before the band caption is found. Untagged
301// tables keep the schema's database hue.
302const DOMAIN_HUES = ['database', 'frontend', 'backend', 'cloud', 'messagebus', 'security'];
303const domainHue = new Map();
304for (const entity of entities.values()) {
305 if (entity.tag && !domainHue.has(entity.tag)) domainHue.set(entity.tag, DOMAIN_HUES[domainHue.size % DOMAIN_HUES.length]);
306}
307const hueClassOf = (tag) => `er-hue-${(tag && domainHue.get(tag)) || 'database'}`;
309function renderDomainBands() {
310 if (!domainGroups.length) return '';
311 return domainGroups.map(({ tag, members }) => {
312 const box = domainBandBox(members);
313 const caption = domainBandCaption(box);
314 const pillWidth = Math.ceil(textUnits(tag) * (9 * nodeTextFit.widthFactor + 0.8) + 14);
315 return ` <g data-domain-band="${esc(tag)}" class="${hueClassOf(tag)}">
316 <rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="12" class="c-lane er-band" stroke-width="1"/>
317 <rect x="${caption.x - 2}" y="${box.y - 7}" width="${pillWidth}" height="15" rx="7.5" class="c-mask"/>
318 <rect x="${caption.x - 2}" y="${box.y - 7}" width="${pillWidth}" height="15" rx="7.5" class="er-band-pill"/>
319 <text data-domain-label="" x="${caption.x + 5}" y="${box.y + 3.6}" class="t-muted er-band-label" font-size="9" font-weight="700">${esc(tag)}</text>
320 </g>`;
321 }).join('\n');
322}
324const entitySteps = new Map();
325for (const [index, relationship] of relationships.entries()) {
326 if (!entitySteps.has(relationship.from)) entitySteps.set(relationship.from, index);
327 if (!entitySteps.has(relationship.to)) entitySteps.set(relationship.to, index + 1);
328}
329for (const [index, entity] of asArray(er.entities).entries()) {
330 if (!entitySteps.has(entity.id)) entitySteps.set(entity.id, index);
331}
333// ---- Cardinality -------------------------------------------------------------
334// A relationship reads `from` -> `to` and both ends declare their own maximum.
335// The schema requires each one, so an unstated maximum fails validation before
336// rendering instead of reading as a fact the author never asserted; this
337// fallback only keeps an already-invalid diagram from crashing the renderer.
338function cardinalityOf(relationship, endpoint) {
339 const value = endpoint === 'from' ? relationship.fromCardinality : relationship.toCardinality;
340 if (value === 'one' || value === 'many') return value;
341 return endpoint === 'from' ? 'many' : 'one';
342}
344function optionalOf(relationship, endpoint) {
345 return (endpoint === 'from' ? relationship.fromOptional : relationship.toOptional) === true;
346}
348// `identifying: false` is a fact about the key and the dashed line is how this
349// renderer draws that fact, so the flag owns the dash. The schema rejects a
350// `variant` that would contradict it; this precedence keeps a document that
351// reached the renderer without passing the schema from drawing the
352// relationship solid.
353function markerStyleOf(relationship) {
354 if (relationship.identifying === false) return 'dashed';
355 return relationship.variant ?? 'default';
356}
358function cardinalityMarkerId(relationship, endpoint) {
359 const cardinality = cardinalityOf(relationship, endpoint);
360 const optional = optionalOf(relationship, endpoint) ? '-optional' : '';
361 return `er-${cardinality}${optional}-${endpoint === 'from' ? 'start' : 'end'}`;
362}
364// Crow's foot notation: the foot opens toward the entity whose cardinality it
365// describes. The toes touch that entity at the path endpoint and the apex sits
366// one foot back along the relationship, so the glyph reads as "many rows here"
367// rather than as an arrowhead pointing away from the table it describes.
368// `start` markers are mirrored because an oriented marker's +x axis points
369// along the path direction, which leaves the from-endpoint heading outward.
370// The glyph is drawn in the muted text ink rather than the shared arrow token:
371// at the light theme's arrow colour a crow's foot over a table fill sits near
372// 2:1 contrast and reads as a smudge.
373//
374// The optionality ring sits past the apex rather than inside the toes. Both
375// facts are about the same end, so a ring centred on the foot would be
376// bisected by its middle toe and read as one muddy shape instead of
377// "zero or many".
378const MARKER_GLYPH_WIDTH = 24;
379const MARKER_TOE_X = 22;
380const MARKER_APEX_X = 8;
381const MARKER_CIRCLE_X = 3.5;
382// The glyph spans y 1..15, so two ports closer than this draw overlapping
383// symbols and the side reads as one lattice instead of N cardinalities.
384const MARKER_HEIGHT = 14;
385// One glyph source for the relationship markers and the legend swatches, so the
386// legend can never teach a different symbol than the lines draw.
387function cardinalityGlyphParts({ cardinality, optional, mirror = false }) {
388 const flip = (x) => (mirror ? MARKER_TOE_X - x : x);
389 const parts = [];
390 if (cardinality === 'many') {
391 parts.push(`<path d="M ${flip(MARKER_APEX_X)} 8 L ${flip(MARKER_TOE_X)} 1 M ${flip(MARKER_APEX_X)} 8 L ${flip(MARKER_TOE_X)} 8 M ${flip(MARKER_APEX_X)} 8 L ${flip(MARKER_TOE_X)} 15"/>`);
392 } else if (cardinality === 'one') {
393 parts.push(`<path d="M ${flip(17)} 1 L ${flip(17)} 15"/>`);
394 }
395 if (optional) {
396 // The ring sits on the relationship line, so the line has to be masked
397 // under it: a stroked circle alone leaves the dashes running through the
398 // middle and the symbol reads as a crossed-out dot.
399 parts.push(`<circle cx="${flip(MARKER_CIRCLE_X)}" cy="8" r="2.6" class="c-mask"/>`);
400 parts.push(`<circle cx="${flip(MARKER_CIRCLE_X)}" cy="8" r="2.6"/>`);
401 }
402 return parts;
403}
405function cardinalityMarkerMarkup(id, { cardinality, optional, mirror }) {
406 const refX = mirror ? 0 : MARKER_TOE_X;
407 return ` <marker id="${esc(id)}" markerWidth="${MARKER_GLYPH_WIDTH}" markerHeight="18" refX="${refX}" refY="8" orient="auto" markerUnits="userSpaceOnUse" class="a-default" style="stroke: var(--text-muted)" stroke-width="1.75">
408 ${cardinalityGlyphParts({ cardinality, optional, mirror }).join('\n ')}
409 </marker>`;
410}
412function renderCardinalityDefs() {
413 const used = new Set();
414 for (const relationship of relationships) {
415 used.add(`${cardinalityOf(relationship, 'from')}|${optionalOf(relationship, 'from')}|start`);
416 used.add(`${cardinalityOf(relationship, 'to')}|${optionalOf(relationship, 'to')}|end`);
417 }
418 const ids = [];
419 for (const cardinality of ['one', 'many']) {
420 for (const optional of [false, true]) {
421 for (const end of ['start', 'end']) {
422 ids.push({ id: `er-${cardinality}${optional ? '-optional' : ''}-${end}`, cardinality, optional, mirror: end === 'start', key: `${cardinality}|${optional}|${end}` });
423 }
424 }
425 }
426 const markup = ids.filter((entry) => used.has(entry.key))
427 .map((entry) => cardinalityMarkerMarkup(entry.id, entry));
428 if (!markup.length) return '';
429 return `\n <!-- Cardinality symbols -->\n${markup.join('\n')}`;
430}
432// ---- Legend ------------------------------------------------------------------
433// The ERD kinds are column keys and relationship ends, not `data-node-kind`
434// values, so the Viewer's counted node lens would mark every entry as zero.
435// The entries stay static and highlight their matches in the SVG on hover.
436const LEGEND_CATALOG = ['pk', 'fk', 'uk', 'one', 'many', 'optional']
437 .map((kind) => ({ kind, label: i18nText(er.meta.locale, `legend.erd.${kind}`), interactive: false }));
438const KEY_KINDS = new Set(['pk', 'fk', 'uk']);
440// The kinds one relationship shows at its two ends; the legend hover matches
441// a route and its label by this list.
442function relationshipEnds(relationship) {
443 const ends = new Set([cardinalityOf(relationship, 'from'), cardinalityOf(relationship, 'to')]);
444 if (optionalOf(relationship, 'from') || optionalOf(relationship, 'to')) ends.add('optional');
445 return [...ends];
446}
448const presentKinds = new Set();
449for (const entity of entities.values()) {
450 for (const attribute of entity.attributes) {
451 for (const role of keyRoles(attribute)) presentKinds.add(role);
452 }
453}
454for (const relationship of relationships) {
455 for (const kind of relationshipEnds(relationship)) presentKinds.add(kind);
456}
458// The reserved legend band: its baseline sits this far above the canvas bottom,
459// and its labels are set at this size. Both are part of the band's footprint.
460const LEGEND_BASELINE_GAP = 16;
461const LEGEND_FONT_SIZE = 10;
462const LEGEND_KEY_SWATCH = 22;
463const LEGEND_END_SWATCH = 30;
464// Extra space before the first relationship entry that follows a key entry, so
465// the divider between the column and relationship groups has room on both sides.
466const LEGEND_GROUP_LEAD = 10;
468// The shared renderer sets labels two units above the measured font size, so
469// each entry reserves that difference and the hover pill never overlaps its
470// neighbour. Computed from the resolved label, so an authored label is measured.
471const erLegendEntries = resolveLegend(er.meta?.legend, LEGEND_CATALOG, presentKinds).map((entry, index, all) => {
472 const isKey = KEY_KINDS.has(entry.kind);
473 const lead = !isKey && index > 0 && KEY_KINDS.has(all[index - 1].kind) ? LEGEND_GROUP_LEAD : 0;
474 return {
475 ...entry,
476 lead,
477 swatchWidth: (isKey ? LEGEND_KEY_SWATCH : LEGEND_END_SWATCH) + lead,
478 trailingWidth: Math.ceil(textUnits(entry.label) * 2 * 0.62),
479 };
480});
482// One layout object for the reserved legend band, shared by the sizing probe
483// and the render call so the drawing area is never sized against a different
484// strip than the one the legend is placed into.
485function legendLayout(width, height, { obstacles = [], unfit = 'error' } = {}) {
486 const contentBottom = Math.max(0, ...[...entities.values()].map((entity) => entity.y + entity.height));
487 return {
488 x: layout.margin,
489 baselineY: height - LEGEND_BASELINE_GAP,
490 width: width - layout.margin * 2,
491 fontSize: LEGEND_FONT_SIZE,
492 minTitleY: contentBottom + 8,
493 obstacles,
494 unfit,
495 diagramType: 'erd',
496 };
497}
499// The default legend is part of the generated content, so the drawing area is
500// sized to hold it: a reserved strip that is a few units short grows the
501// canvas instead of dropping the key. An authored `meta.viewBox` is a fixed
502// area, and there the same measurement raises the capacity diagnostic.
503function autoViewBox() {
504 const maxX = Math.max(0, ...[...entities.values()].map((entity) => entity.x + entity.width));
505 const maxY = Math.max(0, ...[...entities.values()].map((entity) => entity.y + entity.height));
506 let width = Math.ceil(maxX + layout.margin);
507 let footprint = legendFootprint(erLegendEntries, { width: Math.max(1, width - layout.margin * 2) });
508 if (footprint.minWidth > width - layout.margin * 2) {
509 width = Math.ceil(footprint.minWidth + layout.margin * 2);
510 footprint = legendFootprint(erLegendEntries, { width: width - layout.margin * 2 });
511 }
512 let height = Math.ceil(maxY + layout.margin + layout.legendH + footprint.extraHeight);
513 // The shared measurement decides whether the strip fits; each attempt adds a
514 // whole band, so the loop ends after at most a few rounds.
515 for (let attempt = 0; attempt < 4; attempt += 1) {
516 if (measureLegend(erLegendEntries, legendLayout(width, height, { unfit: 'hide' }))) break;
517 height += layout.legendH;
518 }
519 return [width, height];
520}
522// The legends are measured before routing exists (this runs at module load), so
523// the probe covers the sizing failure modes — label width and band height. A
524// route or label that later collides with the placed band is a real capacity
525// failure and keeps its diagnostic.
526const viewBox = Array.isArray(er.meta?.viewBox) ? er.meta.viewBox : autoViewBox();
528// ---- Routing -----------------------------------------------------------------
529// Relationships that would share one corridor get a deterministic lane each, so
530// two routes never sit on the identical channel line. The lane is only a
531// preference: a lane that breaks the endpoint contract or hits an entity is
532// skipped and the shared candidate families run unchanged.
533const LANE_STEP = 12;
535function relationshipLabelBox(relationship) {
536 const [lx, ly] = erLabelPoint(relationship);
537 const width = relationshipLabelWidth(relationship.label);
538 return { x: lx - width / 2, y: ly - 10, width, height: 14 };
539}
541// A cardinality glyph is 14 units tall, so two relationships leaving the same
542// table side must sit at least this far apart: the shared 14px spread put them
543// close enough to read as one shape. The trunk bridge below is derived from it.
544const ERD_PORT_SPACING = 24;
546const bandKey = (entity) => (
547 Number.isInteger(entity.row) && Number.isInteger(entity.col)
548 ? `i${entity.row},${entity.col}`
549 : `p${Math.round(entity.x || 0)},${Math.round(entity.y || 0)}`
550);
552const { ports, pathFor, inferredSides, connectionEndpointSide } = createRouter(entities, relationships, {
553 // Horizontal-first, like every other non-architecture type: a relationship
554 // leaves and enters on the left/right when the tables sit side by side and
555 // only falls back to a top/bottom port when they share a column. The shared
556 // router defaults to architecture's dominant-axis inference, which would flip
557 // the side of a relationship whose targets happen to sit further apart
558 // vertically, splitting one fan-in into two groups nobody authored.
559 sideFor: (relationship, endpoint) => {
560 const from = entities.get(relationship.from);
561 const to = entities.get(relationship.to);
562 if (!from || !to) return undefined;
563 return endpoint === 'source' ? legacyDefaultFromSide(from, to) : legacyDefaultToSide(from, to);
564 },
565 preferredCandidates: (context) => [...trunkCandidates(context), ...laneCandidates(context)],
566 // A bundled trunk is drawn as one bus path and the trunk stretch is removed
567 // from each branch's drawn route, so the router's rhythm floors — which
568 // describe a route drawn exactly as planned — do not describe what this
569 // renderer draws. The composition gate still measures the drawn result.
570 compositionFloors: false,
571 // A cardinality glyph is 14 units tall, so ports must clear it (see
572 // ERD_PORT_SPACING, which the trunk bridge is derived from).
573 maxPortSpacing: ERD_PORT_SPACING,
574 // Greedy planning routes each relationship against only the earlier ones, so
575 // a later route could cross an earlier corridor while an empty side of the
576 // table was free. Architecture's bounded readability sweep re-plans one
577 // route at a time against the complete scene and keeps a change only when it
578 // removes crossings or bends at bounded extra length.
579 preferReadableRoutes: true,
580 // A proper crossing fails the showcase gate, so a relationship that needs
581 // the obstacle search first looks for a detour that crosses nothing.
582 crossingFreeGridFirst: true,
583 // A detour keeps a readable gap from every table it only passes instead of
584 // running along its border at the grid's 2-unit clearance.
585 componentGapPx: 10,
586 // Corners are drawn with a 6-unit radius; an end segment shorter than the
587 // radius plus the 8-unit micro-segment floor fails the drawn-route gate.
588 minimumTerminalSegmentPx: 14,
589});
591function corridorKey(relationship) {
592 const from = entities.get(relationship.from);
593 const to = entities.get(relationship.to);
594 if (!from || !to) return null;
595 const { fromSide, toSide } = inferredSides(relationship);
596 const horizontalPorts = (fromSide === 'left' || fromSide === 'right');
597 const verticalPorts = (fromSide === 'top' || fromSide === 'bottom');
598 if (horizontalPorts === verticalPorts) return null;
599 const ordered = horizontalPorts
600 ? [from, to].sort((left, right) => left.cx - right.cx)
601 : [from, to].sort((left, right) => left.cy - right.cy);
602 return `${horizontalPorts ? 'h' : 'v'}:${bandKey(ordered[0])}|${bandKey(ordered[1])}`;
603}
605const laneOffsets = new Map();
606{
607 const groups = new Map();
608 for (const relationship of relationships) {
609 const key = corridorKey(relationship);
610 if (!key) continue;
611 const list = groups.get(key) || [];
612 list.push(relationship);
613 groups.set(key, list);
614 }
615 for (const list of groups.values()) {
616 if (list.length < 2) continue;
617 list.sort((left, right) => {
618 const leftKey = `${left.from}\u0000${left.to}\u0000${left.id || ''}`;
619 const rightKey = `${right.from}\u0000${right.to}\u0000${right.id || ''}`;
620 return leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0;
621 });
622 for (const [index, relationship] of list.entries()) {
623 laneOffsets.set(relationship, index - (list.length - 1) / 2);
624 }
625 }
626}
628// ---- Bundled fan-in / fan-out trunks ------------------------------------------
629// Relationships that share one entity side read better as a single trunk with
630// short branches than as a sheaf of parallel lines (bus-style fan-in). A trunk
631// group shares the resolved side, the marker style, and automatic routing;
632// anything authored (route/via/labelAt) keeps its own line. The trunk is only
633// a preferred candidate: a route that would break the endpoint contract or
634// clip an entity falls back to the ordinary families and stays unbundled.
635const TRUNK_OFFSETS = [16, 22, 12, 28];
636const TRUNK_SNAP = LANE_STEP;
637// One port step of uncovered bus is a bus with a gap in it: the branches were
638// spread by ERD_PORT_SPACING, so the gap they leave is exactly that wide. Two
639// steps means the runs belong to different groups and stay separate lines.
640const TRUNK_BRIDGE = ERD_PORT_SPACING + 8;
641const TRUNK_CAP = 10;
642const TRUNK_LEG = 12;
643const DEFAULT_RELATIONSHIP_STROKE_WIDTH = 1.5;
645function relationshipStrokeWidth(relationship) {
646 return Number.isFinite(relationship.width) ? relationship.width : DEFAULT_RELATIONSHIP_STROKE_WIDTH;
647}
649const isHorizontalSide = (side) => (side === 'left' || side === 'right');
651function relationshipAutoRouted(relationship) {
652 return renderableRelationship(relationship)
653 && !relationship.via
654 && (!relationship.route || relationship.route === 'auto')
655 && relationship.channelX === undefined
656 && relationship.channelY === undefined
657 && !relationship.labelAt;
658}
660function sideAnchor(entity, side) {
661 if (side === 'left') return [entity.x, entity.cy];
662 if (side === 'right') return [entity.x + entity.width, entity.cy];
663 if (side === 'top') return [entity.cx, entity.y];
664 return [entity.cx, entity.y + entity.height];
665}
667const trunkGroups = [];
668const trunkAssignments = new Map();
669{
670 const fanIn = new Map();
671 const fanOut = new Map();
672 const resolvedSides = new Map();
673 const push = (map, key, entry) => {
674 const list = map.get(key) || [];
675 list.push(entry);
676 map.set(key, list);
677 };
678 for (const [index, relationship] of relationships.entries()) {
679 if (!relationshipAutoRouted(relationship)) continue;
680 const { fromSide, toSide } = inferredSides(relationship);
681 resolvedSides.set(relationship, { fromSide, toSide });
682 const styleKey = markerStyleOf(relationship);
683 push(fanIn, `${relationship.to}\u0000${toSide}\u0000${styleKey}`, { index, relationship });
684 push(fanOut, `${relationship.from}\u0000${fromSide}\u0000${styleKey}`, { index, relationship });
685 }
686 const taken = new Set();
687 const buildGroups = (map, role) => {
688 const grouped = [...map.values()]
689 .filter((list) => list.length >= 2)
690 .sort((left, right) => left[0].index - right[0].index);
691 for (const list of grouped) {
692 const members = list.filter((entry) => !taken.has(entry.relationship));
693 if (members.length < 2) continue;
694 const head = members[0].relationship;
695 const sides = resolvedSides.get(head);
696 const group = {
697 role,
698 entity: entities.get(role === 'in' ? head.to : head.from),
699 side: role === 'in' ? sides.toSide : sides.fromSide,
700 styleKey: markerStyleOf(head),
701 members: members.map((entry) => entry.relationship),
702 };
703 for (const entry of members) {
704 taken.add(entry.relationship);
705 trunkAssignments.set(entry.relationship, { group });
706 }
707 trunkGroups.push(group);
708 }
709 };
710 buildGroups(fanIn, 'in');
711 buildGroups(fanOut, 'out');
713 // One trunk coordinate per group, just outside the shared side. A coordinate
714 // close to an already chosen trunk snaps onto it, so fan-in and fan-out
715 // groups sharing one channel read as a single bus with drops on both sides.
716 const chosenLines = [];
717 for (const group of trunkGroups) {
718 group.axis = isHorizontalSide(group.side) ? 0 : 1;
719 const portAxis = group.axis === 0 ? 1 : 0;
720 const coordinates = group.members.map((relationship) => {
721 const port = ports.get(relationship)?.[group.role === 'in' ? 'to' : 'from'];
722 return (port || sideAnchor(group.entity, group.side))[portAxis];
723 });
724 const low = Math.min(...coordinates) - TRUNK_CAP;
725 const high = Math.max(...coordinates) + TRUNK_CAP;
726 const clearsSpan = (coordinate) => {
727 const segment = group.axis === 0
728 ? { start: [coordinate, low], end: [coordinate, high] }
729 : { start: [low, coordinate], end: [high, coordinate] };
730 return [...entities.values()].every((entity) => !segmentIntersectsRect(segment, entity));
731 };
732 const base = isHorizontalSide(group.side)
733 ? (group.side === 'left' ? group.entity.x : group.entity.x + group.entity.width)
734 : (group.side === 'top' ? group.entity.y : group.entity.y + group.entity.height);
735 const outward = group.side === 'left' || group.side === 'top' ? -1 : 1;
736 for (const offset of TRUNK_OFFSETS) {
737 const candidate = base + outward * offset;
738 if (!clearsSpan(candidate)) continue;
739 const near = chosenLines.find((line) => line.axis === group.axis
740 && line.styleKey === group.styleKey
741 && Math.abs(line.coordinate - candidate) < TRUNK_SNAP);
742 // A snapped coordinate serves another group's span; it must clear this
743 // one too, otherwise the next offset is tried.
744 if (near && !clearsSpan(near.coordinate)) continue;
745 // A differently styled trunk never shares a coordinate: the bus would
746 // have to render in one group's dash language, so the next offset is
747 // tried until the two lines are a lane apart.
748 const clashing = chosenLines.some((line) => line.axis === group.axis
749 && line.styleKey !== group.styleKey
750 && Math.abs(line.coordinate - candidate) < TRUNK_SNAP);
751 if (clashing) continue;
752 group.coordinate = near ? near.coordinate : candidate;
753 break;
754 }
755 if (group.coordinate === undefined) {
756 for (const relationship of group.members) trunkAssignments.delete(relationship);
757 continue;
758 }
759 chosenLines.push({ axis: group.axis, coordinate: group.coordinate, styleKey: group.styleKey });
760 }
761}
763function trunkCandidates(context) {
764 const candidate = trunkCandidateShape(context);
765 return candidate ? [candidate] : [];
766}
768function trunkCandidateShape({ conn, start, end, fromSide, toSide }) {
769 const assignment = trunkAssignments.get(conn);
770 if (!assignment) return null;
771 const { axis, coordinate } = assignment.group;
772 const sideOffset = (side) => (side === 'right' || side === 'bottom' ? 1 : -1);
773 if (axis === 0) {
774 const startHorizontal = isHorizontalSide(fromSide);
775 const endHorizontal = isHorizontalSide(toSide);
776 if (startHorizontal && endHorizontal) return [[coordinate, start[1]], [coordinate, end[1]]];
777 if (startHorizontal) {
778 const approachY = end[1] + sideOffset(toSide) * TRUNK_LEG;
779 return [[coordinate, start[1]], [coordinate, approachY], [end[0], approachY]];
780 }
781 if (endHorizontal) {
782 const exitY = start[1] + sideOffset(fromSide) * TRUNK_LEG;
783 return [[start[0], exitY], [coordinate, exitY], [coordinate, end[1]]];
784 }
785 return null;
786 }
787 const startHorizontal = isHorizontalSide(fromSide);
788 const endHorizontal = isHorizontalSide(toSide);
789 if (!startHorizontal && !endHorizontal) return [[start[0], coordinate], [end[0], coordinate]];
790 if (!startHorizontal) {
791 const approachX = end[0] + sideOffset(toSide) * TRUNK_LEG;
792 return [[start[0], coordinate], [approachX, coordinate], [approachX, end[1]]];
793 }
794 if (!endHorizontal) {
795 const exitX = start[0] + sideOffset(fromSide) * TRUNK_LEG;
796 return [[exitX, start[1]], [exitX, coordinate], [end[0], coordinate]];
797 }
798 return null;
799}
801// The rendered `d` skips each trunk stretch — one shared trunk path carries it
802// — while the logical points keep it, so every gate, label, and legend check
803// still reasons about the full route.
804const trunkRunCache = new Map();
805function relationshipTrunkRuns(relationship) {
806 const assignment = trunkAssignments.get(relationship);
807 if (!assignment) return [];
808 if (trunkRunCache.has(relationship)) return trunkRunCache.get(relationship);
809 const { axis, coordinate, role, side } = assignment.group;
810 // The readability sweep may move a member to another table side after the
811 // groups were formed. Such a route no longer leaves through the shared side,
812 // so it is drawn whole instead of losing a coincidental stretch to the bus.
813 if (connectionEndpointSide(relationship, role === 'in' ? 'target' : 'source') !== side) {
814 trunkRunCache.set(relationship, []);
815 return [];
816 }
817 const points = pathFor(relationship).points;
818 const runs = [];
819 for (let index = 0; index < points.length - 1; index += 1) {
820 const [a, b] = [points[index], points[index + 1]];
821 if (Math.abs(a[axis] - coordinate) > 0.5 || Math.abs(b[axis] - coordinate) > 0.5) continue;
822 const low = Math.min(a[1 - axis], b[1 - axis]);
823 const high = Math.max(a[1 - axis], b[1 - axis]);
824 if (high - low < 1) continue;
825 const last = runs[runs.length - 1];
826 if (last && last.endIndex === index) {
827 last.endIndex = index + 1;
828 last.high = high;
829 } else {
830 runs.push({ startIndex: index, endIndex: index + 1, low, high });
831 }
832 }
833 trunkRunCache.set(relationship, runs);
834 return runs;
835}
837function bundledRelationshipD(routed, runs) {
838 if (!runs.length) return routed.d;
839 const inRun = new Set();
840 for (const run of runs) {
841 for (let index = run.startIndex; index < run.endIndex; index += 1) inRun.add(index);
842 }
843 const pieces = [];
844 let current = [routed.points[0]];
845 for (let index = 0; index < routed.points.length - 1; index += 1) {
846 if (inRun.has(index)) {
847 pieces.push(current);
848 current = null;
849 continue;
850 }
851 if (current === null) current = [routed.points[index]];
852 current.push(routed.points[index + 1]);
853 }
854 if (current) pieces.push(current);
855 return pieces
856 .filter((piece) => piece.length >= 2)
857 .map((piece) => roundedPath(piece, 8))
858 .join(' ');
859}
861function bundledTrunkPaths() {
862 const clusters = new Map();
863 for (const relationship of relationships) {
864 const runs = relationshipTrunkRuns(relationship);
865 if (!runs.length) continue;
866 const { axis, coordinate } = trunkAssignments.get(relationship).group;
867 const styleKey = markerStyleOf(relationship);
868 // Style is part of the cluster key so differently styled groups can never
869 // share one trunk path even if their coordinates ever coincide.
870 const key = `${axis}:${coordinate}:${styleKey}`;
871 const list = clusters.get(key) || [];
872 for (const run of runs) {
873 list.push({ low: run.low, high: run.high, styleKey, relationship });
874 }
875 clusters.set(key, list);
876 }
877 const paths = [];
878 const bridged = [];
879 const routed = relationships.filter(renderableRelationship)
880 .map((relationship) => ({ relationship, points: pathFor(relationship).points, width: relationshipStrokeWidth(relationship) }));
881 for (const [key, runs] of clusters) {
882 const [axis, coordinate] = key.split(':').slice(0, 2).map(Number);
883 const members = new Set(runs.map((run) => run.relationship));
884 runs.sort((left, right) => left.low - right.low);
885 const merged = [];
886 for (const run of runs) {
887 const last = merged[merged.length - 1];
888 if (last && run.low <= last.high + 0.5) last.high = Math.max(last.high, run.high);
889 else merged.push({ ...run });
890 }
891 // Branches that interleave leave short uncovered stretches on an otherwise
892 // continuous bus. Bridge them when the bridged line stays clear of every
893 // entity; anything wider stays separate so no phantom corridor is drawn.
894 const segments = [];
895 for (const segment of merged) {
896 const last = segments[segments.length - 1];
897 const gap = last ? segment.low - last.high : Infinity;
898 if (last && gap > 0.5 && gap <= TRUNK_BRIDGE) {
899 const probe = axis === 0
900 ? { start: [coordinate, last.high], end: [coordinate, segment.low] }
901 : { start: [last.high, coordinate], end: [segment.low, coordinate] };
902 // 桥接不在任何逻辑关系中,必须检查它实际增加的墨迹。同簇成员可以共享总线;
903 // 无关路线和已补出的其他桥接一旦交叉、相接或重叠,就保留两段独立主干。
904 const box = {
905 x: probe.start[0], y: probe.start[1],
906 width: probe.end[0] - probe.start[0], height: probe.end[1] - probe.start[1],
907 };
908 // 两条线的半宽之和才是描边的接触边界,显式加粗关系也使用实际绘制宽度。
909 const conflicts = routed.some(({ relationship, points, width }) => !members.has(relationship)
910 && points.slice(1).some((end, index) => segmentIntersectsRect(
911 { start: points[index], end }, box, (DEFAULT_RELATIONSHIP_STROKE_WIDTH + width) / 2,
912 )))
913 || bridged.some((other) => segmentIntersectsRect(other, box, DEFAULT_RELATIONSHIP_STROKE_WIDTH));
914 if (!conflicts && ![...entities.values()].some((entity) => segmentIntersectsRect(probe, entity))) {
915 bridged.push(probe);
916 last.high = segment.high;
917 continue;
918 }
919 }
920 segments.push({ ...segment });
921 }
922 for (const segment of segments) {
923 const [cls] = arrowClassMap[segment.styleKey] || arrowClassMap.default;
924 const points = axis === 0
925 ? [[coordinate, segment.low], [coordinate, segment.high]]
926 : [[segment.low, coordinate], [segment.high, coordinate]];
927 paths.push(` <path data-er-trunk="" data-composition-points="${routePointsValue(points)}" d="${roundedPath(points, 8)}" class="${cls}" stroke-width="${DEFAULT_RELATIONSHIP_STROKE_WIDTH}"/>`);
928 }
929 }
930 return paths.join('\n');
931}
933// Bundled labels default to the source-side stub so they never sit on the
934// shared trunk, where they would read as annotating every branch at once.
935function defaultErLabelPoint(relationship) {
936 const points = pathFor(relationship).points;
937 if (!trunkAssignments.get(relationship)) return labelPoint(relationship, points);
938 return labelPoint({ ...relationship, labelSegment: relationship.labelSegment ?? 0 }, points);
939}
941const resolvedLabelPoints = new Map();
942function erLabelPoint(relationship) {
943 return resolvedLabelPoints.get(relationship) ?? defaultErLabelPoint(relationship);
944}
946// ---- Automatic label placement ----------------------------------------------
947// The default point is the midpoint of one segment, which knows nothing about
948// the tables, glyphs, or other routes around it: a vertical relationship put
949// its label on the source table's cardinality glyph, and a label in a busy gap
950// sat over a neighbouring table or channel. The shared bounded placer (the one
951// architecture and dataflow use) keeps every clear default and moves only a
952// colliding unpinned label to the nearest clear spot beside its own route.
953// Each relationship end reserves its cardinality glyph, and a bundled member's
954// shared trunk counts as another route, so a moved label never annotates the
955// bus or covers a crow's foot.
956// Half the glyph's 16-unit height plus the 2-unit overlap the shared placer
957// tolerates before it moves a label, so even a default spot keeps clear ink.
958const GLYPH_HALF_HEIGHT = 10;
959function cardinalityGlyphBox(point, side) {
960 const [x, y] = point;
961 const reach = MARKER_GLYPH_WIDTH;
962 if (side === 'left') return { x: x - reach, y: y - GLYPH_HALF_HEIGHT, width: reach, height: GLYPH_HALF_HEIGHT * 2 };
963 if (side === 'right') return { x, y: y - GLYPH_HALF_HEIGHT, width: reach, height: GLYPH_HALF_HEIGHT * 2 };
964 if (side === 'top') return { x: x - GLYPH_HALF_HEIGHT, y: y - reach, width: GLYPH_HALF_HEIGHT * 2, height: reach };
965 return { x: x - GLYPH_HALF_HEIGHT, y, width: GLYPH_HALF_HEIGHT * 2, height: reach };
966}
968{
969 const placeable = relationships.filter(renderableRelationship);
970 const labels = placeable.filter((relationship) => relationship.label).map((relationship) => {
971 const [lx, ly] = defaultErLabelPoint(relationship);
972 const width = relationshipLabelWidth(relationship.label);
973 return {
974 relation: relationship, relationIndex: relationships.indexOf(relationship), label: relationship.label,
975 x: lx - width / 2, y: ly - 10, width, height: 14, lx, ly,
976 };
977 });
978 if (labels.length) {
979 const routes = [];
980 const glyphs = [];
981 placeable.forEach((relationship) => {
982 const relationIndex = relationships.indexOf(relationship);
983 const { points } = pathFor(relationship);
984 glyphs.push(cardinalityGlyphBox(points[0], connectionEndpointSide(relationship, 'source')));
985 glyphs.push(cardinalityGlyphBox(points.at(-1), connectionEndpointSide(relationship, 'target')));
986 const runs = relationshipTrunkRuns(relationship);
987 if (!runs.length) {
988 routes.push({ relationIndex, points });
989 return;
990 }
991 const inRun = new Set(runs.flatMap((run) => Array.from({ length: run.endIndex - run.startIndex }, (_, step) => run.startIndex + step)));
992 let piece = [points[0]];
993 for (let index = 0; index < points.length - 1; index += 1) {
994 if (inRun.has(index)) {
995 if (piece.length > 1) routes.push({ relationIndex, points: piece });
996 routes.push({ relationIndex: -1 - routes.length, points: [points[index], points[index + 1]] });
997 piece = [points[index + 1]];
998 } else {
999 piece.push(points[index + 1]);
1000 }
1001 }
1002 if (piece.length > 1) routes.push({ relationIndex, points: piece });
1003 });
1004 const titles = domainGroups.map(({ tag, members }) => {
1005 const caption = domainBandCaption(domainBandBox(members));
1006 return { x: caption.x - 2, y: caption.y - 10, width: textUnits(tag) * 9 * 0.65 + 4, height: 13 };
1007 });
1008 const footprint = legendFootprint(erLegendEntries, { width: Math.max(1, viewBox[0] - layout.margin * 2) });
1009 const placed = placeAutomaticLabels({
1010 labels,
1011 routes,
1012 components: [...entities.values(), ...glyphs],
1013 titles,
1014 viewBox,
1015 placementBottom: viewBox[1] - layout.legendH - footprint.extraHeight,
1016 keepFallbackNearRoute: true,
1017 });
1018 placed.forEach((rect, index) => {
1019 if (rect !== labels[index]) resolvedLabelPoints.set(rect.relation, [rect.lx, rect.ly]);
1020 });
1021 }
1022}
1024function channelFallback({ conn, start, end, fromSide, toSide }) {
1025 const horizontalPorts = fromSide === 'left' || fromSide === 'right';
1026 const verticalPorts = fromSide === 'top' || fromSide === 'bottom';
1027 if (horizontalPorts === verticalPorts) return null;
1028 const axis = horizontalPorts ? 0 : 1;
1029 const toPoints = (channel) => (horizontalPorts
1030 ? [[channel, start[1]], [channel, end[1]]]
1031 : [[start[0], channel], [end[0], channel]]);
1032 return { axis, toPoints, low: Math.min(start[axis], end[axis]), high: Math.max(start[axis], end[axis]) };
1033}
1035function laneCandidates(context) {
1036 const lane = laneOffsets.get(context.conn);
1037 if (lane === undefined) return [];
1038 const channel = channelFallback(context);
1039 if (!channel) return [];
1040 const base = (context.start[channel.axis] + context.end[channel.axis]) / 2;
1041 return [channel.toPoints(base + lane * LANE_STEP)];
1042}
1044// ---- Validation --------------------------------------------------------------
1045function renderableRelationship(relationship) {
1046 return entities.has(relationship.from) && entities.has(relationship.to);
1047}
1049function rowTextWidths(attribute) {
1050 const name = textUnits(attribute.name) * nodeTextFit.widthFactor;
1051 const type = attribute.type ? textUnits(attribute.type) * nodeTextFit.widthFactor : 0;
1052 return { name, type };
1053}
1055// The header carries one string: the table title. A domain that earns a band is
1056// named by the band, and a tagged table the renderer cannot band is refused
1057// outright, so nothing depends on the header to carry a name.
1058function entityHeader(entity) {
1059 const titleInset = 30;
1060 const titleWidth = entity.width - titleInset - layout.padX;
1061 const labelFont = fittedNodeFontSize(entity.label, titleWidth + nodeTextFit.horizontalPadding, layout.headerFont, layout.headerFontMinimum);
1062 const labelWidth = textUnits(entity.label) * labelFont * nodeTextFit.widthFactor;
1063 if (labelWidth > titleWidth) return { problem: true, titleWidth };
1064 // The title is centered on the table's own center line. It is measured against
1065 // a narrower interval than the box — the sigil porch on the left, the padding
1066 // on the right — so a title that nearly fills that interval is held inside it
1067 // rather than left to reach either edge.
1068 const leftEdge = entity.x + titleInset;
1069 const rightEdge = leftEdge + titleWidth;
1070 const labelX = Math.round(Math.min(Math.max(entity.x + entity.width / 2, leftEdge + labelWidth / 2), rightEdge - labelWidth / 2) * 10) / 10;
1071 return { labelFont, labelY: 19, labelX };
1072}
1074function validateEr() {
1075 const problems = [];
1076 // A self-relationship is reported once below; route checks skip it so they
1077 // do not suggest side or route repairs for a line that is never drawn.
1078 const routableRelationships = relationships.map((relationship) => (
1079 relationship.from === relationship.to ? null : relationship));
1080 const headerDetails = [];
1081 const domainDetails = [];
1082 const attributeDetails = [];
1083 for (const entity of entities.values()) {
1084 const header = entityHeader(entity);
1085 if (!header.problem) continue;
1086 const message = `Entity "${entity.id}" title does not fit the header at a readable size — widen the entity or shorten the title.`;
1087 problems.push(message);
1088 headerDetails.push({
1089 code: 'erd/header-text-capacity', severity: 'error', message,
1090 subject: { diagramType: 'erd', entityId: entity.id },
1091 evidence: { label: entity.label, availableWidth: header.titleWidth, headerHeight: metrics.headerH },
1092 supportedFixes: ['Widen the entity.', 'Shorten the title.'],
1093 });
1094 }
1095 for (const domain of unlabelableDomains) {
1096 // A shared cell is already reported by the grid placement check, with the
1097 // pair that has to move; blaming the block as well would give the author two
1098 // fixes for one mistake.
1099 if (domain.reason === 'shared-cell') continue;
1100 const members = domain.members.map((entity) => entity.id);
1101 const listed = members.map((id) => `"${id}"`).join(', ');
1102 const offGrid = domain.offGrid.map((id) => `"${id}"`).join(', ');
1103 const message = domain.reason === 'no-grid'
1104 ? `Domain "${domain.tag}" is named but cannot be drawn: ${offGrid} have no grid cells, and a band is measured from them. Give every member row/col cells, or drop the tag.`
1105 : `Domain "${domain.tag}" is named but cannot be drawn: ${listed} do not fill a solid rectangle of grid cells (a row, a column, or a filled block — not an L or diagonal), and a band around them would enclose cells the domain does not own. Move them into one block, or split the tag.`;
1106 problems.push(message);
1107 domainDetails.push({
1108 code: 'erd/domain-not-drawn', severity: 'error', message,
1109 subject: { diagramType: 'erd', entityId: members[0], domain: domain.tag },
1110 evidence: { domain: domain.tag, entities: members, reason: domain.reason, offGrid: domain.offGrid },
1111 supportedFixes: domain.reason === 'no-grid'
1112 ? ['Give every domain member row/col cells.', 'Drop the tag to group nothing.']
1113 : ['Give the domain members one solid rectangle of grid cells (a row, a column, or a filled block — not an L).', 'Split the tag so each domain can be drawn as a band.', 'Drop the tag to group nothing.'],
1114 });
1115 }
1116 // The band draws the domain name, so a band the canvas cannot show would put
1117 // the domain back to unnamed. The caption is what has to land inside; it sits
1118 // a few units into the band from its top-left corner.
1119 for (const { tag, members } of domainGroups) {
1120 const caption = domainBandCaption(domainBandBox(members));
1121 if (caption.x >= 4 && caption.y >= 4 && caption.y <= viewBox[1] - 4 && caption.x <= viewBox[0] - 4) continue;
1122 const listed = members.map((entity) => `"${entity.id}"`).join(', ');
1123 const message = `Domain "${tag}" is named but cannot be drawn: the canvas does not reach the band caption for ${listed} at (${caption.x}, ${caption.y}). Move the domain clear of the canvas edge, or drop the tag.`;
1124 problems.push(message);
1125 domainDetails.push({
1126 code: 'erd/domain-not-drawn', severity: 'error', message,
1127 subject: { diagramType: 'erd', entityId: members[0].id, domain: tag },
1128 evidence: { domain: tag, entities: members.map((entity) => entity.id), reason: 'off-canvas', caption, viewBox },
1129 supportedFixes: ['Move the domain clear of the canvas edge.', 'Drop the tag to group nothing.'],
1130 });
1131 }
1132 // A band claims the box it covers. With gaps smaller than the band padding the
1133 // box reaches a table the domain does not own, which is the same false claim as
1134 // a band drawn across a gap.
1135 for (const { tag, members } of domainGroups) {
1136 const memberIds = new Set(members.map((entity) => entity.id));
1137 const box = domainBandBox(members);
1138 const intruder = [...entities.values()].find((entity) => !memberIds.has(entity.id)
1139 && entity.x < box.x + box.width && entity.x + entity.width > box.x
1140 && entity.y < box.y + box.height && entity.y + entity.height > box.y);
1141 if (!intruder) continue;
1142 const listed = members.map((entity) => `"${entity.id}"`).join(', ');
1143 const message = `Domain "${tag}" is named but cannot be drawn: its band would cover the unrelated table "${intruder.id}". Widen layout.gapX/gapY past the band padding, or split the tag.`;
1144 problems.push(message);
1145 domainDetails.push({
1146 code: 'erd/domain-not-drawn', severity: 'error', message,
1147 subject: { diagramType: 'erd', entityId: members[0].id, domain: tag },
1148 evidence: { domain: tag, entities: members.map((entity) => entity.id), reason: 'band-covers-entity', band: box, coveredEntity: intruder.id },
1149 supportedFixes: ['Widen layout.gapX/gapY past the band padding.', 'Split the tag so each band covers only its own tables.'],
1150 });
1151 }
1152 validateErGridPlacement(er, grid, bands, problems);
1153 const seenEntityIds = new Set();
1154 for (const [entityIndex, entity] of asArray(er.entities).entries()) {
1155 if (seenEntityIds.has(entity.id)) problems.push(`Entity ids must be unique; "${entity.id}" is declared twice.`);
1156 seenEntityIds.add(entity.id);
1158 if (!Number.isFinite(entity.width) && entity.width !== undefined) {
1159 problems.push(`Entity "${entity.id}" width must be a finite number.`);
1160 }
1161 const seenColumns = new Set();
1162 for (const [attributeIndex, attribute] of asArray(entity.attributes).entries()) {
1163 if (seenColumns.has(attribute.name)) {
1164 problems.push(`Entity "${entity.id}" declares attribute "${attribute.name}" twice.`);
1165 }
1166 seenColumns.add(attribute.name);
1167 const { name: nameUnits, type: typeUnits } = rowTextWidths(attribute);
1168 // entity.width is optional; validateEr sees the authored object, so the
1169 // overflow check must use the resolved width or an omitted width turns the
1170 // available space into NaN and silently skips the diagnostic.
1171 const available = entityWidth(entity) - layout.padX * 2 - keyColumnWidth(entity);
1172 const needed = nameUnits * layout.rowFont + (typeUnits ? typeUnits * layout.typeFont + 8 : 0);
1173 if (needed > available) {
1174 const requiredEntityWidth = Math.ceil(needed + layout.padX * 2 + keyColumnWidth(entity));
1175 const fix = `For this attribute row's text capacity, set entities[${entityIndex}].width to at least ${requiredEntityWidth}px, preserving the attribute name, type and key roles.`;
1176 const message = `Entity "${entity.id}" attribute ${attributeIndex} "${attribute.name}" needs ${Math.ceil(needed)}px of text but only ${Math.floor(available)}px is available — ${fix}`;
1177 problems.push(message);
1178 attributeDetails.push({
1179 code: 'erd/attribute-text-capacity', severity: 'error', message,
1180 subject: { diagramType: 'erd', entityId: entity.id, attributeIndex, attributeName: attribute.name },
1181 evidence: {
1182 entityWidth: entityWidth(entity), requiredEntityWidth,
1183 availableTextWidth: available, requiredTextWidth: needed,
1184 paddingWidth: layout.padX * 2, keyColumnWidth: keyColumnWidth(entity),
1185 nameFontSize: layout.rowFont, typeFontSize: layout.typeFont,
1186 },
1187 supportedFixes: [fix],
1188 });
1189 }
1190 }
1191 }
1193 for (const entity of entities.values()) {
1194 for (const other of entities.values()) {
1195 if (entity.id >= other.id) continue;
1196 if (!rectsOverlap(entity, other, 0)) continue;
1197 problems.push(`Entity "${entity.id}" overlaps entity "${other.id}".\n${suggestComponentSeparation(entity, other)}`);
1198 }
1199 }
1201 for (const [index, relationship] of relationships.entries()) {
1202 const id = relationship.id ? ` id "${relationship.id}"` : '';
1203 if (!entities.has(relationship.from)) {
1204 problems.push(`Relationship ${index}${id} references unknown source entity "${relationship.from}".`);
1205 continue;
1206 }
1207 if (!entities.has(relationship.to)) {
1208 problems.push(`Relationship ${index}${id} references unknown target entity "${relationship.to}".`);
1209 continue;
1210 }
1211 if (relationship.from === relationship.to) {
1212 problems.push(`Relationship ${index}${id} connects entity "${relationship.from}" to itself — draw it between two entities or describe the self-reference in a card.`);
1213 continue;
1214 }
1215 const routed = pathFor(relationship);
1216 const [start, end] = [routed.points[0], routed.points[routed.points.length - 1]];
1217 const distance = Math.hypot(end[0] - start[0], end[1] - start[1]);
1218 if (distance < layout.minRelationshipLength) {
1219 problems.push(`Relationship ${index}${id} "${relationship.from}" -> "${relationship.to}" is too short (${Math.round(distance)}px; minimum ${layout.minRelationshipLength}px) — place the entities farther apart.`);
1220 }
1221 }
1223 for (const [index, entity] of asArray(er.entities).entries()) {
1224 for (const [attributeIndex, attribute] of asArray(entity.attributes).entries()) {
1225 if (typeof attribute.references !== 'string') continue;
1226 const [targetId, targetColumn] = attribute.references.split('.');
1227 const target = entities.get(targetId);
1228 if (!target) {
1229 problems.push(`Entity "${entity.id}" attribute ${attributeIndex} "${attribute.name}" references unknown entity "${targetId}" in "${attribute.references}".`);
1230 continue;
1231 }
1232 if (!asArray(target.attributes).some((candidate) => candidate.name === targetColumn)) {
1233 problems.push(`Entity "${entity.id}" attribute ${attributeIndex} "${attribute.name}" references unknown attribute "${attribute.references}" — "${targetId}" has no attribute "${targetColumn}".`);
1234 }
1235 }
1236 }
1238 // A busy fan-in can leave less room than a glyph is tall. The marker spread
1239 // is a cap, not a floor: `automaticPortSpread` spaces a side by
1240 // `min(maxSpacing, (extent - 2 x gutter) / (n - 1))`, so a short table with
1241 // several relationships draws overlapping feet and bars. The gate cannot see
1242 // that (the routes are legal), so the renderer names it here.
1243 const portSpacingDetails = [];
1244 for (const [entityId, entity] of entities) {
1245 const sides = new Map();
1246 for (const relationship of relationships) {
1247 if (!relationshipAutoRouted(relationship)) continue;
1248 const { fromSide, toSide } = inferredSides(relationship);
1249 if (relationship.from === entityId) sides.set(fromSide, (sides.get(fromSide) || 0) + 1);
1250 if (relationship.to === entityId) sides.set(toSide, (sides.get(toSide) || 0) + 1);
1251 }
1252 for (const [side, count] of sides) {
1253 if (count < 2) continue;
1254 const extent = side === 'left' || side === 'right' ? entity.height : entity.width;
1255 const spacing = Math.min(ERD_PORT_SPACING, Math.max(0, extent - 32) / (count - 1));
1256 if (spacing >= MARKER_HEIGHT) continue;
1257 const message = `Entity "${entityId}" shares its ${side} side with ${count} relationship ends: their ports would sit ${Math.round(spacing)}px apart, under the ${MARKER_HEIGHT}px a cardinality glyph is tall, so the symbols overlap. Give the table more rows so that side is taller, spread the relationships across sides with room, or reduce the fan-in.`;
1258 problems.push(message);
1259 portSpacingDetails.push({
1260 code: 'layout/marker-capacity',
1261 severity: 'error',
1262 message,
1263 subject: { diagramType: 'erd', path: `/entities/${entityId}` },
1264 evidence: { entity: entityId, side, relationshipEnds: count, spacingPx: Math.round(spacing), markerHeightPx: MARKER_HEIGHT, sideExtentPx: extent },
1265 supportedFixes: [
1266 `add fields to "${entityId}" so its ${side} side is taller`,
1267 'spread the relationships across the sides that have room',
1268 'reduce the fan-in by modelling the shared parent instead of pointing every child at this table',
1269 ],
1270 });
1271 }
1272 }
1274 problems.push(...cleanEndpointSideProblems({
1275 relations: routableRelationships,
1276 endpointIds: new Set(entities.keys()),
1277 pathFor,
1278 diagramType: 'erd',
1279 relationCollection: 'relationships',
1280 fromSideFor: (relationship) => connectionEndpointSide(relationship, 'source'),
1281 toSideFor: (relationship) => connectionEndpointSide(relationship, 'target'),
1282 routeHint: 'keep automatic routing so the renderer can use a side-aware bridge, or set truthful fromSide/toSide',
1283 }));
1284 problems.push(...cleanFlowProblems({
1285 relations: routableRelationships,
1286 obstacles: entities.values(),
1287 pathFor,
1288 diagramType: 'erd',
1289 relationCollection: 'relationships',
1290 obstacleKind: 'entity',
1291 routeHint: 'adjust fromSide/toSide, set route/via, or move the entity',
1292 }));
1293 problems.push(...cleanCrossingProblems({
1294 relations: routableRelationships,
1295 endpointIds: new Set(entities.keys()),
1296 pathFor,
1297 diagramType: 'erd',
1298 relationCollection: 'relationships',
1299 profile: er.meta?.quality_profile,
1300 routeHint: 'move the entities so unrelated relationships use separate corridors — put a junction beside both parents inside one solid domain rectangle',
1301 }));
1302 problems.push(...cleanAmbiguousCorridorProblems({
1303 relations: routableRelationships,
1304 endpointIds: new Set(entities.keys()),
1305 pathFor,
1306 diagramType: 'erd',
1307 relationCollection: 'relationships',
1308 profile: er.meta?.quality_profile,
1309 routeHint: 'give the relationships different fromSide/toSide targets, or move an entity so their corridors differ',
1310 }));
1311 problems.push(...cleanRouteRhythmProblems({
1312 relations: routableRelationships,
1313 endpointIds: new Set(entities.keys()),
1314 pathFor,
1315 diagramType: 'erd',
1316 relationCollection: 'relationships',
1317 profile: er.meta?.quality_profile,
1318 routeHint: 'widen the gap between bands so every turn has room to read',
1319 }));
1321 const labelRects = [];
1322 for (const [index, relationship] of relationships.entries()) {
1323 if (!relationship.label || !renderableRelationship(relationship)) continue;
1324 const [lx, ly] = erLabelPoint(relationship);
1325 labelRects.push({
1326 relation: relationship,
1327 relationIndex: index,
1328 label: relationship.label,
1329 ...relationshipLabelBox(relationship),
1330 lx,
1331 ly,
1332 });
1333 }
1334 for (const rect of labelRects) {
1335 for (const entity of entities.values()) {
1336 if (!rectsOverlap(rect, entity, -2)) continue;
1337 problems.push(`Label "${rect.label}" overlaps entity "${entity.id}" — adjust labelDx/labelDy/labelSegment or set labelAt.\n${suggestLabelObstacleFix(rect, rect.lx, rect.ly, entity, 'entity')}`);
1338 }
1339 }
1340 problems.push(...cleanLabelRouteClearanceProblems({
1341 relations: routableRelationships,
1342 labels: labelRects,
1343 endpointIds: new Set(entities.keys()),
1344 pathFor,
1345 diagramType: 'erd',
1346 relationCollection: 'relationships',
1347 profile: er.meta?.quality_profile,
1348 }));
1350 if (problems.length) {
1351 throwDiagnosticProblems('Entity-relationship layout validation failed', problems, {
1352 code: 'layout/constraint',
1353 subject: { diagramType: 'erd' },
1354 diagnostics: [...headerDetails, ...domainDetails, ...portSpacingDetails, ...attributeDetails],
1355 });
1356 }
1357}
1359function buildLayoutReport() {
1360 return {
1361 diagram_type: 'erd',
1362 viewBox,
1363 layout,
1364 entities: [...entities.values()].map(entityBox),
1365 relationships: relationships
1366 .filter(renderableRelationship)
1367 .map((relationship) => ({
1368 ...relationshipPath(relationship, pathFor(relationship), relationship.labelAt || resolvedLabelPoints.get(relationship)),
1369 fromCardinality: cardinalityOf(relationship, 'from'),
1370 toCardinality: cardinalityOf(relationship, 'to'),
1371 })),
1372 };
1373}
1375// ---- Rendering ---------------------------------------------------------------
1376function renderEntityColumnRows(entity) {
1377 const keyColumn = keyColumnWidth(entity);
1378 return entity.attributes.map((attribute, index) => {
1379 const rowTop = entity.y + metrics.headerH + index * metrics.rowH;
1380 const baseline = rowTop + metrics.rowH - 5;
1381 // The row carries `data-detail="context"` as a group, and each text repeats
1382 // it: the Viewer's reader sizes the diagram from the texts that declare the
1383 // level themselves, so a field that is only context inside a group would be
1384 // shrunk past the readable floor on a tall schema.
1385 const keyGlyph = renderKeyGlyphs(attribute, entity.x + layout.padX, baseline);
1386 const nameX = entity.x + layout.padX + keyColumn;
1387 const typeSpace = attribute.type ? Math.min(96, textUnits(attribute.type) * layout.typeFont * nodeTextFit.widthFactor) : 0;
1388 const nameFontSize = fittedNodeFontSize(
1389 attribute.name,
1390 Math.max(40, entity.x + entity.width - layout.padX - typeSpace - nameX),
1391 layout.rowFont,
1392 layout.rowFontMinimum,
1393 );
1394 const type = attribute.type
1395 ? `<text data-detail="context" x="${entity.x + entity.width - layout.padX}" y="${baseline}" class="er-type" font-size="${layout.typeFont}" text-anchor="end">${esc(attribute.type)}</text>`
1396 : '';
1397 const zebra = index % 2
1398 ? `<rect x="${entity.x + 4}" y="${rowTop + 1}" width="${entity.width - 8}" height="${metrics.rowH - 2}" rx="4" class="er-zebra"/>`
1399 : '';
1400 const primary = keyRoles(attribute).includes('pk');
1401 return ` <g data-detail="context" data-er-row="${index}">
1402 <line x1="${entity.x}" y1="${rowTop}" x2="${entity.x + entity.width}" y2="${rowTop}" class="c-grid er-row-rule" stroke-width="0.5"/>
1403 ${zebra}
1404 ${keyGlyph}
1405 <text data-detail="context" x="${nameX}" y="${baseline}" class="t-primary${primary ? ' er-name-pk' : ''}" font-size="${nameFontSize}">${esc(attribute.name)}</text>
1406 ${type}
1407 </g>`.replace(/[ \t]+$/gm, '');
1408 }).join('\n');
1409}
1411function renderEntity(entity) {
1412 const header = entity.y + metrics.headerH;
1413 const { labelFont, labelY, labelX } = entityHeader(entity);
1414 const passport = {
1415 kind: 'database',
1416 sublabel: entity.sublabel,
1417 tag: entity.tag,
1418 context: i18nText(er.meta.locale, 'node.context.erd'),
1419 };
1420 const rows = renderEntityColumnRows(entity);
1421 const r = 8;
1422 return ` <g ${focusNodeAttrs(entity.id, entity.label, passport, er.meta.locale)} class="${hueClassOf(entity.tag)}">
1423 ${focusNodeTitle(entity.label, passport)}
1424 <rect x="${entity.x}" y="${entity.y}" width="${entity.width}" height="${entity.height}" rx="${r}" class="c-mask"/>
1425 <rect x="${entity.x}" y="${entity.y}" width="${entity.width}" height="${entity.height}" rx="${r}" class="c-database er-table"${animateAttr(er.meta, 'node', entitySteps.get(entity.id))} stroke-width="1.2"/>
1426 <path class="er-table-header" d="M ${entity.x + r} ${entity.y} H ${entity.x + entity.width - r} A ${r} ${r} 0 0 1 ${entity.x + entity.width} ${entity.y + r} V ${header} H ${entity.x} V ${entity.y + r} A ${r} ${r} 0 0 1 ${entity.x + r} ${entity.y} Z"/>
1427 <path class="er-table-cap" d="M ${entity.x + 12} ${entity.y + 1.4} H ${entity.x + entity.width - 12}" stroke-width="2.6" stroke-linecap="round" fill="none"/>
1428 ${renderSemanticSigil('database', { x: entity.x + 8, y: entity.y + 7, size: 14 })}
1429 <text data-node-label="" x="${labelX}" y="${entity.y + labelY}" class="t-primary er-title" font-size="${labelFont}" font-weight="700" text-anchor="middle">${esc(entity.label)}</text>
1430 <line x1="${entity.x}" y1="${header}" x2="${entity.x + entity.width}" y2="${header}" class="c-grid er-header-rule" stroke-width="1"/>
1431${rows}
1432 </g>`;
1433}
1435function renderRelationshipPath(relationship, index) {
1436 const [cls] = arrowClassMap[markerStyleOf(relationship)] || arrowClassMap.default;
1437 const routed = pathFor(relationship);
1438 const d = bundledRelationshipD(routed, relationshipTrunkRuns(relationship));
1439 const strokeWidth = relationshipStrokeWidth(relationship);
1440 return ` <path ${focusEdgeAttrs(relationship.from, relationship.to, relationship.label, index, relationship.id)} data-er-ends="${relationshipEnds(relationship).join(' ')}" data-composition-points="${routePointsValue(routed.points)}" d="${d}" class="${cls}"${animateAttr(er.meta, 'edge', index)} stroke-width="${strokeWidth}" marker-start="url(#${cardinalityMarkerId(relationship, 'from')})" marker-end="url(#${cardinalityMarkerId(relationship, 'to')})"/>`;
1441}
1443function renderRelationshipLabel(relationship, index) {
1444 if (!relationship.label) return '';
1445 const [lx, ly] = erLabelPoint(relationship);
1446 const box = relationshipLabelBox(relationship);
1447 return ` <g data-detail="context" ${focusEdgeAttrs(relationship.from, relationship.to, relationship.label, index, relationship.id)} data-er-ends="${relationshipEnds(relationship).join(' ')}">
1448 <rect x="${box.x}" y="${box.y}" width="${box.width}" height="${box.height}" rx="3" class="c-mask"/>
1449 <text x="${lx}" y="${ly}" class="${variantAccent(relationship.variant)}" font-size="${RELATIONSHIP_LABEL_FONT}" text-anchor="middle">${esc(relationship.label)}</text>
1450 </g>`;
1451}
1453// A key swatch is the badge a field row draws. A relationship swatch is a short
1454// route meeting a table edge, carrying the marker's own glyph, ink, and weight,
1455// so the legend shows the symbol in the context the diagram draws it.
1456function legendSwatch(entry) {
1457 const x = entry.x + entry.lead;
1458 const centerY = entry.baseline - 4;
1459 const hit = `<rect class="er-legend-hit" tabindex="0" role="img" aria-label="${esc(entry.label)}" x="${x - 6}" y="${entry.baseline - 15}" width="${entry.width - entry.lead + 12}" height="22" rx="11"/>`;
1460 const divider = entry.lead && entry.x > layout.margin
1461 ? `<line x1="${entry.x - 4}" y1="${centerY - 8}" x2="${entry.x - 4}" y2="${centerY + 8}" class="er-legend-divider" stroke-width="1"/>`
1462 : '';
1463 if (KEY_KINDS.has(entry.kind)) {
1464 return `${hit}<rect x="${x}" y="${entry.baseline - 10}" width="${LEGEND_KEY_SWATCH}" height="13" rx="3" class="er-key-badge" data-er-badge="${entry.kind}"/><text x="${x + LEGEND_KEY_SWATCH / 2}" y="${entry.baseline}" class="${KEY_ACCENT[entry.kind]}" text-anchor="middle" font-size="${layout.keyFont}" font-weight="600">${esc(entry.kind.toUpperCase())}</text>`;
1465 }
1466 const edgeX = x + LEGEND_END_SWATCH - 2;
1467 const glyph = cardinalityGlyphParts({
1468 cardinality: entry.kind === 'optional' ? null : entry.kind,
1469 optional: entry.kind === 'optional',
1470 });
1471 return `${divider}${hit}<line x1="${x}" y1="${centerY}" x2="${edgeX}" y2="${centerY}" class="er-legend-route" stroke-width="1.5"/><line x1="${edgeX}" y1="${centerY - 7}" x2="${edgeX}" y2="${centerY + 7}" class="er-legend-edge" stroke-width="2"/><g class="er-legend-glyph" transform="translate(${edgeX - MARKER_TOE_X} ${centerY - 8})" style="stroke: var(--text-muted)" stroke-width="1.75" fill="none">${glyph.join('')}</g>`;
1472}
1474// The ring a highlighted badge takes, in the ink of the role's own text.
1475const KEY_RING = { pk: 'var(--database-stroke)', fk: 'var(--messagebus-stroke)', uk: 'var(--text-muted)' };
1477const LEGEND_HOVER_RULES = [
1478 ...Object.entries(KEY_RING).map(([kind, ring]) => ` svg[data-erd-ui="cards"]:has([data-legend-semantic-kind="${kind}"]:is(:hover, :focus-within)) [data-er-row]:not(:has([data-er-badge="${kind}"])) { opacity: .22; }
1479 svg[data-erd-ui="cards"]:has([data-legend-semantic-kind="${kind}"]:is(:hover, :focus-within)) [data-er-row] [data-er-badge="${kind}"] { stroke: ${ring}; stroke-width: 1.25; }`),
1480 // Most schemas carry one and many on every route, so dimming alone would show
1481 // nothing: the matching glyph itself takes the accent ink wherever it is drawn.
1482 ...Object.entries({ one: '[id^="er-one"]', many: '[id^="er-many"]', optional: '[id*="-optional"]' }).map(([kind, marker]) => ` svg[data-erd-ui="cards"]:has([data-legend-semantic-kind="${kind}"]:is(:hover, :focus-within)) [data-er-ends]:not([data-er-ends~="${kind}"]) { opacity: .12; }
1483 svg[data-erd-ui="cards"]:has([data-legend-semantic-kind="${kind}"]:is(:hover, :focus-within)) marker${marker} { stroke: var(--database-stroke) !important; stroke-width: 2.25; }`),
1484].join('\n');
1486function renderLegend() {
1487 const relationshipObstacles = relationshipLegendObstacles(relationships, {
1488 pointsFor: (relationship) => (renderableRelationship(relationship) ? pathFor(relationship).points : []),
1489 labelRectFor: (relationship) => (
1490 relationship.label && renderableRelationship(relationship)
1491 ? relationshipLabelBox(relationship)
1492 : null
1493 ),
1494 });
1495 return renderResolvedLegend({
1496 entries: erLegendEntries,
1497 locale: er.meta.locale,
1498 layout: legendLayout(viewBox[0], viewBox[1], { obstacles: relationshipObstacles }),
1499 renderSwatch: legendSwatch,
1500 labelClass: 't-primary',
1501 labelWeight: 600,
1502 });
1503}
1505function renderSvg() {
1506 // A schema with more tables than one screen holds is a normal ERD, and the
1507 // reader asked for readable fields over a squeezed canvas: an automatic
1508 // canvas declares its height as intrinsic so the desktop reader scrolls
1509 // instead of shrinking the tables. An authored meta.viewBox stays
1510 // authoritative and keeps the established Viewer contract.
1511 const readerFit = er.meta?.viewBox ? '' : ' data-reader-fit="intrinsic-height"';
1512 const readerMinimumText = er.meta?.viewBox ? '' : ' data-reader-min-text="7.5"';
1513 return ` <svg viewBox="0 0 ${viewBox[0]} ${viewBox[1]}" ${svgRootAttrs(er.meta)} data-erd-ui="cards"${readerFit}${readerMinimumText}>
1514${svgAccessibleText(er.meta, 'erd')}
1515${renderDefinitions(renderCardinalityDefs())}
1516 <style>
1517${DOMAIN_HUES.map((hue) => ` svg[data-erd-ui="cards"] .er-hue-${hue} { --er-hue: var(--${hue}-stroke); --er-wash: var(--${hue}-fill); }`).join('\n')}
1518 svg[data-erd-ui="cards"] .er-band { fill: color-mix(in srgb, var(--er-hue) 4%, transparent); stroke: color-mix(in srgb, var(--er-hue) 20%, transparent); stroke-dasharray: none; }
1519 svg[data-erd-ui="cards"] .er-band-pill { fill: var(--er-wash); stroke: color-mix(in srgb, var(--er-hue) 30%, transparent); stroke-width: .8; }
1520 svg[data-erd-ui="cards"] .er-band-label { fill: var(--er-hue); letter-spacing: .08em; text-transform: uppercase; }
1521 svg[data-erd-ui="cards"] .er-table { fill: var(--mask); stroke: color-mix(in srgb, var(--er-hue) 34%, var(--lane-stroke)); }
1522 svg[data-erd-ui="cards"] .er-table-header { fill: var(--er-wash); stroke: none; }
1523 svg[data-erd-ui="cards"] .er-table-cap { stroke: var(--er-hue); pointer-events: none; }
1524 svg[data-erd-ui="cards"] .er-header-rule { stroke: color-mix(in srgb, var(--er-hue) 30%, var(--lane-stroke)); }
1525 svg[data-erd-ui="cards"] [class*="er-hue-"] .semantic-sigil { color: var(--er-hue); }
1526 svg[data-erd-ui="cards"] .er-row-rule { stroke-opacity: 0; }
1527 svg[data-erd-ui="cards"] .er-zebra { fill: color-mix(in srgb, var(--er-hue) 6%, transparent); }
1528 svg[data-erd-ui="cards"] .er-name-pk { font-weight: 700; }
1529 svg[data-erd-ui="cards"] .er-type { fill: var(--text-muted); opacity: .86; }
1530 svg[data-erd-ui="cards"] .er-key-badge { fill: var(--database-fill); stroke: none; }
1531 svg[data-erd-ui="cards"] .er-key-badge[data-er-badge="fk"] { fill: var(--messagebus-fill); }
1532 svg[data-erd-ui="cards"] .er-key-badge[data-er-badge="uk"] { fill: none; stroke: var(--lane-stroke); }
1533 svg[data-erd-ui="cards"] .er-key-badge + text { text-anchor: middle; }
1534 svg[data-erd-ui="cards"] [data-legend] text.t-primary { fill: var(--text); }
1535 svg[data-erd-ui="cards"] [data-legend] > text.t-primary { fill: var(--text-muted); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; }
1536 svg[data-erd-ui="cards"] .er-legend-hit { fill: transparent; transition: fill .15s; }
1537 svg[data-erd-ui="cards"] .er-legend-hit:focus { outline: none; }
1538 svg[data-erd-ui="cards"] .er-legend-hit:focus-visible { stroke: var(--database-stroke); stroke-width: 1.5; }
1539 svg[data-erd-ui="cards"] [data-legend-semantic-kind]:is(:hover, :focus-within) .er-legend-hit { fill: var(--database-fill); }
1540 svg[data-erd-ui="cards"] [data-legend-semantic-kind]:is(:hover, :focus-within) .er-legend-glyph { stroke: var(--database-stroke) !important; }
1541 svg[data-erd-ui="cards"] .er-legend-route { stroke: var(--text-muted); stroke-opacity: .55; }
1542 svg[data-erd-ui="cards"] .er-legend-edge { stroke: var(--lane-stroke); stroke-linecap: round; }
1543 svg[data-erd-ui="cards"] .er-legend-divider { stroke: var(--lane-stroke); }
1544 svg[data-erd-ui="cards"] [data-er-row], svg[data-erd-ui="cards"] [data-er-ends] { transition: opacity .15s; }
1545${LEGEND_HOVER_RULES}
1546 @media (prefers-reduced-motion: reduce) { svg[data-erd-ui="cards"] * { transition: none !important; } }
1547 </style>
1549 <!-- Background Grid -->
1550 <rect width="100%" height="100%" fill="url(#grid)" />
1552 <!-- Domain bands (behind the routes so a channel is never washed out) -->
1553${renderDomainBands()}
1555 <!-- Bundled relationship trunks (under the branches: a bus runs close
1556 to the shared side, so a cardinality glyph that reaches further out
1557 has to stay readable over it, and the ring's mask has to be able to
1558 punch through) -->
1559${bundledTrunkPaths()}
1561 <!-- Relationship paths (before entities for correct z-order) -->
1562${relationships.map((relationship, index) => (renderableRelationship(relationship) ? renderRelationshipPath(relationship, index) : '')).filter(Boolean).join('\n')}
1564 <!-- Entities -->
1565${[...entities.values()].map(renderEntity).join('\n\n')}
1567 <!-- Relationship labels -->
1568${relationships.map((relationship, index) => (renderableRelationship(relationship) ? renderRelationshipLabel(relationship, index) : '')).filter(Boolean).join('\n')}
1570 <!-- Legend -->
1571${renderLegend()}
1572 </svg>`;
1573}
1575validateEr();
1576if (layoutJsonMode) {
1577 console.log(JSON.stringify(buildLayoutReport(), null, 2));
1578 process.exit(0);
1579}
1580writeDiagram({
1581 outPath,
1582 template,
1583 diagramType: 'erd',
1584 meta: er.meta,
1585 svg: renderSvg(),
1586 cards: er.cards,
1587 sourceEvidence,
1588});