@diagrammo/dgmo 0.7.3 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/AGENTS.md +15 -20
  2. package/README.md +56 -58
  3. package/dist/cli.cjs +188 -181
  4. package/dist/index.cjs +3522 -1072
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +196 -43
  7. package/dist/index.d.ts +196 -43
  8. package/dist/index.js +3509 -1072
  9. package/dist/index.js.map +1 -1
  10. package/docs/language-reference.md +629 -289
  11. package/package.json +1 -1
  12. package/src/c4/layout.ts +6 -9
  13. package/src/c4/parser.ts +189 -83
  14. package/src/c4/renderer.ts +8 -9
  15. package/src/chart.ts +296 -83
  16. package/src/class/parser.ts +54 -37
  17. package/src/class/renderer.ts +8 -8
  18. package/src/cli.ts +8 -8
  19. package/src/colors.ts +4 -1
  20. package/src/completion.ts +757 -10
  21. package/src/d3.ts +324 -78
  22. package/src/dgmo-router.ts +63 -8
  23. package/src/echarts.ts +735 -241
  24. package/src/er/parser.ts +94 -76
  25. package/src/er/renderer.ts +6 -5
  26. package/src/gantt/parser.ts +144 -69
  27. package/src/gantt/renderer.ts +50 -14
  28. package/src/gantt/types.ts +3 -3
  29. package/src/graph/flowchart-parser.ts +97 -37
  30. package/src/graph/flowchart-renderer.ts +4 -3
  31. package/src/graph/state-parser.ts +50 -31
  32. package/src/graph/state-renderer.ts +4 -3
  33. package/src/index.ts +14 -5
  34. package/src/infra/compute.ts +1 -0
  35. package/src/infra/layout.ts +3 -0
  36. package/src/infra/parser.ts +291 -92
  37. package/src/infra/renderer.ts +172 -30
  38. package/src/infra/types.ts +5 -0
  39. package/src/initiative-status/layout.ts +1 -1
  40. package/src/initiative-status/parser.ts +121 -47
  41. package/src/initiative-status/renderer.ts +42 -23
  42. package/src/initiative-status/types.ts +10 -2
  43. package/src/kanban/parser.ts +60 -37
  44. package/src/kanban/renderer.ts +2 -2
  45. package/src/kanban/types.ts +1 -0
  46. package/src/org/layout.ts +9 -9
  47. package/src/org/parser.ts +39 -40
  48. package/src/org/renderer.ts +5 -6
  49. package/src/org/resolver.ts +26 -19
  50. package/src/render.ts +1 -1
  51. package/src/sequence/parser.ts +304 -95
  52. package/src/sequence/renderer.ts +9 -9
  53. package/src/sitemap/layout.ts +3 -4
  54. package/src/sitemap/parser.ts +57 -49
  55. package/src/sitemap/renderer.ts +6 -7
  56. package/src/utils/arrows.ts +25 -6
  57. package/src/utils/duration.ts +43 -7
  58. package/src/utils/legend-constants.ts +26 -0
  59. package/src/utils/legend-svg.ts +167 -0
  60. package/src/utils/parsing.ts +247 -7
  61. package/src/utils/tag-groups.ts +160 -15
  62. package/src/utils/title-constants.ts +9 -0
@@ -11,10 +11,15 @@ import {
11
11
  measureIndent,
12
12
  extractColor,
13
13
  parsePipeMetadata,
14
+ normalizeDirection,
15
+ inferArrowColor,
14
16
  MULTIPLE_PIPE_WARNING,
15
17
  CHART_TYPE_RE,
16
18
  TITLE_RE,
17
19
  OPTION_RE,
20
+ parseFirstLine,
21
+ OPTION_NOCOLON_RE,
22
+ ALL_CHART_TYPES,
18
23
  } from '../utils/parsing';
19
24
  import type {
20
25
  SitemapNode,
@@ -26,7 +31,8 @@ import type {
26
31
  // Regexes
27
32
  // ============================================================
28
33
 
29
- const CONTAINER_RE = /^\[([^\]]+)\]$/;
34
+ const CONTAINER_RE = /^\[([^\]]+)\]\s*(?:\|\s*(.+))?$/;
35
+ /** Metadata on content nodes: `key: value` (colon-separated, used in content phase) */
30
36
  const METADATA_RE = /^([^:]+):\s*(.+)$/;
