@opendata-ai/openchart-vanilla 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/index.d.ts +327 -0
  2. package/dist/index.js +4745 -0
  3. package/dist/index.js.map +1 -0
  4. package/dist/simulation-worker.js +1196 -0
  5. package/package.json +58 -0
  6. package/src/__test-fixtures__/dom.ts +42 -0
  7. package/src/__test-fixtures__/specs.ts +187 -0
  8. package/src/__tests__/edit-events.test.ts +747 -0
  9. package/src/__tests__/events.test.ts +336 -0
  10. package/src/__tests__/export.test.ts +150 -0
  11. package/src/__tests__/mount.test.ts +219 -0
  12. package/src/__tests__/svg-renderer.test.ts +609 -0
  13. package/src/__tests__/table-mount.test.ts +484 -0
  14. package/src/__tests__/tooltip.test.ts +201 -0
  15. package/src/export.ts +105 -0
  16. package/src/graph/__tests__/canvas-renderer.test.ts +704 -0
  17. package/src/graph/__tests__/graph-mount.test.ts +213 -0
  18. package/src/graph/__tests__/interaction.test.ts +205 -0
  19. package/src/graph/__tests__/keyboard.test.ts +653 -0
  20. package/src/graph/__tests__/search.test.ts +88 -0
  21. package/src/graph/__tests__/simulation.test.ts +233 -0
  22. package/src/graph/__tests__/spatial-index.test.ts +142 -0
  23. package/src/graph/__tests__/zoom.test.ts +195 -0
  24. package/src/graph/canvas-renderer.ts +660 -0
  25. package/src/graph/interaction.ts +359 -0
  26. package/src/graph/keyboard.ts +208 -0
  27. package/src/graph/search.ts +50 -0
  28. package/src/graph/simulation-worker-url.ts +30 -0
  29. package/src/graph/simulation-worker.ts +265 -0
  30. package/src/graph/simulation.ts +350 -0
  31. package/src/graph/spatial-index.ts +121 -0
  32. package/src/graph/types.ts +44 -0
  33. package/src/graph/worker-protocol.ts +67 -0
  34. package/src/graph/zoom.ts +104 -0
  35. package/src/graph-mount.ts +675 -0
  36. package/src/index.ts +56 -0
  37. package/src/mount.ts +1639 -0
  38. package/src/renderers/table-cells.ts +444 -0
  39. package/src/resize-observer.ts +46 -0
  40. package/src/svg-renderer.ts +914 -0
  41. package/src/table-keyboard.ts +266 -0
  42. package/src/table-mount.ts +532 -0
  43. package/src/table-renderer.ts +350 -0
  44. package/src/tooltip.ts +120 -0
