Skill Tracer
Skills › Archify › archify › legend.mjs

legend.mjs

227 lines · archify/renderers/shared/legend.mjs at a406594

1import { throwDiagnosticError } from './diagnostics.mjs';
2import { rectsOverlap, segmentIntersectsRect } from './geometry.mjs';
3import { esc, textUnits } from './utils.mjs';
4import { translateMessage } from './i18n.mjs';
5
6const DEFAULT_FONT_SIZE = 8;
7const DEFAULT_ITEM_GAP = 22;
8const DEFAULT_LINE_GAP = 22;
9const DEFAULT_SWATCH_GAP = 8;
10const TEXT_ADVANCE_EM = 0.62;
11const INTERACTIVE_BADGE_ALLOWANCE = 21;
13export function relationshipLegendObstacles(relations, { pointsFor, labelRectFor } = {}) {
14 const obstacles = [];
15 for (const [index, relation] of (Array.isArray(relations) ? relations : []).entries()) {
16 const points = typeof pointsFor === 'function' ? pointsFor(relation, index) : [];
17 const finitePoints = (Array.isArray(points) ? points : []).filter((point) => (
18 Array.isArray(point) && point.length === 2 && point.every(Number.isFinite)
19 ));
20 for (let pointIndex = 0; pointIndex < finitePoints.length - 1; pointIndex += 1) {
21 obstacles.push({
22 kind: 'relationship-segment',
23 start: finitePoints[pointIndex],
24 end: finitePoints[pointIndex + 1],
25 });
26 }
27 const labelRect = typeof labelRectFor === 'function' ? labelRectFor(relation, index) : null;
28 if (labelRect && [labelRect.x, labelRect.y, labelRect.width, labelRect.height].every(Number.isFinite)) {
29 obstacles.push({ kind: 'relationship-label', ...labelRect });
30 }
31 }
32 return obstacles;
35export function resolveLegend(config, catalog, presentKinds) {
36 const mode = config?.mode || 'auto';
37 if (mode === 'hidden') return [];
38 const present = presentKinds instanceof Set ? presentKinds : new Set(presentKinds || []);
39 const overrides = config?.entries || {};
41 return catalog.flatMap((catalogEntry) => {
42 const override = overrides[catalogEntry.kind] || {};
43 const selectedByMode = mode === 'all' || present.has(catalogEntry.kind);
44 const visible = override.visible === true || (selectedByMode && override.visible !== false);
45 if (!visible) return [];
46 return [{
47 ...catalogEntry,
48 label: override.label || catalogEntry.label,
49 present: present.has(catalogEntry.kind),
50 interactive: catalogEntry.interactive !== false && present.has(catalogEntry.kind),
51 }];
52 });
55// Labels render slightly larger than the layout font size; measure the
56// rendered size so a long label cannot run into the next entry.
57function renderedLegendFontSize(fontSize) {
58 return fontSize < 8 ? fontSize + 0.5 : fontSize + 2;
61function measuredEntryWidth(entry, fontSize, swatchGap) {
62 const swatchWidth = entry.swatchWidth ?? 14;
63 return Math.ceil(
64 swatchWidth
65 + swatchGap
66 + textUnits(entry.label) * renderedLegendFontSize(fontSize) * TEXT_ADVANCE_EM
67 + (entry.interactive ? INTERACTIVE_BADGE_ALLOWANCE : 0)
68 + (entry.trailingWidth ?? 0),
69 );
72// One pure footprint calculation owns both auto-viewBox sizing and final SVG
73// placement. Callers must not maintain a second approximation of legend width
74// or row count; that would make generated geometry disagree with validation.
75export function legendFootprint(entries, {
76 width,
77 fontSize = DEFAULT_FONT_SIZE,
78 itemGap = DEFAULT_ITEM_GAP,
79 lineGap = DEFAULT_LINE_GAP,
80 swatchGap = DEFAULT_SWATCH_GAP,
81} = {}) {
82 if (!entries.length) {
83 return { measured: [], rows: [], rowCount: 0, minWidth: 0, extraHeight: 0 };
84 }
85 const measured = entries.map((entry) => ({
86 ...entry,
87 width: measuredEntryWidth(entry, fontSize, entry.swatchGap ?? swatchGap),
88 }));
89 const rows = [[]];
90 let cursor = 0;
91 for (const entry of measured) {
92 const row = rows.at(-1);
93 const required = (row.length ? itemGap : 0) + entry.width;
94 if (row.length && cursor + required > width) {
95 rows.push([entry]);
96 cursor = entry.width;
97 } else {
98 row.push(entry);
99 cursor += required;
100 }
101 }
102 return {
103 measured,
104 rows,
105 rowCount: rows.length,
106 minWidth: measured.reduce((width, entry) => Math.max(width, entry.width), 0),
107 extraHeight: (rows.length - 1) * lineGap,
108 };
111export function measureLegend(entries, {
112 x,
113 baselineY,
114 width,
115 fontSize = DEFAULT_FONT_SIZE,
116 itemGap = DEFAULT_ITEM_GAP,
117 lineGap = DEFAULT_LINE_GAP,
118 swatchGap = DEFAULT_SWATCH_GAP,
119 minTitleY = 0,
120 obstacles = [],
121 unfit = 'error',
122 diagramType = 'diagram',
123} = {}) {
124 if (!entries.length) return { entries: [], rowCount: 0, titleY: null };
125 const footprint = legendFootprint(entries, { width, fontSize, itemGap, lineGap, swatchGap });
126 const tooWide = footprint.measured.find((entry) => entry.width > width);
127 if (tooWide) {
128 if (unfit === 'hide') return null;
129 const message = `[legend/label-too-wide] ${diagramType} legend label for "${tooWide.kind}" needs ${tooWide.width}px but only ${width}px is available.`;
130 throwDiagnosticError(message, [{
131 code: 'legend/label-too-wide',
132 severity: 'error',
133 message,
134 subject: { diagramType, path: `/meta/legend/entries/${tooWide.kind}/label` },
135 evidence: { kind: tooWide.kind, measuredWidthPx: tooWide.width, availableWidthPx: width },
136 supportedFixes: ['shorten the legend label or use a wider viewBox'],
137 }]);
138 }
140 const titleY = baselineY - footprint.extraHeight - 20;
141 const legendTopY = titleY - 10;
142 if (legendTopY < minTitleY) {
143 if (unfit === 'hide') return null;
144 const message = `[legend/vertical-overflow] ${diagramType} legend needs ${footprint.rowCount} rows, which would start at y=${legendTopY} above the available legend band at y=${minTitleY}.`;
145 throwDiagnosticError(message, [{
146 code: 'legend/vertical-overflow',
147 severity: 'error',
148 message,
149 subject: { diagramType, path: '/meta/legend' },
150 evidence: { rowCount: footprint.rowCount, requiredTopY: legendTopY, availableTopY: minTitleY },
151 supportedFixes: ['shorten legend labels, hide nonessential entries, or use a wider viewBox'],
152 }]);
153 }
155 const positioned = [];
156 footprint.rows.forEach((row, rowIndex) => {
157 let entryX = x;
158 const baseline = baselineY - (footprint.rowCount - rowIndex - 1) * lineGap;
159 for (const entry of row) {
160 positioned.push({ ...entry, x: entryX, baseline, row: rowIndex });
161 entryX += entry.width + itemGap;
162 }
163 });
165 const legendRects = [
166 { kind: 'title', x, y: legendTopY, width: 48, height: 14 },
167 ...positioned.map((entry) => ({
168 kind: entry.kind,
169 x: entry.x,
170 y: entry.baseline - 10,
171 width: entry.width,
172 height: 14,
173 })),
174 ];
175 const collision = legendRects.find((legendRect) => obstacles.some((obstacle) => (
176 Array.isArray(obstacle.start) && Array.isArray(obstacle.end)
177 ? segmentIntersectsRect({ start: obstacle.start, end: obstacle.end }, legendRect)
178 : rectsOverlap(obstacle, legendRect)
179 )));
180 if (collision) {
181 if (unfit === 'hide') return null;
182 const message = `[legend/content-overlap] ${diagramType} legend entry "${collision.kind}" overlaps authored relationship geometry.`;
183 throwDiagnosticError(message, [{
184 code: 'legend/content-overlap',
185 severity: 'error',
186 message,
187 subject: { diagramType, path: '/meta/legend' },
188 evidence: { legendKind: collision.kind, legendRect: collision },
189 supportedFixes: ['shorten or hide legend entries, use a wider viewBox, or move the authored relationship route/label out of the legend band'],
190 }]);
191 }
193 return {
194 entries: positioned,
195 rowCount: footprint.rowCount,
196 titleY,
197 fontSize,
198 };
201// `labelClass`/`labelWeight` let a renderer state that its legend labels are
202// the same ink and weight as the value they describe; both default to the
203// shared presentation, so callers that pass nothing keep their exact bytes.
204export function renderLegend({ entries, layout, renderSwatch, locale, labelClass = 't-muted', labelWeight = 500 }) {
205 if (!entries.length) return '';
206 const measured = measureLegend(entries, layout);
207 if (!measured) return '';
208 const hasInteractiveEntries = measured.entries.some((entry) => entry.interactive);
209 const renderedFontSize = renderedLegendFontSize(measured.fontSize);
210 const rootAttributes = hasInteractiveEntries ? ' data-legend="" data-legend-bridge=""' : ' data-legend=""';
211 const parts = [
212 ` <g${rootAttributes}>`,
213 ` <text x="${layout.x}" y="${measured.titleY}" class="t-primary" font-size="12" font-weight="650">${esc(translateMessage(locale, 'legend.title'))}</text>`,
214 ];
216 for (const entry of measured.entries) {
217 const interactive = entry.interactive
218 ? ` data-legend-kind="${esc(entry.kind)}" data-legend-label="${esc(entry.label)}"`
219 : '';
220 parts.push(` <g data-legend-semantic-kind="${esc(entry.kind)}"${interactive} data-legend-x="${entry.x}" data-legend-baseline="${entry.baseline}" data-legend-width="${entry.width}">`);
221 parts.push(` ${renderSwatch(entry)}`);
222 parts.push(` <text x="${entry.x + (entry.swatchWidth ?? 14) + (entry.swatchGap ?? DEFAULT_SWATCH_GAP)}" y="${entry.baseline}" class="${labelClass}" font-size="${renderedFontSize}" font-weight="${labelWeight}">${esc(entry.label)}</text>`);
223 parts.push(' </g>');
224 }
225 parts.push(' </g>');
226 return parts.join('\n');