31
37
 
32
38
  /**
@@ -54,9 +60,12 @@ function parseArrowLine(
54
60
  const arrowMatch = trimmed.match(ARROW_RE);
55
61
  if (arrowMatch) {
56
62
  const label = arrowMatch[1]?.trim() || undefined;
57
- const color = arrowMatch[2]
58
- ? resolveColor(arrowMatch[2].trim(), palette)
63
+ let color = arrowMatch[2]
64
+ ? resolveColor(arrowMatch[2].trim(), palette) ?? undefined
59
65
  : undefined;
66
+ if (label && !color) {
67
+ color = inferArrowColor(label);
68
+ }
60
69
  const target = arrowMatch[3].trim();
61
70
  return { label, color, target };
62
71
  }
@@ -149,6 +158,7 @@ export function parseSitemap(
149
158
  let contentStarted = false;
150
159
  let nodeCounter = 0;
151
160
  let containerCounter = 0;
161
+ let firstLineParsed = false;
152
162
 
153
163
  // Tag group parsing state
154
164
  let currentTagGroup: TagGroup | null = null;
@@ -189,32 +199,22 @@ export function parseSitemap(
189
199
 
190
200
  // --- Header phase ---
191
201
 
192
- // chart: type
193
- if (!contentStarted) {
194
- const chartMatch = trimmed.match(CHART_TYPE_RE);
195
- if (chartMatch) {
196
- const chartType = chartMatch[1].trim().toLowerCase();
197
- if (chartType !== 'sitemap') {
198
- const allTypes = [
199
- 'sitemap', 'org', 'class', 'flowchart', 'sequence', 'er',
200
- 'bar', 'line', 'pie', 'scatter', 'sankey', 'venn', 'timeline',
201
- 'arc', 'slope', 'kanban', 'c4', 'initiative-status', 'state',
202
- ];
203
- let msg = `Expected chart type "sitemap", got "${chartType}"`;
204
- const hint = suggest(chartType, allTypes);
202
+ // First line: try parseFirstLine for `sitemap [Title]`
203
+ if (!firstLineParsed && !contentStarted) {
204
+ const firstLineResult = parseFirstLine(trimmed);
205
+ if (firstLineResult) {
206
+ firstLineParsed = true;
207
+ if (firstLineResult.chartType !== 'sitemap') {
208
+ const allTypes = Array.from(ALL_CHART_TYPES);
209
+ let msg = `Expected chart type "sitemap", got "${firstLineResult.chartType}"`;
210
+ const hint = suggest(firstLineResult.chartType, allTypes);
205
211
  if (hint) msg += `. ${hint}`;
206
212
  return fail(lineNumber, msg);
207
213
  }
208
- continue;
209
- }
210
- }
211
-
212
- // title: value
213
- if (!contentStarted) {
214
- const titleMatch = trimmed.match(TITLE_RE);
215
- if (titleMatch) {
216
- result.title = titleMatch[1].trim();
217
- result.titleLineNumber = lineNumber;
214
+ if (firstLineResult.title) {
215
+ result.title = firstLineResult.title;
216
+ result.titleLineNumber = lineNumber;
217
+ }
218
218
  continue;
219
219
  }
220
220
  }
@@ -227,10 +227,8 @@ export function parseSitemap(
227
227
  continue;
228
228
  }
229
229
  if (tagBlockMatch.deprecated) {
230
- pushWarning(
231
- lineNumber,
232
- `'## ${tagBlockMatch.name}' is deprecated for tag groups — use 'tag: ${tagBlockMatch.name}' instead`,
233
- );
230
+ pushError(lineNumber, `'## ${tagBlockMatch.name}' is no longer supported — use 'tag: ${tagBlockMatch.name}' instead`);
231
+ continue;
234
232
  }
235
233
  currentTagGroup = {
236
234
  name: tagBlockMatch.name,
@@ -245,22 +243,22 @@ export function parseSitemap(
245
243
  continue;
246
244
  }
247
245
 
248
- // Generic header options (before content/tag groups)
249
- if (!contentStarted && !currentTagGroup && measureIndent(line) === 0) {
250
- const optMatch = trimmed.match(OPTION_RE);
246
+ // Generic header options (space-separated, before content/tag groups)
247
+ // Skip lines with `|` (pipe metadata) or `->` (arrows) — those are content
248
+ if (!contentStarted && !currentTagGroup && measureIndent(line) === 0
249
+ && !trimmed.includes('|') && !trimmed.includes('->')) {
250
+ const optMatch = trimmed.match(OPTION_NOCOLON_RE);
251
251
  if (optMatch) {
252
252
  const key = optMatch[1].trim().toLowerCase();
253
- if (key === 'direction') {
254
- const dir = optMatch[2].trim().toUpperCase();
255
- if (dir === 'TB' || dir === 'LR') {
253
+ if (key === 'direction' || key === 'orientation') {
254
+ const dir = normalizeDirection(optMatch[2]);
255
+ if (dir) {
256
256
  result.direction = dir as SitemapDirection;
257
257
  }
258
258
  continue;
259
259
  }
260
- if (key !== 'chart' && key !== 'title') {
261
- result.options[key] = optMatch[2].trim();
262
- continue;
263
- }
260
+ result.options[key] = optMatch[2].trim();
261
+ continue;
264
262
  }
265
263
  }
266
264
 
@@ -268,11 +266,7 @@ export function parseSitemap(
268
266
  if (currentTagGroup && !contentStarted) {
269
267
  const indent = measureIndent(line);
270
268
  if (indent > 0) {
271
- const isDefault = /\bdefault\s*$/.test(trimmed);
272
- const entryText = isDefault
273
- ? trimmed.replace(/\s+default\s*$/, '').trim()
274
- : trimmed;
275
- const { label, color } = extractColor(entryText, palette);
269
+ const { label, color } = extractColor(trimmed, palette);
276
270
  if (!color) {
277
271
  pushError(
278
272
  lineNumber,
@@ -280,14 +274,15 @@ export function parseSitemap(
280
274
  );
281
275
  continue;
282
276
  }
283
- if (isDefault) {
284
- currentTagGroup.defaultValue = label;
285
- }
286
277
  currentTagGroup.entries.push({
287
278
  value: label,
288
279
  color,
289
280
  lineNumber,
290
281
  });
282
+ // First entry is the default
283
+ if (currentTagGroup.entries.length === 1) {
284
+ currentTagGroup.defaultValue = label;
285
+ }
291
286
  continue;
292
287
  }
293
288
  // Non-indented line after tag group — fall through to content
@@ -332,11 +327,20 @@ export function parseSitemap(
332
327
  const rawLabel = containerMatch[1].trim();
333
328
  const { label, color } = extractColor(rawLabel, palette);
334
329
 
330
+ // Parse optional pipe metadata on the container line
331
+ const pipeStr = containerMatch[2];
332
+ const containerMetadata: Record<string, string> = {};
333
+ if (pipeStr) {
334
+ // Build segments array compatible with parsePipeMetadata (first element is label, rest are pipe parts)
335
+ const pipeSegments = ['', pipeStr];
336
+ Object.assign(containerMetadata, parsePipeMetadata(pipeSegments, aliasMap));
337
+ }
338
+
335
339
  containerCounter++;
336
340
  const node: SitemapNode = {
337
341
  id: `container-${containerCounter}`,
338
342
  label,
339
- metadata: {},
343
+ metadata: containerMetadata,
340
344
  children: [],
341
345
  parentId: null,
342
346
  isContainer: true,
@@ -466,6 +470,10 @@ function attachNode(
466
470
  if (indentStack.length > 0) {
467
471
  const parent = indentStack[indentStack.length - 1].node;
468
472
  node.parentId = parent.id;
473
+ // Cascade container metadata to child nodes (child overrides on conflict)
474
+ if (parent.isContainer && Object.keys(parent.metadata).length > 0 && !node.isContainer) {
475
+ node.metadata = { ...parent.metadata, ...node.metadata };
476
+ }
469
477
  parent.children.push(node);
470
478
  } else {
471
479
  result.roots.push(node);
@@ -16,11 +16,9 @@ import {
16
16
  LEGEND_HEIGHT,
17
17
  LEGEND_PILL_PAD,
18
18
  LEGEND_PILL_FONT_SIZE,
19
- LEGEND_PILL_FONT_W,
20
19
  LEGEND_CAPSULE_PAD,
21
20
  LEGEND_DOT_R,
22
21
  LEGEND_ENTRY_FONT_SIZE,
23
- LEGEND_ENTRY_FONT_W,
24
22
  LEGEND_ENTRY_DOT_GAP,
25
23
  LEGEND_ENTRY_TRAIL,
26
24
  LEGEND_GROUP_GAP,
@@ -28,6 +26,7 @@ import {
28
26
  LEGEND_EYE_GAP,
29
27
  EYE_OPEN_PATH,
30
28
  EYE_CLOSED_PATH,
29
+ measureLegendText,
31
30
  } from '../utils/legend-constants';
32
31
 
33
32
  // ============================================================
@@ -36,8 +35,8 @@ import {
36
35
 
37
36
  const DIAGRAM_PADDING = 20;
38
37
  const MAX_SCALE = 3;
38
+ import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT } from '../utils/title-constants';
39
39
  const TITLE_HEIGHT = 30;
40
- const TITLE_FONT_SIZE = 18;
41
40
  const LABEL_FONT_SIZE = 13;
42
41
  const META_FONT_SIZE = 11;
43
42
  const META_LINE_HEIGHT = 16;
@@ -209,7 +208,7 @@ export function renderSitemap(
209
208
  .attr('text-anchor', 'middle')
210
209
  .attr('fill', palette.text)
211
210
  .attr('font-size', TITLE_FONT_SIZE)
212
- .attr('font-weight', 'bold')
211
+ .attr('font-weight', TITLE_FONT_WEIGHT)
213
212
  .attr('class', 'sitemap-title chart-title');
214
213
 
215
214
  if (parsed.titleLineNumber) {
@@ -523,7 +522,7 @@ export function renderSitemap(
523
522
  .attr('text-anchor', 'middle')
524
523
  .attr('fill', palette.text)
525
524
  .attr('font-size', TITLE_FONT_SIZE)
526
- .attr('font-weight', 'bold')
525
+ .attr('font-weight', TITLE_FONT_WEIGHT)
527
526
  .attr('class', 'sitemap-title chart-title')
528
527
  .style('font-family', FONT_FAMILY);
529
528
 
@@ -592,7 +591,7 @@ function renderLegend(
592
591
 
593
592
  for (const group of visibleGroups) {
594
593
  const isActive = activeTagGroup != null;
595
- const pillW = group.name.length * LEGEND_PILL_FONT_W + LEGEND_PILL_PAD;
594
+ const pillW = measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
596
595
 
597
596
  const gX = fixedPositions?.get(group.name) ?? group.x;
598
597
  const gY = fixedPositions ? 0 : group.y;
@@ -705,7 +704,7 @@ function renderLegend(
705
704
  .attr('fill', palette.textMuted)
706
705
  .text(entry.value);
707
706
 
708
- entryX = textX + entry.value.length * LEGEND_ENTRY_FONT_W + LEGEND_ENTRY_TRAIL;
707
+ entryX = textX + measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
709
708
  }
710
709
  }
711
710
  }
@@ -14,14 +14,14 @@ export interface ParsedArrow {
14
14
  }
15
15
 
16
16
  // Forward (call) patterns — participant names may contain spaces, so use non-greedy (.+?)
17
- const SYNC_LABELED_RE = /^(.+?)\s+-(.+)->\s+(.+)$/;
18
- const ASYNC_LABELED_RE = /^(.+?)\s+~(.+)~>\s+(.+)$/;
17
+ const SYNC_LABELED_RE = /^(.+?)\s*-(.+)->\s*(.+)$/;
18
+ const ASYNC_LABELED_RE = /^(.+?)\s*~(.+)~>\s*(.+)$/;
19
19
 
20
20
  // Deprecated patterns — produce errors
21
- const RETURN_SYNC_LABELED_RE = /^(.+?)\s+<-(.+)-\s+(.+)$/;
22
- const RETURN_ASYNC_LABELED_RE = /^(.+?)\s+<~(.+)~\s+(.+)$/;
23
- const BIDI_SYNC_RE = /^(.+?)\s+<-(.+)->\s+(.+)$/;
24
- const BIDI_ASYNC_RE = /^(.+?)\s+<~(.+)~>\s+(.+)$/;
21
+ const RETURN_SYNC_LABELED_RE = /^(.+?)\s*<-(.+)-\s*(.+)$/;
22
+ const RETURN_ASYNC_LABELED_RE = /^(.+?)\s*<~(.+)~\s*(.+)$/;
23
+ const BIDI_SYNC_RE = /^(.+?)\s*<-(.+)->\s*(.+)$/;
24
+ const BIDI_ASYNC_RE = /^(.+?)\s*<~(.+)~>\s*(.+)$/;
25
25
 
26
26
  const ARROW_CHARS = ['->', '~>'];
27
27
 
@@ -93,3 +93,22 @@ export function parseArrow(
93
93
 
94
94
  return null;
95
95
  }
96
+
97
+ /**
98
+ * Match an arrow segment and extract label + async flag.
99
+ * Handles: `->`, `-label->`, `~>`, `~label~>`.
100
+ * Returns null if no arrow pattern matched.
101
+ */
102
+ export function matchArrowLabel(segment: string): { label: string; async: boolean } | null {
103
+ // Async labeled: ~label~>
104
+ const asyncLabeled = segment.match(/^~(.+?)~>$/);
105
+ if (asyncLabeled) return { label: asyncLabeled[1].trim(), async: true };
106
+ // Async bare: ~>
107
+ if (segment.trim() === '~>') return { label: '', async: true };
108
+ // Sync labeled: -label->
109
+ const syncLabeled = segment.match(/^-(.+?)->$/);
110
+ if (syncLabeled) return { label: syncLabeled[1].trim(), async: false };
111
+ // Sync bare: ->
112
+ if (segment.trim() === '->') return { label: '', async: false };
113
+ return null;
114
+ }
@@ -150,6 +150,18 @@ export function addGanttDuration(
150
150
  }