@@ -0,0 +1,914 @@
1
+ /**
2
+ * SVG renderer: converts a ChartLayout into SVG DOM elements.
3
+ *
4
+ * Creates an <svg> element with viewBox matching layout dimensions,
5
+ * renders chrome (title/subtitle/source), axes, marks, annotations,
6
+ * and legend. All styling via inline SVG attributes from layout data.
7
+ *
8
+ * Mark rendering dispatches per mark type with dedicated renderers
9
+ * for line, area, rect, arc, and point marks.
10
+ */
11
+
12
+ import type {
13
+ ArcMark,
14
+ AreaMark,
15
+ AxisLayout,
16
+ ChartLayout,
17
+ LegendLayout,
18
+ LineMark,
19
+ Mark,
20
+ Point,
21
+ PointMark,
22
+ RectMark,
23
+ ResolvedAnnotation,
24
+ ResolvedChromeElement,
25
+ TextStyle,
26
+ } from '@opendata-ai/openchart-core';
27
+ import { estimateTextWidth } from '@opendata-ai/openchart-core';
28
+
29
+ const SVG_NS = 'http://www.w3.org/2000/svg';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Helpers
33
+ // ---------------------------------------------------------------------------
34
+
35
+ function createSVGElement(tag: string): SVGElement {
36
+ return document.createElementNS(SVG_NS, tag);
37
+ }
38
+
39
+ function setAttrs(el: SVGElement, attrs: Record<string, string | number>): void {
40
+ for (const [key, value] of Object.entries(attrs)) {
41
+ el.setAttribute(key, String(value));
42
+ }
43
+ }
44
+
45
+ function applyTextStyle(el: SVGElement, style: TextStyle): void {
46
+ setAttrs(el, {
47
+ 'font-family': style.fontFamily,
48
+ 'font-size': style.fontSize,
49
+ 'font-weight': style.fontWeight,
50
+ });
51
+ // Use inline style for fill so it takes priority over CSS class defaults
52
+ // (e.g. .viz-title { fill: var(--viz-text) } which would override attributes)
53
+ (el as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', style.fill);
54
+ if (style.textAnchor) {
55
+ el.setAttribute('text-anchor', style.textAnchor);
56
+ }
57
+ if (style.dominantBaseline) {
58
+ el.setAttribute('dominant-baseline', style.dominantBaseline);
59
+ }
60
+ if (style.fontVariant) {
61
+ el.setAttribute('font-variant', style.fontVariant);
62
+ }
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // Chrome rendering
67
+ // ---------------------------------------------------------------------------
68
+
69
+ function renderChromeElement(
70
+ parent: SVGElement,
71
+ element: ResolvedChromeElement,
72
+ className: string,
73
+ chromeKey: string,
74
+ ): void {
75
+ const text = createSVGElement('text');
76
+ setAttrs(text, { x: element.x, y: element.y });
77
+ applyTextStyle(text, element.style);
78
+ text.setAttribute('class', className);
79
+ text.setAttribute('data-chrome-key', chromeKey);
80
+ text.textContent = element.text;
81
+ parent.appendChild(text);
82
+ }
83
+
84
+ function renderChrome(parent: SVGElement, layout: ChartLayout): void {
85
+ const g = createSVGElement('g');
86
+ g.setAttribute('class', 'viz-chrome');
87
+
88
+ const { chrome } = layout;
89
+
90
+ // Top chrome: render at their stored y positions (already absolute)
91
+ if (chrome.title) {
92
+ renderChromeElement(g, chrome.title, 'viz-title', 'title');
93
+ }
94
+ if (chrome.subtitle) {
95
+ renderChromeElement(g, chrome.subtitle, 'viz-subtitle', 'subtitle');
96
+ }
97
+
98
+ // Bottom chrome starts below x-axis labels/title, not at chart area bottom.
99
+ // X-axis tick labels render at +14, axis title at +35. Account for that
100
+ // so source/byline/footer don't overlap axis content.
101
+ const xAxisExtent = layout.axes.x ? (layout.axes.x.label ? 48 : 26) : 0;
102
+ const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
103
+ if (chrome.source) {
104
+ renderChromeElement(
105
+ g,
106
+ { ...chrome.source, y: bottomOffset + chrome.source.y },
107
+ 'viz-source',
108
+ 'source',
109
+ );
110
+ }
111
+ if (chrome.byline) {
112
+ renderChromeElement(
113
+ g,
114
+ { ...chrome.byline, y: bottomOffset + chrome.byline.y },
115
+ 'viz-byline',
116
+ 'byline',
117
+ );
118
+ }
119
+ if (chrome.footer) {
120
+ renderChromeElement(
121
+ g,
122
+ { ...chrome.footer, y: bottomOffset + chrome.footer.y },
123
+ 'viz-footer',
124
+ 'footer',
125
+ );
126
+ }
127
+
128
+ parent.appendChild(g);
129
+ }
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Axis rendering
133
+ // ---------------------------------------------------------------------------
134
+
135
+ function renderAxis(
136
+ parent: SVGElement,
137
+ axis: AxisLayout,
138
+ orientation: 'x' | 'y',
139
+ layout: ChartLayout,
140
+ ): void {
141
+ const g = createSVGElement('g');
142
+ g.setAttribute('class', `viz-axis viz-axis-${orientation}`);
143
+
144
+ const { area } = layout;
145
+
146
+ // Only draw axis line for x-axis (bottom baseline).
147
+ // Horizontal gridlines already guide y-values, so the vertical y-axis line is redundant.
148
+ if (orientation === 'x') {
149
+ const line = createSVGElement('line');
150
+ line.setAttribute('class', 'viz-axis-line');
151
+ setAttrs(line, {
152
+ x1: axis.start.x,
153
+ y1: axis.start.y,
154
+ x2: axis.end.x,
155
+ y2: axis.end.y,
156
+ stroke: layout.theme.colors.axis,
157
+ 'stroke-width': 1,
158
+ });
159
+ g.appendChild(line);
160
+ }
161
+
162
+ // Ticks and labels
163
+ // Tick positions are absolute pixel coordinates from D3 scales whose range
164
+ // was set to [chartArea.x, chartArea.x + chartArea.width] (and similarly for y).
165
+ // Don't add area.x/area.y again or you'll double-offset everything.
166
+ for (const tick of axis.ticks) {
167
+ if (orientation === 'x') {
168
+ // Label (no tick marks -- gridlines provide sufficient reference)
169
+ const label = createSVGElement('text');
170
+ label.setAttribute('class', 'viz-axis-tick');
171
+ setAttrs(label, {
172
+ x: tick.position,
173
+ y: area.y + area.height + 14,
174
+ 'text-anchor': 'middle',
175
+ });
176
+ applyTextStyle(label, axis.tickLabelStyle);
177
+ label.textContent = tick.label;
178
+ g.appendChild(label);
179
+ } else {
180
+ // Label (no tick marks -- gridlines provide sufficient reference)
181
+ const label = createSVGElement('text');
182
+ label.setAttribute('class', 'viz-axis-tick');
183
+ setAttrs(label, {
184
+ x: area.x - 6,
185
+ y: tick.position,
186
+ 'text-anchor': 'end',
187
+ 'dominant-baseline': 'central',
188
+ });
189
+ applyTextStyle(label, axis.tickLabelStyle);
190
+ label.textContent = tick.label;
191
+ g.appendChild(label);
192
+ }
193
+ }
194
+
195
+ // Gridlines (positions are also absolute from the scales)
196
+ for (const gridline of axis.gridlines) {
197
+ const gl = createSVGElement('line');
198
+ gl.setAttribute('class', 'viz-gridline');
199
+ if (orientation === 'y') {
200
+ setAttrs(gl, {
201
+ x1: area.x,
202
+ y1: gridline.position,
203
+ x2: area.x + area.width,
204
+ y2: gridline.position,
205
+ stroke: layout.theme.colors.gridline,
206
+ 'stroke-width': 1,
207
+ 'stroke-opacity': 0.35,
208
+ });
209
+ } else {
210
+ setAttrs(gl, {
211
+ x1: gridline.position,
212
+ y1: area.y,
213
+ x2: gridline.position,
214
+ y2: area.y + area.height,
215
+ stroke: layout.theme.colors.gridline,
216
+ 'stroke-width': 1,
217
+ 'stroke-opacity': 0.35,
218
+ });
219
+ }
220
+ g.appendChild(gl);
221
+ }
222
+
223
+ // Axis label
224
+ if (axis.label && axis.labelStyle) {
225
+ const axisLabel = createSVGElement('text');
226
+ axisLabel.setAttribute('class', 'viz-axis-title');
227
+ applyTextStyle(axisLabel, axis.labelStyle);
228
+ axisLabel.textContent = axis.label;
229
+
230
+ if (orientation === 'x') {
231
+ setAttrs(axisLabel, {
232
+ x: area.x + area.width / 2,
233
+ y: area.y + area.height + 35,
234
+ 'text-anchor': 'middle',
235
+ });
236
+ } else {
237
+ // Rotated y-axis label
238
+ setAttrs(axisLabel, {
239
+ x: area.x - 45,
240
+ y: area.y + area.height / 2,
241
+ 'text-anchor': 'middle',
242
+ transform: `rotate(-90, ${area.x - 45}, ${area.y + area.height / 2})`,
243
+ });
244
+ }
245
+ g.appendChild(axisLabel);
246
+ }
247
+
248
+ parent.appendChild(g);
249
+ }
250
+
251
+ function renderAxes(parent: SVGElement, layout: ChartLayout): void {
252
+ if (layout.axes.x) {
253
+ renderAxis(parent, layout.axes.x, 'x', layout);
254
+ }
255
+ if (layout.axes.y) {
256
+ renderAxis(parent, layout.axes.y, 'y', layout);
257
+ }
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Mark rendering (dispatch per mark type)
262
+ // ---------------------------------------------------------------------------
263
+
264
+ type MarkRenderer<T extends Mark> = (mark: T, index: number) => SVGElement;
265
+
266
+ const markRenderers: Record<string, MarkRenderer<Mark>> = {};
267
+
268
+ /**
269
+ * Register a mark renderer for a specific mark type.
270
+ * Built-in renderers are registered below for all chart types.
271
+ */
272
+ export function registerMarkRenderer<T extends Mark>(
273
+ type: T['type'],
274
+ renderer: MarkRenderer<T>,
275
+ ): void {
276
+ markRenderers[type] = renderer as MarkRenderer<Mark>;
277
+ }
278
+
279
+ function renderLineMark(mark: LineMark, index: number): SVGElement {
280
+ const g = createSVGElement('g');
281
+ g.setAttribute('data-mark-id', `line-${mark.seriesKey ?? index}`);
282
+ g.setAttribute('class', 'viz-mark viz-mark-line');
283
+
284
+ if (mark.points.length > 1) {
285
+ const path = createSVGElement('path');
286
+ // Use the pre-computed D3 curve path when available (smooth monotone),
287
+ // otherwise fall back to straight M/L segments.
288
+ const d =
289
+ mark.path ?? mark.points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
290
+ setAttrs(path, {
291
+ d,
292
+ fill: 'none',
293
+ stroke: mark.stroke,
294
+ 'stroke-width': mark.strokeWidth,
295
+ });
296
+ if (mark.strokeDasharray) {
297
+ path.setAttribute('stroke-dasharray', mark.strokeDasharray);
298
+ }
299
+ g.appendChild(path);
300
+ }
301
+
302
+ // Render end-of-line label if present and visible
303
+ if (mark.label?.visible) {
304
+ const label = createSVGElement('text');
305
+ label.setAttribute('class', 'viz-mark-label');
306
+ if (mark.seriesKey) {
307
+ label.setAttribute('data-series', mark.seriesKey);
308
+ }
309
+ setAttrs(label, { x: mark.label.x, y: mark.label.y });
310
+ applyTextStyle(label, mark.label.style);
311
+ label.textContent = mark.label.text;
312
+ g.appendChild(label);
313
+
314
+ // Render connector line if label was offset from anchor
315
+ if (mark.label.connector) {
316
+ const connector = createSVGElement('line');
317
+ connector.setAttribute('class', 'viz-mark-connector');
318
+ setAttrs(connector, {
319
+ x1: mark.label.connector.from.x,
320
+ y1: mark.label.connector.from.y,
321
+ x2: mark.label.connector.to.x,
322
+ y2: mark.label.connector.to.y,
323
+ stroke: mark.label.connector.stroke,
324
+ 'stroke-width': 1,
325
+ 'stroke-opacity': 0.5,
326
+ });
327
+ g.appendChild(connector);
328
+ }
329
+ }
330
+
331
+ return g;
332
+ }
333
+
334
+ function renderAreaMark(mark: AreaMark, index: number): SVGElement {
335
+ const g = createSVGElement('g');
336
+ g.setAttribute('data-mark-id', `area-${mark.seriesKey ?? index}`);
337
+ g.setAttribute('class', 'viz-mark viz-mark-area');
338
+
339
+ if (mark.path) {
340
+ // Area fill: the full closed shape (top line + baseline), no stroke
341
+ const fill = createSVGElement('path');
342
+ setAttrs(fill, {
343
+ d: mark.path,
344
+ fill: mark.fill,
345
+ 'fill-opacity': mark.fillOpacity,
346
+ stroke: 'none',
347
+ });
348
+ g.appendChild(fill);
349
+
350
+ // Top-line stroke: only along the data points, not the baseline
351
+ if (mark.stroke && mark.topPath) {
352
+ const strokePath = createSVGElement('path');
353
+ setAttrs(strokePath, {
354
+ d: mark.topPath,
355
+ fill: 'none',
356
+ stroke: mark.stroke,
357
+ 'stroke-width': mark.strokeWidth ?? 1,
358
+ });
359
+ g.appendChild(strokePath);
360
+ }
361
+ }
362
+
363
+ return g;
364
+ }
365
+
366
+ function renderRectMark(mark: RectMark, index: number): SVGElement {
367
+ const g = createSVGElement('g');
368
+ g.setAttribute('data-mark-id', `rect-${index}`);
369
+ g.setAttribute('class', 'viz-mark viz-mark-rect');
370
+
371
+ const rect = createSVGElement('rect');
372
+ setAttrs(rect, {
373
+ x: mark.x,
374
+ y: mark.y,
375
+ width: mark.width,
376
+ height: mark.height,
377
+ fill: mark.fill,
378
+ });
379
+ if (mark.stroke) {
380
+ rect.setAttribute('stroke', mark.stroke);
381
+ }
382
+ if (mark.strokeWidth) {
383
+ rect.setAttribute('stroke-width', String(mark.strokeWidth));
384
+ }
385
+ if (mark.cornerRadius) {
386
+ setAttrs(rect, { rx: mark.cornerRadius, ry: mark.cornerRadius });
387
+ }
388
+ g.appendChild(rect);
389
+
390
+ // Render value label if present and visible
391
+ if (mark.label?.visible) {
392
+ const label = createSVGElement('text');
393
+ label.setAttribute('class', 'viz-mark-label');
394
+ setAttrs(label, { x: mark.label.x, y: mark.label.y });
395
+ applyTextStyle(label, mark.label.style);
396
+ label.textContent = mark.label.text;
397
+ g.appendChild(label);
398
+ }
399
+
400
+ return g;
401
+ }
402
+
403
+ function renderArcMark(mark: ArcMark, index: number): SVGElement {
404
+ const g = createSVGElement('g');
405
+ g.setAttribute('data-mark-id', `arc-${index}`);
406
+ g.setAttribute('class', 'viz-mark viz-mark-arc');
407
+ g.setAttribute('transform', `translate(${mark.center.x},${mark.center.y})`);
408
+
409
+ const path = createSVGElement('path');
410
+ setAttrs(path, {
411
+ d: mark.path,
412
+ fill: mark.fill,
413
+ stroke: mark.stroke,
414
+ 'stroke-width': mark.strokeWidth,
415
+ });
416
+ g.appendChild(path);
417
+
418
+ // Render label if present and visible
419
+ if (mark.label?.visible) {
420
+ const label = createSVGElement('text');
421
+ label.setAttribute('class', 'viz-mark-label');
422
+ // Label position is in absolute coords, but we're in a translated group,
423
+ // so subtract the center offset
424
+ setAttrs(label, {
425
+ x: mark.label.x - mark.center.x,
426
+ y: mark.label.y - mark.center.y,
427
+ });
428
+ applyTextStyle(label, mark.label.style);
429
+ label.textContent = mark.label.text;
430
+ g.appendChild(label);
431
+ }
432
+
433
+ return g;
434
+ }
435
+
436
+ function renderPointMark(mark: PointMark, index: number): SVGElement {
437
+ const circle = createSVGElement('circle');
438
+ circle.setAttribute('data-mark-id', `point-${index}`);
439
+ circle.setAttribute('class', 'viz-mark viz-mark-point');
440
+ setAttrs(circle, {
441
+ cx: mark.cx,
442
+ cy: mark.cy,
443
+ r: mark.r,
444
+ fill: mark.fill,
445
+ stroke: mark.stroke,
446
+ 'stroke-width': mark.strokeWidth,
447
+ });
448
+ if (mark.fillOpacity !== undefined) {
449
+ circle.setAttribute('fill-opacity', String(mark.fillOpacity));
450
+ }
451
+ return circle;
452
+ }
453
+
454
+ // Register built-in renderers
455
+ registerMarkRenderer('line', renderLineMark as MarkRenderer<Mark>);
456
+ registerMarkRenderer('area', renderAreaMark as MarkRenderer<Mark>);
457
+ registerMarkRenderer('rect', renderRectMark as MarkRenderer<Mark>);
458
+ registerMarkRenderer('arc', renderArcMark as MarkRenderer<Mark>);
459
+ registerMarkRenderer('point', renderPointMark as MarkRenderer<Mark>);
460
+
461
+ /** Extract series name from a mark for legend toggle matching. */
462
+ function getMarkSeries(mark: Mark): string | undefined {
463
+ // Line and area marks have an explicit seriesKey
464
+ if (mark.type === 'line' || mark.type === 'area') {
465
+ return mark.seriesKey;
466
+ }
467
+ // For arc marks, the category name is the first part of the aria label (before ':')
468
+ if (mark.type === 'arc') {
469
+ return mark.aria.label.split(':')[0]?.trim();
470
+ }
471
+ // For rect/point, the aria label may be "category: value" or "category, group: value".
472
+ // The series name is the category part (before the colon).
473
+ if (mark.aria?.label) {
474
+ const beforeColon = mark.aria.label.split(':')[0]?.trim();
475
+ if (beforeColon) return beforeColon;
476
+ }
477
+ return undefined;
478
+ }
479
+
480
+ function renderMarks(parent: SVGElement, layout: ChartLayout): void {
481
+ const g = createSVGElement('g');
482
+ g.setAttribute('class', 'viz-marks');
483
+
484
+ for (let i = 0; i < layout.marks.length; i++) {
485
+ const mark = layout.marks[i];
486
+ const renderer = markRenderers[mark.type];
487
+ if (renderer) {
488
+ const el = renderer(mark, i);
489
+ // Add ARIA label if present
490
+ if (mark.aria?.label) {
491
+ el.setAttribute('aria-label', mark.aria.label);
492
+ }
493
+ // Add data-series attribute for legend toggle matching
494
+ const series = getMarkSeries(mark);
495
+ if (series) {
496
+ el.setAttribute('data-series', series);
497
+ }
498
+ g.appendChild(el);
499
+ }
500
+ }
501
+
502
+ parent.appendChild(g);
503
+ }
504
+
505
+ // ---------------------------------------------------------------------------
506
+ // Annotation rendering
507
+ // ---------------------------------------------------------------------------
508
+
509
+ function renderAnnotations(parent: SVGElement, layout: ChartLayout): void {
510
+ if (layout.annotations.length === 0) return;
511
+
512
+ const g = createSVGElement('g');
513
+ g.setAttribute('class', 'viz-annotations');
514
+
515
+ // Annotations are already sorted by zIndex from the engine, so render in order
516
+ for (let i = 0; i < layout.annotations.length; i++) {
517
+ renderAnnotation(g, layout.annotations[i], i);
518
+ }
519
+
520
+ parent.appendChild(g);
521
+ }
522
+
523
+ /**
524
+ * Render a curved arrow connector from a label to a data point.
525
+ * Uses a cubic bezier that sweeps outward then curves toward the
526
+ * target, with a triangular arrowhead at the tip.
527
+ */
528
+ function renderCurvedArrow(parent: SVGElement, from: Point, to: Point, stroke: string): void {
529
+ // Pad above the target so the arrow doesn't sit right on the element.
530
+ const pad = 6;
531
+ const tipY = to.y - pad;
532
+
533
+ const dy = tipY - from.y;
534
+ const dist = Math.sqrt((to.x - from.x) ** 2 + dy ** 2) || 1;
535
+
536
+ // Arrowhead geometry
537
+ const arrowLen = 8;
538
+ const arrowWidth = 4;
539
+
540
+ // cp2 directly above target so arrow arrives pointing straight down.
541
+ const bulge = Math.max(dist * 0.4, 35);
542
+ const cp1x = from.x + bulge;
543
+ const cp1y = from.y + dy * 0.35;
544
+ const cp2x = to.x;
545
+ const cp2y = tipY - Math.abs(dy) * 0.25;
546
+
547
+ // Tangent at the tip (from cp2 → tip), used for arrowhead direction.
548
+ const tx = to.x - cp2x;
549
+ const ty = tipY - cp2y;
550
+ const tLen = Math.sqrt(tx * tx + ty * ty) || 1;
551
+ const ux = tx / tLen;
552
+ const uy = ty / tLen;
553
+
554
+ // End the curve path at the arrowhead BASE so the stroke doesn't
555
+ // poke through the filled triangle.
556
+ const baseX = to.x - ux * arrowLen;
557
+ const baseY = tipY - uy * arrowLen;
558
+
559
+ const path = createSVGElement('path');
560
+ path.setAttribute('class', 'viz-annotation-connector');
561
+ setAttrs(path, {
562
+ d: `M ${from.x} ${from.y} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${baseX} ${baseY}`,
563
+ fill: 'none',
564
+ stroke,
565
+ 'stroke-width': 1.5,
566
+ });
567
+ parent.appendChild(path);
568
+
569
+ // Arrowhead triangle: perpendicular to tangent direction.
570
+ const px = -uy;
571
+ const py = ux;
572
+
573
+ const arrow = createSVGElement('polygon');
574
+ arrow.setAttribute('class', 'viz-annotation-connector');
575
+ setAttrs(arrow, {
576
+ points: [
577
+ `${to.x},${tipY}`,
578
+ `${baseX + px * arrowWidth},${baseY + py * arrowWidth}`,
579
+ `${baseX - px * arrowWidth},${baseY - py * arrowWidth}`,
580
+ ].join(' '),
581
+ fill: stroke,
582
+ });
583
+ parent.appendChild(arrow);
584
+ }
585
+
586
+ function renderAnnotation(parent: SVGElement, annotation: ResolvedAnnotation, index: number): void {
587
+ const g = createSVGElement('g');
588
+ g.setAttribute('class', `viz-annotation viz-annotation-${annotation.type}`);
589
+ g.setAttribute('data-annotation-index', String(index));
590
+
591
+ // Range rect
592
+ if (annotation.rect) {
593
+ const rect = createSVGElement('rect');
594
+ rect.setAttribute('class', 'viz-annotation-range');
595
+ setAttrs(rect, {
596
+ x: annotation.rect.x,
597
+ y: annotation.rect.y,
598
+ width: annotation.rect.width,
599
+ height: annotation.rect.height,
600
+ });
601
+ if (annotation.fill) rect.setAttribute('fill', annotation.fill);
602
+ if (annotation.opacity !== undefined) {
603
+ rect.setAttribute('fill-opacity', String(annotation.opacity));
604
+ }
605
+ g.appendChild(rect);
606
+ }
607
+
608
+ // Reference line
609
+ if (annotation.line) {
610
+ const line = createSVGElement('line');
611
+ line.setAttribute('class', 'viz-annotation-line');
612
+ setAttrs(line, {
613
+ x1: annotation.line.start.x,
614
+ y1: annotation.line.start.y,
615
+ x2: annotation.line.end.x,
616
+ y2: annotation.line.end.y,
617
+ 'stroke-width': annotation.strokeWidth ?? 1,
618
+ });
619
+ if (annotation.stroke) line.setAttribute('stroke', annotation.stroke);
620
+ if (annotation.strokeDasharray) {
621
+ line.setAttribute('stroke-dasharray', annotation.strokeDasharray);
622
+ }
623
+ g.appendChild(line);
624
+ }
625
+
626
+ // Label with optional connector line
627
+ if (annotation.label?.visible) {
628
+ // Render connector first (behind the label text)
629
+ if (annotation.label.connector) {
630
+ const c = annotation.label.connector;
631
+ if (c.style === 'caret') {
632
+ // Small directional chevron centered in the gap between the label
633
+ // text and the data mark, pointing toward the data.
634
+ const pointsDown = c.to.y > c.from.y;
635
+ const caretSize = 4;
636
+ // c.from.y is near the text baseline, not the visual bottom.
637
+ // Estimate the text bottom from the label's line count and font size.
638
+ const labelLines = annotation.label.text.split('\n');
639
+ const labelFontSize = annotation.label.style.fontSize ?? 12;
640
+ const labelLineHeight = labelFontSize * (annotation.label.style.lineHeight ?? 1.3);
641
+ const textBottom =
642
+ annotation.label.y + (labelLines.length - 1) * labelLineHeight + labelFontSize * 0.25;
643
+ const textTop = annotation.label.y - labelFontSize;
644
+ // Center caret in the gap between text edge and data point
645
+ const gapEdge = pointsDown ? textBottom : textTop;
646
+ const midY = (gapEdge + c.to.y) / 2;
647
+ const tipX = c.to.x;
648
+ const tipY = pointsDown ? midY + caretSize / 2 : midY - caretSize / 2;
649
+ const baseY = pointsDown ? tipY - caretSize : tipY + caretSize;
650
+ const path = createSVGElement('path');
651
+ path.setAttribute('class', 'viz-annotation-connector');
652
+ setAttrs(path, {
653
+ d: `M${tipX - caretSize},${baseY} L${tipX},${tipY} L${tipX + caretSize},${baseY}`,
654
+ fill: 'none',
655
+ stroke: c.stroke,
656
+ 'stroke-width': 1.5,
657
+ 'stroke-opacity': 0.4,
658
+ 'stroke-linecap': 'round',
659
+ 'stroke-linejoin': 'round',
660
+ });
661
+ g.appendChild(path);
662
+ } else if (c.style === 'curve') {
663
+ renderCurvedArrow(g, c.from, c.to, c.stroke);
664
+ } else {
665
+ const connector = createSVGElement('line');
666
+ connector.setAttribute('class', 'viz-annotation-connector');
667
+ setAttrs(connector, {
668
+ x1: c.from.x,
669
+ y1: c.from.y,
670
+ x2: c.to.x,
671
+ y2: c.to.y,
672
+ stroke: c.stroke,
673
+ 'stroke-width': 1,
674
+ 'stroke-opacity': 0.5,
675
+ });
676
+ g.appendChild(connector);
677
+ }
678
+ }
679
+
680
+ const text = createSVGElement('text');
681
+ text.setAttribute('class', 'viz-annotation-label');
682
+ setAttrs(text, { x: annotation.label.x, y: annotation.label.y });
683
+ applyTextStyle(text, annotation.label.style);
684
+
685
+ const lines = annotation.label.text.split('\n');
686
+ const fontSize = annotation.label.style.fontSize ?? 12;
687
+ const lineHeight = fontSize * (annotation.label.style.lineHeight ?? 1.3);
688
+ const isMultiLine = lines.length > 1;
689
+
690
+ // Multi-line text uses center alignment for a cleaner look
691
+ if (isMultiLine) {
692
+ text.setAttribute('text-anchor', 'middle');
693
+ for (let i = 0; i < lines.length; i++) {
694
+ const tspan = createSVGElement('tspan');
695
+ setAttrs(tspan, { x: annotation.label.x, dy: i === 0 ? 0 : lineHeight });
696
+ tspan.textContent = lines[i];
697
+ text.appendChild(tspan);
698
+ }
699
+ } else {
700
+ text.textContent = annotation.label.text;
701
+ }
702
+
703
+ // Render background rect behind text if specified
704
+ if (annotation.label.background) {
705
+ const charWidth = fontSize * 0.55;
706
+ const maxLineWidth = Math.max(...lines.map((l) => l.length)) * charWidth;
707
+ const totalHeight = lines.length * lineHeight;
708
+ const pad = 3;
709
+ const bgX = isMultiLine
710
+ ? annotation.label.x - maxLineWidth / 2 - pad
711
+ : annotation.label.x - pad;
712
+ const bgRect = createSVGElement('rect');
713
+ bgRect.setAttribute('class', 'viz-annotation-bg');
714
+ setAttrs(bgRect, {
715
+ x: bgX,
716
+ y: annotation.label.y - fontSize + (lineHeight - fontSize) / 2 - pad,
717
+ width: maxLineWidth + pad * 2,
718
+ height: totalHeight + pad * 2,
719
+ fill: annotation.label.background,
720
+ rx: 2,
721
+ });
722
+ g.appendChild(bgRect);
723
+ }
724
+
725
+ g.appendChild(text);
726
+ }
727
+
728
+ parent.appendChild(g);
729
+ }
730
+
731
+ // ---------------------------------------------------------------------------
732
+ // Legend rendering
733
+ // ---------------------------------------------------------------------------
734
+
735
+ function renderLegend(parent: SVGElement, legend: LegendLayout): void {
736
+ if (legend.entries.length === 0) return;
737
+
738
+ const g = createSVGElement('g');
739
+ g.setAttribute('class', 'viz-legend');
740
+ g.setAttribute('role', 'list');
741
+ g.setAttribute('aria-label', 'Chart legend');
742
+
743
+ const isHorizontal = legend.position === 'top' || legend.position === 'bottom';
744
+ let offsetX = legend.bounds.x;
745
+ let offsetY = legend.bounds.y;
746
+
747
+ for (let i = 0; i < legend.entries.length; i++) {
748
+ const entry = legend.entries[i];
749
+ const entryG = createSVGElement('g');
750
+ entryG.setAttribute('class', 'viz-legend-entry');
751
+ entryG.setAttribute('role', 'listitem');
752
+ entryG.setAttribute('data-legend-index', String(i));
753
+ entryG.setAttribute('data-legend-label', entry.label);
754
+ entryG.setAttribute(
755
+ 'aria-label',
756
+ `${entry.label}: ${entry.active !== false ? 'visible' : 'hidden'}`,
757
+ );
758
+ entryG.setAttribute('style', 'cursor: pointer');
759
+
760
+ // Apply dimming for inactive entries
761
+ if (entry.active === false) {
762
+ entryG.setAttribute('opacity', '0.3');
763
+ }
764
+
765
+ // Swatch
766
+ if (entry.shape === 'circle') {
767
+ const circle = createSVGElement('circle');
768
+ setAttrs(circle, {
769
+ cx: offsetX + legend.swatchSize / 2,
770
+ cy: offsetY + legend.swatchSize / 2,
771
+ r: legend.swatchSize / 2,
772
+ fill: entry.color,
773
+ });
774
+ entryG.appendChild(circle);
775
+ } else if (entry.shape === 'line') {
776
+ // Line swatch: a short line segment with a dot in the middle
777
+ const line = createSVGElement('line');
778
+ setAttrs(line, {
779
+ x1: offsetX,
780
+ y1: offsetY + legend.swatchSize / 2,
781
+ x2: offsetX + legend.swatchSize,
782
+ y2: offsetY + legend.swatchSize / 2,
783
+ stroke: entry.color,
784
+ 'stroke-width': 2,
785
+ });
786
+ entryG.appendChild(line);
787
+ // Small dot at center
788
+ const dot = createSVGElement('circle');
789
+ setAttrs(dot, {
790
+ cx: offsetX + legend.swatchSize / 2,
791
+ cy: offsetY + legend.swatchSize / 2,
792
+ r: 2.5,
793
+ fill: entry.color,
794
+ });
795
+ entryG.appendChild(dot);
796
+ } else {
797
+ const rect = createSVGElement('rect');
798
+ setAttrs(rect, {
799
+ x: offsetX,
800
+ y: offsetY,
801
+ width: legend.swatchSize,
802
+ height: legend.swatchSize,
803
+ fill: entry.color,
804
+ rx: 2,
805
+ });
806
+ entryG.appendChild(rect);
807
+ }
808
+
809
+ // Label
810
+ const label = createSVGElement('text');
811
+ setAttrs(label, {
812
+ x: offsetX + legend.swatchSize + legend.swatchGap,
813
+ y: offsetY + legend.swatchSize / 2,
814
+ 'dominant-baseline': 'central',
815
+ });
816
+ applyTextStyle(label, legend.labelStyle);
817
+ label.textContent = entry.label;
818
+ entryG.appendChild(label);
819
+
820
+ g.appendChild(entryG);
821
+
822
+ // Advance position for next entry
823
+ if (isHorizontal) {
824
+ const labelWidth = estimateTextWidth(
825
+ entry.label,
826
+ legend.labelStyle.fontSize,
827
+ legend.labelStyle.fontWeight,
828
+ );
829
+ const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
830
+ offsetX += entryWidth;
831
+ // Wrap to next line if exceeding bounds
832
+ if (offsetX > legend.bounds.x + legend.bounds.width && i < legend.entries.length - 1) {
833
+ offsetX = legend.bounds.x;
834
+ offsetY += legend.swatchSize + 6;
835
+ }
836
+ } else {
837
+ offsetY += legend.swatchSize + legend.entryGap;
838
+ }
839
+ }
840
+
841
+ parent.appendChild(g);
842
+ }
843
+
844
+ // ---------------------------------------------------------------------------
845
+ // Main render function
846
+ // ---------------------------------------------------------------------------
847
+
848
+ /**
849
+ * Render a compiled ChartLayout into an SVG element and append it to a container.
850
+ *
851
+ * @param layout - Compiled ChartLayout from compileChart().
852
+ * @param container - DOM element to mount the SVG into.
853
+ * @returns The created SVG element.
854
+ */
855
+ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVGElement {
856
+ const { width, height } = layout.dimensions;
857
+
858
+ const svg = createSVGElement('svg') as SVGSVGElement;
859
+ setAttrs(svg, {
860
+ viewBox: `0 0 ${width} ${height}`,
861
+ xmlns: SVG_NS,
862
+ });
863
+ svg.setAttribute('role', layout.a11y.role);
864
+ svg.setAttribute('aria-label', layout.a11y.altText);
865
+ svg.setAttribute('class', 'viz-chart');
866
+
867
+ // Background
868
+ const bg = createSVGElement('rect');
869
+ setAttrs(bg, {
870
+ x: 0,
871
+ y: 0,
872
+ width,
873
+ height,
874
+ fill: layout.theme.colors.background,
875
+ });
876
+ svg.appendChild(bg);
877
+
878
+ // Clip path to prevent marks (especially area fills) from overflowing
879
+ // into the chrome region (title/subtitle). Extends full width so
880
+ // end-of-line labels aren't clipped, but constrains vertically.
881
+ const clipId = `viz-clip-${Math.random().toString(36).slice(2, 8)}`;
882
+ const defs = createSVGElement('defs');
883
+ const clipPath = createSVGElement('clipPath');
884
+ clipPath.setAttribute('id', clipId);
885
+ const clipRect = createSVGElement('rect');
886
+ setAttrs(clipRect, {
887
+ x: 0,
888
+ y: layout.area.y,
889
+ width,
890
+ height: layout.area.height + 2,
891
+ });
892
+ clipPath.appendChild(clipRect);
893
+ defs.appendChild(clipPath);
894
+ svg.appendChild(defs);
895
+
896
+ // Render layers in order (back to front)
897
+ // Axes render outside clip (labels extend beyond chart area)
898
+ renderAxes(svg, layout);
899
+
900
+ // Marks are clipped to chart area so area fills don't cover chrome
901
+ const clippedGroup = createSVGElement('g');
902
+ clippedGroup.setAttribute('clip-path', `url(#${clipId})`);
903
+ renderMarks(clippedGroup, layout);
904
+ svg.appendChild(clippedGroup);
905
+
906
+ renderAnnotations(svg, layout);
907
+ renderLegend(svg, layout.legend);
908
+
909
+ // Chrome renders on top so titles are never obscured by chart elements
910
+ renderChrome(svg, layout);
911
+
912
+ container.appendChild(svg);
913
+ return svg;
914
+ }