151
151
  return result;
152
152
  }
153
+
154
+ case 'h': {
155
+ const result = new Date(startDate);
156
+ result.setTime(result.getTime() + amount * 3600000 * direction);
157
+ return result;
158
+ }
159
+
160
+ case 'min': {
161
+ const result = new Date(startDate);
162
+ result.setTime(result.getTime() + amount * 60000 * direction);
163
+ return result;
164
+ }
153
165
  }
154
166
  }
155
167
 
@@ -157,7 +169,7 @@ export function addGanttDuration(
157
169
  * Parse a duration string like "3bd" or "5d".
158
170
  */
159
171
  export function parseDuration(s: string): Duration | null {
160
- const match = s.trim().match(/^(\d+(?:\.\d+)?)(d|bd|w|m|q|y)$/);
172
+ const match = s.trim().match(/^(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h)$/);
161
173
  if (!match) return null;
162
174
  return { amount: parseFloat(match[1]), unit: match[2] as DurationUnit };
163
175
  }
@@ -185,22 +197,46 @@ export function parseOffset(value: string): Offset | null {
185
197
  }
186
198
 
187
199
  /**
188
- * Parse a date string (YYYY-MM-DD, YYYY-MM, or YYYY) into a Date object.
189
- * Always returns midnight local time on the first available day.
200
+ * Parse a date string (YYYY-MM-DD, YYYY-MM, YYYY, or YYYY-MM-DD HH:MM) into a Date object.
201
+ * Returns midnight local time unless HH:MM is specified.
190
202
  */
191
203
  export function parseGanttDate(s: string): Date {
192
- const parts = s.split('-').map(p => parseInt(p, 10));
204
+ // Split on space to detect optional time component
205
+ const spaceIdx = s.indexOf(' ');
206
+ let datePart = s;
207
+ let hour = 0;
208
+ let minute = 0;
209
+
210
+ if (spaceIdx !== -1) {
211
+ datePart = s.slice(0, spaceIdx);
212
+ const timePart = s.slice(spaceIdx + 1);
213
+ const timeParts = timePart.split(':');
214
+ if (timeParts.length === 2) {
215
+ const h = parseInt(timeParts[0], 10);
216
+ const m = parseInt(timeParts[1], 10);
217
+ if (h >= 0 && h <= 23 && m >= 0 && m <= 59) {
218
+ hour = h;
219
+ minute = m;
220
+ }
221
+ }
222
+ }
223
+
224
+ const parts = datePart.split('-').map(p => parseInt(p, 10));
193
225
  const year = parts[0];
194
226
  const month = parts.length >= 2 ? parts[1] - 1 : 0; // JS months are 0-based
195
227
  const day = parts.length >= 3 ? parts[2] : 1;
196
- return new Date(year, month, day);
228
+ return new Date(year, month, day, hour, minute);
197
229
  }
198
230
 
199
231
  /**
200
- * Format a Date as YYYY-MM-DD string.
232
+ * Format a Date as YYYY-MM-DD string, or YYYY-MM-DD HH:MM if time is non-midnight.
201
233
  */
202
234
  export function formatGanttDate(date: Date): string {
203
- return formatDateKey(date);
235
+ const dateStr = formatDateKey(date);
236
+ const h = date.getHours();
237
+ const m = date.getMinutes();
238
+ if (h === 0 && m === 0) return dateStr;
239
+ return `${dateStr} ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
204
240
  }
205
241
 
206
242
  /**
@@ -18,6 +18,32 @@ export const LEGEND_EYE_SIZE = 14;
18
18
  export const LEGEND_EYE_GAP = 6;
19
19
  export const LEGEND_ICON_W = 20;
20
20
 
21
+ // ── Proportional text measurement ────────────────────────────
22
+ // Helvetica character width ratios (fraction of fontSize).
23
+ // Replaces the naive `chars * 0.6 * fontSize` estimate with
24
+ // per-character proportional widths for accurate legend sizing.
25
+ // prettier-ignore
26
+ const CHAR_W: Record<string, number> = {
27
+ ' ':.28,'!': .28,'"': .36,'#': .56,'$': .56,'%': .89,'&': .67,"'":.19,
28
+ '(':.33,')':.33,'*': .39,'+':.58,',':.28,'-':.33,'.':.28,'/':.28,
29
+ '0':.56,'1':.56,'2':.56,'3':.56,'4':.56,'5':.56,'6':.56,'7':.56,'8':.56,'9':.56,
30
+ ':':.28,';':.28,'<':.58,'=':.58,'>':.58,'?':.56,'@':1.02,
31
+ A:.67,B:.67,C:.72,D:.72,E:.67,F:.61,G:.78,H:.72,I:.28,J:.50,K:.67,L:.56,M:.83,
32
+ N:.72,O:.78,P:.67,Q:.78,R:.72,S:.67,T:.61,U:.72,V:.67,W:.94,X:.67,Y:.67,Z:.61,
33
+ a:.56,b:.56,c:.50,d:.56,e:.56,f:.28,g:.56,h:.56,i:.22,j:.22,k:.50,l:.22,m:.83,
34
+ n:.56,o:.56,p:.56,q:.56,r:.33,s:.50,t:.28,u:.56,v:.50,w:.72,x:.50,y:.50,z:.50,
35
+ };
36
+ const DEFAULT_W = 0.56;
37
+
38
+ /** Estimate rendered text width using Helvetica proportional character widths. */
39
+ export function measureLegendText(text: string, fontSize: number): number {
40
+ let w = 0;
41
+ for (let i = 0; i < text.length; i++) {
42
+ w += (CHAR_W[text[i]] ?? DEFAULT_W) * fontSize;
43
+ }
44
+ return w;
45
+ }
46
+
21
47
  // Eye icon SVG paths (14×14 viewBox)
22
48
  // Present only in org and sitemap legends (metadata visibility toggle)
23
49
  export const EYE_OPEN_PATH =
@@ -0,0 +1,167 @@
1
+ // ============================================================
2
+ // Shared legend SVG string generator
3
+ // Produces SVG <g> elements matching the standard legend style
4
+ // used across all diagram types (capsule pills with colored dots).
5
+ // ============================================================
6
+
7
+ import {
8
+ LEGEND_HEIGHT,
9
+ LEGEND_PILL_PAD,
10
+ LEGEND_PILL_FONT_SIZE,
11
+ LEGEND_CAPSULE_PAD,
12
+ LEGEND_DOT_R,
13
+ LEGEND_ENTRY_FONT_SIZE,
14
+ LEGEND_ENTRY_DOT_GAP,
15
+ LEGEND_ENTRY_TRAIL,
16
+ LEGEND_GROUP_GAP,
17
+ measureLegendText,
18
+ } from './legend-constants';
19
+ import { mix } from '../palettes/color-utils';
20
+ import { FONT_FAMILY } from '../fonts';
21
+
22
+ // ── Types ────────────────────────────────────────────────────
23
+
24
+ export interface LegendGroupData {
25
+ name: string;
26
+ entries: Array<{ value: string; color: string }>;
27
+ }
28
+
29
+ export interface LegendRenderOptions {
30
+ palette: { bg: string; surface: string; text: string; textMuted: string };
31
+ isDark: boolean;
32
+ containerWidth: number;
33
+ /** Grid left offset as percentage (e.g. 12 for '12%'). Centers legend over plot area. */
34
+ gridLeftPct?: number;
35
+ /** Grid right offset as percentage (e.g. 4 for '4%'). Centers legend over plot area. */
36
+ gridRightPct?: number;
37
+ activeGroup?: string | null;
38
+ className?: string;
39
+ }
40
+
41
+ export interface LegendRenderResult {
42
+ svg: string;
43
+ height: number;
44
+ /** Natural content width (px). Callers can use this for CSS-based centering. */
45
+ width: number;
46
+ }
47
+
48
+ // ── Helpers ──────────────────────────────────────────────────
49
+
50
+ function esc(s: string): string {
51
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
52
+ }
53
+
54
+ function pillWidth(name: string): number {
55
+ return measureLegendText(name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
56
+ }
57
+
58
+ function entriesWidth(entries: Array<{ value: string }>): number {
59
+ let w = 0;
60
+ for (const e of entries) {
61
+ w += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + measureLegendText(e.value, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
62
+ }
63
+ return w;
64
+ }
65
+
66
+ function groupTotalWidth(name: string, entries: Array<{ value: string }>, isActive: boolean): number {
67
+ const pw = pillWidth(name);
68
+ if (!isActive) return pw;
69
+ return LEGEND_CAPSULE_PAD * 2 + pw + 4 + entriesWidth(entries);
70
+ }
71
+
72
+ // ── Main renderer ────────────────────────────────────────────
73
+
74
+ export function renderLegendSvg(
75
+ groups: LegendGroupData[],
76
+ options: LegendRenderOptions,
77
+ ): LegendRenderResult {
78
+ if (groups.length === 0) return { svg: '', height: 0, width: 0 };
79
+
80
+ const { palette, isDark, containerWidth, activeGroup, className } = options;
81
+ const groupBg = isDark
82
+ ? mix(palette.surface, palette.bg, 50)
83
+ : mix(palette.surface, palette.bg, 30);
84
+
85
+ // Pre-compute layout
86
+ const items = groups
87
+ .filter((g) => g.entries.length > 0)
88
+ .map((g) => {
89
+ const isActive = !!activeGroup && g.name.toLowerCase() === activeGroup.toLowerCase();
90
+ const pw = pillWidth(g.name);
91
+ const tw = groupTotalWidth(g.name, g.entries, isActive);
92
+ return { group: g, isActive, pillWidth: pw, totalWidth: tw };
93
+ });
94
+
95
+ if (items.length === 0) return { svg: '', height: 0, width: 0 };
96
+
97
+ const totalWidth = items.reduce((s, it) => s + it.totalWidth, 0) + (items.length - 1) * LEGEND_GROUP_GAP;
98
+
99
+ // Center over the plot area when grid offsets are provided, otherwise full container
100
+ const plotLeft = options.gridLeftPct ? (containerWidth * options.gridLeftPct) / 100 : 0;
101
+ const plotRight = options.gridRightPct ? containerWidth - (containerWidth * options.gridRightPct) / 100 : containerWidth;
102
+ const plotWidth = plotRight - plotLeft;
103
+ let x = Math.max(0, plotLeft + (plotWidth - totalWidth) / 2);
104
+
105
+ const parts: string[] = [];
106
+ const pillH = LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2;
107
+
108
+ for (const item of items) {
109
+ const groupKey = item.group.name.toLowerCase();
110
+ const inner: string[] = [];
111
+
112
+ // Outer capsule (active only)
113
+ if (item.isActive) {
114
+ inner.push(
115
+ `<rect width="${item.totalWidth}" height="${LEGEND_HEIGHT}" rx="${LEGEND_HEIGHT / 2}" fill="${esc(groupBg)}"/>`,
116
+ );
117
+ }
118
+
119
+ const pillXOff = item.isActive ? LEGEND_CAPSULE_PAD : 0;
120
+ const pillYOff = item.isActive ? LEGEND_CAPSULE_PAD : 0;
121
+ const h = item.isActive ? pillH : LEGEND_HEIGHT;
122
+
123
+ // Pill background
124
+ inner.push(
125
+ `<rect x="${pillXOff}" y="${pillYOff}" width="${item.pillWidth}" height="${h}" rx="${h / 2}" fill="${esc(item.isActive ? palette.bg : groupBg)}"/>`,
126
+ );
127
+
128
+ // Active pill border
129
+ if (item.isActive) {
130
+ inner.push(
131
+ `<rect x="${pillXOff}" y="${pillYOff}" width="${item.pillWidth}" height="${h}" rx="${h / 2}" fill="none" stroke="${esc(mix(palette.textMuted, palette.bg, 50))}" stroke-width="0.75"/>`,
132
+ );
133
+ }
134
+
135
+ // Pill text
136
+ inner.push(
137
+ `<text x="${pillXOff + item.pillWidth / 2}" y="${LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2}" font-size="${LEGEND_PILL_FONT_SIZE}" font-weight="500" fill="${esc(item.isActive ? palette.text : palette.textMuted)}" text-anchor="middle" font-family="${esc(FONT_FAMILY)}">${esc(item.group.name)}</text>`,
138
+ );
139
+
140
+ // Entry dots + labels (active only)
141
+ if (item.isActive) {
142
+ let entryX = pillXOff + item.pillWidth + 4;
143
+ for (const entry of item.group.entries) {
144
+ const textX = entryX + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
145
+ inner.push(
146
+ `<g data-legend-entry="${esc(entry.value.toLowerCase())}" data-series-name="${esc(entry.value)}" style="cursor:pointer">` +
147
+ `<circle cx="${entryX + LEGEND_DOT_R}" cy="${LEGEND_HEIGHT / 2}" r="${LEGEND_DOT_R}" fill="${esc(entry.color)}"/>` +
148
+ `<text x="${textX}" y="${LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1}" font-size="${LEGEND_ENTRY_FONT_SIZE}" fill="${esc(palette.textMuted)}" font-family="${esc(FONT_FAMILY)}">${esc(entry.value)}</text>` +
149
+ `</g>`,
150
+ );
151
+ entryX = textX + measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
152
+ }
153
+ }
154
+
155
+ parts.push(
156
+ `<g transform="translate(${x},0)" data-legend-group="${esc(groupKey)}" style="cursor:pointer">${inner.join('')}</g>`,
157
+ );
158
+
159
+ x += item.totalWidth + LEGEND_GROUP_GAP;
160
+ }
161
+
162
+ const classAttr = className ? ` class="${esc(className)}"` : '';
163
+ const activeAttr = activeGroup ? ` data-legend-active="${esc(activeGroup.toLowerCase())}"` : '';
164
+ const svg = `<g${classAttr}${activeAttr}>${parts.join('')}</g>`;
165
+
166
+ return { svg, height: LEGEND_HEIGHT, width: totalWidth };
167
+ }