@opendata-ai/openchart-vanilla 6.19.3 → 6.21.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.
@@ -5,63 +5,25 @@
5
5
  * renders chrome (title/subtitle/source), axes, marks, annotations,
6
6
  * and legend. All styling via inline SVG attributes from layout data.
7
7
  *
8
- * Mark rendering dispatches per mark type with dedicated renderers
9
- * for line, area, rect, arc, and point marks.
8
+ * This file is the orchestrator only. Each rendering concern lives in its
9
+ * own module under `./renderers/`.
10
10
  */
11
11
 
12
- import type {
13
- ArcMark,
14
- AreaMark,
15
- AxisLayout,
16
- ChartLayout,
17
- LegendLayout,
18
- LineMark,
19
- Mark,
20
- MeasureTextFn,
21
- Point,
22
- PointMark,
23
- RectMark,
24
- ResolvedAnimation,
25
- ResolvedAnnotation,
26
- ResolvedChromeElement,
27
- RuleMarkLayout,
28
- TextMarkLayout,
29
- TextStyle,
30
- TickMarkLayout,
31
- } from '@opendata-ai/openchart-core';
32
- import { BRAND_FONT_SIZE, BRAND_MIN_WIDTH, estimateTextWidth } from '@opendata-ai/openchart-core';
12
+ import type { ChartLayout, RectMark } from '@opendata-ai/openchart-core';
33
13
  import { clampStaggerDelay } from '@opendata-ai/openchart-engine';
34
- import { buildGradientDefs, resolveMarkFill } from './gradient-utils';
35
-
36
- const SVG_NS = 'http://www.w3.org/2000/svg';
37
-
38
- /**
39
- * Module-level animation state. Set by renderChartSVG before rendering marks
40
- * so mark renderers can read it without changing their function signatures.
41
- */
42
- let currentAnimation: ResolvedAnimation | undefined;
43
-
44
- /**
45
- * Module-level gradient map. Set by renderChartSVG after building gradient defs
46
- * so mark renderers can resolve gradient fills without signature changes.
47
- */
48
- let currentGradientMap: Map<string, string> = new Map();
49
-
50
- /**
51
- * Stamp animation index attributes on a mark element when animation is enabled.
52
- * Sets `data-animation-index` (for querySelector) and `--oc-mark-index`
53
- * (for CSS calc-based stagger delay).
54
- */
55
- function stampAnimationAttrs(
56
- el: SVGElement,
57
- mark: { animationIndex?: number },
58
- fallbackIndex: number,
59
- ): void {
60
- if (!currentAnimation?.enabled) return;
61
- const idx = mark.animationIndex ?? fallbackIndex;
62
- el.setAttribute('data-animation-index', String(idx));
63
- (el as SVGElement & ElementCSSInlineStyle).style.setProperty('--oc-mark-index', String(idx));
64
- }
14
+ import { buildGradientDefs } from './gradient-utils';
15
+ import { renderAnnotations } from './renderers/annotations';
16
+ import { renderAxes } from './renderers/axes';
17
+ import { renderBrand } from './renderers/brand';
18
+ import { renderChrome } from './renderers/chrome';
19
+ import { renderLegend } from './renderers/legend';
20
+ import { renderMarks, resetMarkRenderState, setMarkRenderState } from './renderers/marks';
21
+ import { createSVGElement, SVG_NS, setAttrs } from './renderers/svg-dom';
22
+ import { nextSvgId } from './svg-ids';
23
+
24
+ // Re-export registerMarkRenderer so external consumers can still register
25
+ // custom mark renderers via the vanilla package entry point.
26
+ export { registerMarkRenderer } from './renderers/marks';
65
27
 
66
28
  /** CSS easing preset map for inline style custom properties. */
67
29
  const EASE_VAR_MAP: Record<string, string> = {
@@ -69,1175 +31,6 @@ const EASE_VAR_MAP: Record<string, string> = {
69
31
  snappy: 'var(--oc-ease-snappy)',
70
32
  };
71
33
 
72
- /**
73
- * Compute the vertical extent of x-axis labels below the chart area.
74
- * Accounts for rotated tick labels which need more vertical space.
75
- */
76
- function computeXAxisExtent(layout: ChartLayout): number {
77
- const xAxis = layout.axes.x;
78
- if (!xAxis) return 0;
79
-
80
- if (xAxis.tickAngle && Math.abs(xAxis.tickAngle) > 10) {
81
- // Rotated labels: estimate height from the longest tick label.
82
- const fontSize = xAxis.tickLabelStyle.fontSize;
83
- const fontWeight = xAxis.tickLabelStyle.fontWeight;
84
- const angleRad = Math.abs(xAxis.tickAngle) * (Math.PI / 180);
85
- let maxLabelWidth = 40;
86
- for (const tick of xAxis.ticks) {
87
- const w = estimateTextWidth(tick.label, fontSize, fontWeight);
88
- if (w > maxLabelWidth) maxLabelWidth = w;
89
- }
90
- const rotatedHeight = Math.min(maxLabelWidth * Math.sin(angleRad) + 6, 120);
91
- return xAxis.label ? rotatedHeight + 20 : rotatedHeight;
92
- }
93
-
94
- return xAxis.label ? 48 : 26;
95
- }
96
-
97
- // ---------------------------------------------------------------------------
98
- // Helpers
99
- // ---------------------------------------------------------------------------
100
-
101
- function createSVGElement(tag: string): SVGElement {
102
- return document.createElementNS(SVG_NS, tag);
103
- }
104
-
105
- function setAttrs(el: SVGElement, attrs: Record<string, string | number>): void {
106
- for (const [key, value] of Object.entries(attrs)) {
107
- el.setAttribute(key, String(value));
108
- }
109
- }
110
-
111
- function applyTextStyle(el: SVGElement, style: TextStyle): void {
112
- setAttrs(el, {
113
- 'font-family': style.fontFamily,
114
- 'font-size': style.fontSize,
115
- 'font-weight': style.fontWeight,
116
- });
117
- // Use inline style for fill so it takes priority over CSS class defaults
118
- // (e.g. .oc-title { fill: var(--oc-text) } which would override attributes)
119
- (el as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', style.fill);
120
- if (style.textAnchor) {
121
- el.setAttribute('text-anchor', style.textAnchor);
122
- }
123
- if (style.dominantBaseline) {
124
- el.setAttribute('dominant-baseline', style.dominantBaseline);
125
- }
126
- if (style.fontVariant) {
127
- el.setAttribute('font-variant', style.fontVariant);
128
- }
129
- }
130
-
131
- // ---------------------------------------------------------------------------
132
- // Chrome rendering
133
- // ---------------------------------------------------------------------------
134
-
135
- /**
136
- * Break text into lines that fit within maxWidth using word wrapping.
137
- * Uses a character-width heuristic (same as text-measure.ts).
138
- */
139
- function wrapText(
140
- text: string,
141
- fontSize: number,
142
- fontWeight: number,
143
- maxWidth: number,
144
- measureText?: MeasureTextFn,
145
- ): string[] {
146
- if (maxWidth <= 0) return [text];
147
-
148
- // Split on explicit newlines first
149
- const segments = text.split('\n');
150
- if (segments.length > 1) {
151
- return segments.flatMap((segment) =>
152
- segment.length === 0 ? [''] : wrapText(segment, fontSize, fontWeight, maxWidth, measureText),
153
- );
154
- }
155
-
156
- // Use real text measurement when available
157
- if (measureText) {
158
- const textWidth = measureText(text, fontSize, fontWeight).width;
159
- if (textWidth <= maxWidth) return [text];
160
-
161
- const words = text.split(' ');
162
- const lines: string[] = [];
163
- let current = '';
164
-
165
- for (const word of words) {
166
- const candidate = current ? `${current} ${word}` : word;
167
- const candidateWidth = measureText(candidate, fontSize, fontWeight).width;
168
- if (candidateWidth > maxWidth && current) {
169
- lines.push(current);
170
- current = word;
171
- } else {
172
- current = candidate;
173
- }
174
- }
175
- if (current) lines.push(current);
176
-
177
- return lines;
178
- }
179
-
180
- // Heuristic character width matching text-measure.ts
181
- const AVG_CHAR_WIDTH = 0.57;
182
- const WEIGHT_FACTORS: Record<number, number> = {
183
- 100: 0.9,
184
- 200: 0.92,
185
- 300: 0.95,
186
- 400: 1.0,
187
- 500: 1.02,
188
- 600: 1.05,
189
- 700: 1.08,
190
- 800: 1.1,
191
- 900: 1.12,
192
- };
193
- const weightFactor = WEIGHT_FACTORS[fontWeight] ?? 1.0;
194
- const charWidth = fontSize * AVG_CHAR_WIDTH * weightFactor;
195
- const maxChars = Math.floor(maxWidth / charWidth);
196
-
197
- if (text.length <= maxChars) return [text];
198
-
199
- const words = text.split(' ');
200
- const lines: string[] = [];
201
- let current = '';
202
-
203
- for (const word of words) {
204
- const candidate = current ? `${current} ${word}` : word;
205
- if (candidate.length > maxChars && current) {
206
- lines.push(current);
207
- current = word;
208
- } else {
209
- current = candidate;
210
- }
211
- }
212
- if (current) lines.push(current);
213
-
214
- return lines;
215
- }
216
-
217
- function renderChromeElement(
218
- parent: SVGElement,
219
- element: ResolvedChromeElement,
220
- className: string,
221
- chromeKey: string,
222
- measureText?: MeasureTextFn,
223
- ): void {
224
- const text = createSVGElement('text');
225
- setAttrs(text, { x: element.x, y: element.y });
226
- applyTextStyle(text, element.style);
227
- text.setAttribute('class', className);
228
- text.setAttribute('data-chrome-key', chromeKey);
229
-
230
- const lines = wrapText(
231
- element.text,
232
- element.style.fontSize,
233
- element.style.fontWeight,
234
- element.maxWidth,
235
- measureText,
236
- );
237
-
238
- if (lines.length === 1) {
239
- text.textContent = element.text;
240
- } else {
241
- const lineHeight = element.style.fontSize * (element.style.lineHeight ?? 1.3);
242
- for (let i = 0; i < lines.length; i++) {
243
- const tspan = createSVGElement('tspan');
244
- setAttrs(tspan, { x: element.x, dy: i === 0 ? 0 : lineHeight });
245
- tspan.textContent = lines[i];
246
- text.appendChild(tspan);
247
- }
248
- }
249
-
250
- parent.appendChild(text);
251
- }
252
-
253
- function renderChrome(parent: SVGElement, layout: ChartLayout): void {
254
- const g = createSVGElement('g');
255
- g.setAttribute('class', 'oc-chrome');
256
-
257
- const { chrome, measureText } = layout;
258
-
259
- // Top chrome: render at their stored y positions (already absolute)
260
- if (chrome.title) {
261
- renderChromeElement(g, chrome.title, 'oc-title', 'title', measureText);
262
- }
263
- if (chrome.subtitle) {
264
- renderChromeElement(g, chrome.subtitle, 'oc-subtitle', 'subtitle', measureText);
265
- }
266
-
267
- // Bottom chrome starts below x-axis labels/title, not at chart area bottom.
268
- // Accounts for rotated tick labels which need more vertical space.
269
- const xAxisExtent = computeXAxisExtent(layout);
270
- const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
271
- if (chrome.source) {
272
- renderChromeElement(
273
- g,
274
- { ...chrome.source, y: bottomOffset + chrome.source.y },
275
- 'oc-source',
276
- 'source',
277
- measureText,
278
- );
279
- }
280
- if (chrome.byline) {
281
- renderChromeElement(
282
- g,
283
- { ...chrome.byline, y: bottomOffset + chrome.byline.y },
284
- 'oc-byline',
285
- 'byline',
286
- measureText,
287
- );
288
- }
289
- if (chrome.footer) {
290
- renderChromeElement(
291
- g,
292
- { ...chrome.footer, y: bottomOffset + chrome.footer.y },
293
- 'oc-footer',
294
- 'footer',
295
- measureText,
296
- );
297
- }
298
-
299
- parent.appendChild(g);
300
- }
301
-
302
- // ---------------------------------------------------------------------------
303
- // Axis rendering
304
- // ---------------------------------------------------------------------------
305
-
306
- function renderAxis(
307
- parent: SVGElement,
308
- axis: AxisLayout,
309
- orientation: 'x' | 'y',
310
- layout: ChartLayout,
311
- ): void {
312
- const g = createSVGElement('g');
313
- g.setAttribute('class', `oc-axis oc-axis-${orientation}`);
314
-
315
- const { area } = layout;
316
-
317
- // Only draw axis line for x-axis (bottom baseline).
318
- // Horizontal gridlines already guide y-values, so the vertical y-axis line is redundant.
319
- if (orientation === 'x') {
320
- const line = createSVGElement('line');
321
- line.setAttribute('class', 'oc-axis-line');
322
- setAttrs(line, {
323
- x1: axis.start.x,
324
- y1: axis.start.y,
325
- x2: axis.end.x,
326
- y2: axis.end.y,
327
- stroke: layout.theme.colors.axis,
328
- 'stroke-width': 1,
329
- });
330
- g.appendChild(line);
331
- }
332
-
333
- // Ticks and labels
334
- // Tick positions are absolute pixel coordinates from D3 scales whose range
335
- // was set to [chartArea.x, chartArea.x + chartArea.width] (and similarly for y).
336
- // Don't add area.x/area.y again or you'll double-offset everything.
337
- for (const tick of axis.ticks) {
338
- if (orientation === 'x') {
339
- // Label (no tick marks -- gridlines provide sufficient reference)
340
- const label = createSVGElement('text');
341
- label.setAttribute('class', 'oc-axis-tick');
342
-
343
- if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
344
- // Rotated labels: anchor at the rotation pivot point
345
- const labelX = tick.position;
346
- const labelY = area.y + area.height + 6;
347
- setAttrs(label, {
348
- x: labelX,
349
- y: labelY,
350
- 'text-anchor': axis.tickAngle < 0 ? 'end' : 'start',
351
- 'dominant-baseline': 'central',
352
- transform: `rotate(${axis.tickAngle}, ${labelX}, ${labelY})`,
353
- });
354
- } else {
355
- setAttrs(label, {
356
- x: tick.position,
357
- y: area.y + area.height + 14,
358
- 'text-anchor': 'middle',
359
- });
360
- }
361
-
362
- applyTextStyle(label, axis.tickLabelStyle);
363
- label.textContent = tick.label;
364
- g.appendChild(label);
365
- } else {
366
- // Label (no tick marks -- gridlines provide sufficient reference)
367
- const label = createSVGElement('text');
368
- label.setAttribute('class', 'oc-axis-tick');
369
- setAttrs(label, {
370
- x: area.x - 6,
371
- y: tick.position,
372
- 'text-anchor': 'end',
373
- 'dominant-baseline': 'central',
374
- });
375
- applyTextStyle(label, axis.tickLabelStyle);
376
- label.textContent = tick.label;
377
- g.appendChild(label);
378
- }
379
- }
380
-
381
- // Gridlines (positions are also absolute from the scales)
382
- for (const gridline of axis.gridlines) {
383
- const gl = createSVGElement('line');
384
- gl.setAttribute('class', 'oc-gridline');
385
- if (orientation === 'y') {
386
- setAttrs(gl, {
387
- x1: area.x,
388
- y1: gridline.position,
389
- x2: area.x + area.width,
390
- y2: gridline.position,
391
- stroke: layout.theme.colors.gridline,
392
- 'stroke-width': 1,
393
- 'stroke-opacity': 0.6,
394
- });
395
- } else {
396
- setAttrs(gl, {
397
- x1: gridline.position,
398
- y1: area.y,
399
- x2: gridline.position,
400
- y2: area.y + area.height,
401
- stroke: layout.theme.colors.gridline,
402
- 'stroke-width': 1,
403
- 'stroke-opacity': 0.6,
404
- });
405
- }
406
- g.appendChild(gl);
407
- }
408
-
409
- // Axis label
410
- if (axis.label && axis.labelStyle) {
411
- const axisLabel = createSVGElement('text');
412
- axisLabel.setAttribute('class', 'oc-axis-title');
413
- applyTextStyle(axisLabel, axis.labelStyle);
414
- axisLabel.textContent = axis.label;
415
-
416
- if (orientation === 'x') {
417
- // Position axis title below tick labels. For rotated labels, compute
418
- // the vertical extent of the rotated ticks and place the title below.
419
- let titleY = area.y + area.height + 35;
420
- if (axis.tickAngle && Math.abs(axis.tickAngle) > 10) {
421
- const angleRad = Math.abs(axis.tickAngle) * (Math.PI / 180);
422
- let maxLabelWidth = 40;
423
- for (const tick of axis.ticks) {
424
- const w = estimateTextWidth(
425
- tick.label,
426
- axis.tickLabelStyle.fontSize,
427
- axis.tickLabelStyle.fontWeight,
428
- );
429
- if (w > maxLabelWidth) maxLabelWidth = w;
430
- }
431
- const rotatedHeight = Math.min(maxLabelWidth * Math.sin(angleRad) + 6, 120);
432
- titleY = area.y + area.height + rotatedHeight + 14;
433
- }
434
- setAttrs(axisLabel, {
435
- x: area.x + area.width / 2,
436
- y: titleY,
437
- 'text-anchor': 'middle',
438
- });
439
- } else {
440
- // Rotated y-axis label
441
- setAttrs(axisLabel, {
442
- x: area.x - 45,
443
- y: area.y + area.height / 2,
444
- 'text-anchor': 'middle',
445
- transform: `rotate(-90, ${area.x - 45}, ${area.y + area.height / 2})`,
446
- });
447
- }
448
- g.appendChild(axisLabel);
449
- }
450
-
451
- parent.appendChild(g);
452
- }
453
-
454
- function renderAxes(parent: SVGElement, layout: ChartLayout): void {
455
- if (layout.axes.x) {
456
- renderAxis(parent, layout.axes.x, 'x', layout);
457
- }
458
- if (layout.axes.y) {
459
- renderAxis(parent, layout.axes.y, 'y', layout);
460
- }
461
- }
462
-
463
- // ---------------------------------------------------------------------------
464
- // Mark rendering (dispatch per mark type)
465
- // ---------------------------------------------------------------------------
466
-
467
- type MarkRenderer<T extends Mark> = (mark: T, index: number) => SVGElement;
468
-
469
- const markRenderers: Record<string, MarkRenderer<Mark>> = {};
470
-
471
- /**
472
- * Register a mark renderer for a specific mark type.
473
- * Built-in renderers are registered below for all chart types.
474
- */
475
- export function registerMarkRenderer<T extends Mark>(
476
- type: T['type'],
477
- renderer: MarkRenderer<T>,
478
- ): void {
479
- markRenderers[type] = renderer as MarkRenderer<Mark>;
480
- }
481
-
482
- function renderLineMark(mark: LineMark, index: number): SVGElement {
483
- const g = createSVGElement('g');
484
- g.setAttribute('data-mark-id', `line-${mark.seriesKey ?? index}`);
485
- g.setAttribute('class', 'oc-mark oc-mark-line');
486
- stampAnimationAttrs(g, mark, index);
487
-
488
- if (mark.points.length > 1) {
489
- const path = createSVGElement('path');
490
- // Use the pre-computed D3 curve path when available (smooth monotone),
491
- // otherwise fall back to straight M/L segments.
492
- const d =
493
- mark.path ?? mark.points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' ');
494
- setAttrs(path, {
495
- d,
496
- fill: 'none',
497
- stroke: mark.stroke,
498
- 'stroke-width': mark.strokeWidth,
499
- });
500
- if (mark.strokeDasharray) {
501
- path.setAttribute('stroke-dasharray', mark.strokeDasharray);
502
- }
503
- if (mark.opacity != null) {
504
- path.setAttribute('opacity', String(mark.opacity));
505
- }
506
- // Note: line drawing animation is handled via CSS clip-path on the group,
507
- // no inline dasharray/dashoffset needed.
508
- g.appendChild(path);
509
- }
510
-
511
- // Render end-of-line label if present and visible
512
- if (mark.label?.visible) {
513
- const label = createSVGElement('text');
514
- label.setAttribute('class', 'oc-mark-label');
515
- if (mark.seriesKey) {
516
- label.setAttribute('data-series', mark.seriesKey);
517
- }
518
- setAttrs(label, { x: mark.label.x, y: mark.label.y });
519
- applyTextStyle(label, mark.label.style);
520
- label.textContent = mark.label.text;
521
- g.appendChild(label);
522
-
523
- // Render connector line if label was offset from anchor
524
- if (mark.label.connector) {
525
- const connector = createSVGElement('line');
526
- connector.setAttribute('class', 'oc-mark-connector');
527
- setAttrs(connector, {
528
- x1: mark.label.connector.from.x,
529
- y1: mark.label.connector.from.y,
530
- x2: mark.label.connector.to.x,
531
- y2: mark.label.connector.to.y,
532
- stroke: mark.label.connector.stroke,
533
- 'stroke-width': 1,
534
- 'stroke-opacity': 0.5,
535
- });
536
- g.appendChild(connector);
537
- }
538
- }
539
-
540
- return g;
541
- }
542
-
543
- function renderAreaMark(mark: AreaMark, index: number): SVGElement {
544
- const g = createSVGElement('g');
545
- g.setAttribute('data-mark-id', `area-${mark.seriesKey ?? index}`);
546
- g.setAttribute('class', 'oc-mark oc-mark-area');
547
- stampAnimationAttrs(g, mark, index);
548
-
549
- if (mark.path) {
550
- // Area fill: the full closed shape (top line + baseline), no stroke
551
- const fill = createSVGElement('path');
552
- setAttrs(fill, {
553
- d: mark.path,
554
- fill: resolveMarkFill(mark.fill, currentGradientMap),
555
- 'fill-opacity': mark.fillOpacity,
556
- stroke: 'none',
557
- });
558
- g.appendChild(fill);
559
-
560
- // Top-line stroke: only along the data points, not the baseline
561
- if (mark.stroke && mark.topPath) {
562
- const strokePath = createSVGElement('path');
563
- strokePath.setAttribute('class', 'oc-area-top');
564
- setAttrs(strokePath, {
565
- d: mark.topPath,
566
- fill: 'none',
567
- stroke: mark.stroke,
568
- 'stroke-width': mark.strokeWidth ?? 1,
569
- });
570
- // Note: area drawing animation is handled via CSS clip-path on the group,
571
- // no inline dasharray/dashoffset needed.
572
- g.appendChild(strokePath);
573
- }
574
- }
575
-
576
- return g;
577
- }
578
-
579
- function renderRectMark(mark: RectMark, index: number): SVGElement {
580
- const g = createSVGElement('g');
581
- g.setAttribute('data-mark-id', `rect-${index}`);
582
- g.setAttribute('class', 'oc-mark oc-mark-rect');
583
- stampAnimationAttrs(g, mark, index);
584
- // Use engine-provided orientation for animation direction
585
- if (currentAnimation?.enabled && mark.orient === 'horizontal') {
586
- g.setAttribute('data-orient', 'horizontal');
587
- }
588
-
589
- const rect = createSVGElement('rect');
590
- setAttrs(rect, {
591
- x: mark.x,
592
- y: mark.y,
593
- width: mark.width,
594
- height: mark.height,
595
- fill: resolveMarkFill(mark.fill, currentGradientMap),
596
- });
597
- if (mark.stroke) {
598
- rect.setAttribute('stroke', mark.stroke);
599
- }
600
- if (mark.strokeWidth) {
601
- rect.setAttribute('stroke-width', String(mark.strokeWidth));
602
- }
603
- if (mark.cornerRadius) {
604
- setAttrs(rect, { rx: mark.cornerRadius, ry: mark.cornerRadius });
605
- }
606
- g.appendChild(rect);
607
-
608
- // Render value label if present and visible
609
- if (mark.label?.visible) {
610
- const label = createSVGElement('text');
611
- label.setAttribute('class', 'oc-mark-label');
612
- setAttrs(label, { x: mark.label.x, y: mark.label.y });
613
- applyTextStyle(label, mark.label.style);
614
- label.textContent = mark.label.text;
615
- g.appendChild(label);
616
- }
617
-
618
- return g;
619
- }
620
-
621
- function renderArcMark(mark: ArcMark, index: number): SVGElement {
622
- const g = createSVGElement('g');
623
- g.setAttribute('data-mark-id', `arc-${index}`);
624
- g.setAttribute('class', 'oc-mark oc-mark-arc');
625
- g.setAttribute('transform', `translate(${mark.center.x},${mark.center.y})`);
626
- stampAnimationAttrs(g, mark, index);
627
-
628
- const path = createSVGElement('path');
629
- setAttrs(path, {
630
- d: mark.path,
631
- fill: resolveMarkFill(mark.fill, currentGradientMap),
632
- stroke: mark.stroke,
633
- 'stroke-width': mark.strokeWidth,
634
- });
635
- g.appendChild(path);
636
-
637
- // Render label if present and visible
638
- if (mark.label?.visible) {
639
- const label = createSVGElement('text');
640
- label.setAttribute('class', 'oc-mark-label');
641
- // Label position is in absolute coords, but we're in a translated group,
642
- // so subtract the center offset
643
- setAttrs(label, {
644
- x: mark.label.x - mark.center.x,
645
- y: mark.label.y - mark.center.y,
646
- });
647
- applyTextStyle(label, mark.label.style);
648
- label.textContent = mark.label.text;
649
- g.appendChild(label);
650
- }
651
-
652
- return g;
653
- }
654
-
655
- function renderPointMark(mark: PointMark, index: number): SVGElement {
656
- const circle = createSVGElement('circle');
657
- circle.setAttribute('data-mark-id', `point-${index}`);
658
- circle.setAttribute('class', 'oc-mark oc-mark-point');
659
- stampAnimationAttrs(circle, mark, index);
660
-
661
- setAttrs(circle, {
662
- cx: mark.cx,
663
- cy: mark.cy,
664
- r: mark.r,
665
- fill: resolveMarkFill(mark.fill, currentGradientMap),
666
- stroke: mark.stroke,
667
- 'stroke-width': mark.strokeWidth,
668
- });
669
- if (mark.fillOpacity !== undefined) {
670
- circle.setAttribute('fill-opacity', String(mark.fillOpacity));
671
- }
672
- return circle;
673
- }
674
-
675
- function renderTextMark(mark: TextMarkLayout, index: number): SVGElement {
676
- const text = createSVGElement('text');
677
- text.setAttribute('data-mark-id', `textMark-${index}`);
678
- text.setAttribute('class', 'oc-mark oc-mark-text');
679
- stampAnimationAttrs(text, mark, index);
680
-
681
- setAttrs(text, {
682
- x: mark.x,
683
- y: mark.y,
684
- 'font-size': mark.fontSize,
685
- 'text-anchor': mark.textAnchor,
686
- });
687
- (text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', mark.fill);
688
- if (mark.fontWeight) {
689
- text.setAttribute('font-weight', String(mark.fontWeight));
690
- }
691
- if (mark.fontFamily) {
692
- text.setAttribute('font-family', mark.fontFamily);
693
- }
694
- if (mark.angle) {
695
- text.setAttribute('transform', `rotate(${mark.angle}, ${mark.x}, ${mark.y})`);
696
- }
697
- text.textContent = mark.text;
698
- return text;
699
- }
700
-
701
- function renderRuleMark(mark: RuleMarkLayout, index: number): SVGElement {
702
- const line = createSVGElement('line');
703
- line.setAttribute('data-mark-id', `rule-${index}`);
704
- line.setAttribute('class', 'oc-mark oc-mark-rule');
705
- stampAnimationAttrs(line, mark, index);
706
-
707
- setAttrs(line, {
708
- x1: mark.x1,
709
- y1: mark.y1,
710
- x2: mark.x2,
711
- y2: mark.y2,
712
- stroke: mark.stroke,
713
- 'stroke-width': mark.strokeWidth,
714
- });
715
- if (mark.strokeDasharray) {
716
- line.setAttribute('stroke-dasharray', mark.strokeDasharray);
717
- }
718
- if (mark.opacity != null) {
719
- line.setAttribute('opacity', String(mark.opacity));
720
- }
721
- return line;
722
- }
723
-
724
- function renderTickMark(mark: TickMarkLayout, index: number): SVGElement {
725
- const line = createSVGElement('line');
726
- line.setAttribute('data-mark-id', `tick-${index}`);
727
- line.setAttribute('class', 'oc-mark oc-mark-tick');
728
- stampAnimationAttrs(line, mark, index);
729
-
730
- // Tick is a short line segment centered at (x, y)
731
- const half = mark.length / 2;
732
- if (mark.orient === 'vertical') {
733
- setAttrs(line, {
734
- x1: mark.x,
735
- y1: mark.y - half,
736
- x2: mark.x,
737
- y2: mark.y + half,
738
- stroke: mark.stroke,
739
- 'stroke-width': mark.strokeWidth,
740
- });
741
- } else {
742
- setAttrs(line, {
743
- x1: mark.x - half,
744
- y1: mark.y,
745
- x2: mark.x + half,
746
- y2: mark.y,
747
- stroke: mark.stroke,
748
- 'stroke-width': mark.strokeWidth,
749
- });
750
- }
751
-
752
- if (mark.opacity != null) {
753
- line.setAttribute('opacity', String(mark.opacity));
754
- }
755
- return line;
756
- }
757
-
758
- // Register built-in renderers
759
- registerMarkRenderer('line', renderLineMark as MarkRenderer<Mark>);
760
- registerMarkRenderer('area', renderAreaMark as MarkRenderer<Mark>);
761
- registerMarkRenderer('rect', renderRectMark as MarkRenderer<Mark>);
762
- registerMarkRenderer('arc', renderArcMark as MarkRenderer<Mark>);
763
- registerMarkRenderer('point', renderPointMark as MarkRenderer<Mark>);
764
- registerMarkRenderer('textMark', renderTextMark as MarkRenderer<Mark>);
765
- registerMarkRenderer('rule', renderRuleMark as MarkRenderer<Mark>);
766
- registerMarkRenderer('tick', renderTickMark as MarkRenderer<Mark>);
767
-
768
- /** Extract series name from a mark for legend toggle matching. */
769
- function getMarkSeries(mark: Mark): string | undefined {
770
- // Line and area marks have an explicit seriesKey
771
- if (mark.type === 'line' || mark.type === 'area') {
772
- return mark.seriesKey;
773
- }
774
- // For arc marks, the category name is the first part of the aria label (before ':')
775
- if (mark.type === 'arc') {
776
- return mark.aria.label.split(':')[0]?.trim();
777
- }
778
- // For rect/point, the aria label may be "category: value" or "category, group: value".
779
- // The series name is the category part (before the colon).
780
- if (mark.aria?.label) {
781
- const beforeColon = mark.aria.label.split(':')[0]?.trim();
782
- if (beforeColon) return beforeColon;
783
- }
784
- return undefined;
785
- }
786
-
787
- function renderMarks(parent: SVGElement, layout: ChartLayout): void {
788
- const g = createSVGElement('g');
789
- g.setAttribute('class', 'oc-marks');
790
-
791
- for (let i = 0; i < layout.marks.length; i++) {
792
- const mark = layout.marks[i];
793
- const renderer = markRenderers[mark.type];
794
- if (!renderer) continue;
795
-
796
- const el = renderer(mark, i);
797
- // Add ARIA label if present
798
- if (mark.aria?.label) {
799
- el.setAttribute('aria-label', mark.aria.label);
800
- }
801
- // Add data-series attribute for legend toggle matching
802
- const series = getMarkSeries(mark);
803
- if (series) {
804
- el.setAttribute('data-series', series);
805
- }
806
-
807
- // For stacked segments, set stack position for sequential animation chaining.
808
- // stackPos is computed by the engine on RectMark during compilation.
809
- if (currentAnimation?.enabled && mark.type === 'rect') {
810
- const rect = mark as RectMark;
811
- if (rect.stackGroup && rect.stackPos !== undefined) {
812
- el.setAttribute('data-stack-pos', String(rect.stackPos));
813
- (el as SVGElement & ElementCSSInlineStyle).style.setProperty(
814
- '--oc-stack-pos',
815
- String(rect.stackPos),
816
- );
817
- }
818
- }
819
-
820
- g.appendChild(el);
821
- }
822
-
823
- parent.appendChild(g);
824
- }
825
-
826
- // ---------------------------------------------------------------------------
827
- // Annotation rendering
828
- // ---------------------------------------------------------------------------
829
-
830
- function renderAnnotations(parent: SVGElement, layout: ChartLayout): void {
831
- if (layout.annotations.length === 0) return;
832
-
833
- const g = createSVGElement('g');
834
- g.setAttribute('class', 'oc-annotations');
835
-
836
- // Annotations are already sorted by zIndex from the engine, so render in order
837
- const bgColor = layout.theme.colors.background;
838
- for (let i = 0; i < layout.annotations.length; i++) {
839
- renderAnnotation(g, layout.annotations[i], i, bgColor);
840
- }
841
-
842
- parent.appendChild(g);
843
- }
844
-
845
- /**
846
- * Render a curved arrow connector from a label to a data point.
847
- * Uses a cubic bezier that sweeps outward then curves toward the
848
- * target, with a triangular arrowhead at the tip.
849
- */
850
- function renderCurvedArrow(parent: SVGElement, from: Point, to: Point, stroke: string): void {
851
- // Pad above the target so the arrow doesn't sit right on the element.
852
- const pad = 6;
853
- const tipY = to.y - pad;
854
-
855
- const dy = tipY - from.y;
856
- const dist = Math.sqrt((to.x - from.x) ** 2 + dy ** 2) || 1;
857
-
858
- // Arrowhead geometry
859
- const arrowLen = 8;
860
- const arrowWidth = 4;
861
-
862
- // cp2 directly above target so arrow arrives pointing straight down.
863
- const bulge = Math.max(dist * 0.4, 35);
864
- const cp1x = from.x + bulge;
865
- const cp1y = from.y + dy * 0.35;
866
- const cp2x = to.x;
867
- const cp2y = tipY - Math.abs(dy) * 0.25;
868
-
869
- // Tangent at the tip (from cp2 → tip), used for arrowhead direction.
870
- const tx = to.x - cp2x;
871
- const ty = tipY - cp2y;
872
- const tLen = Math.sqrt(tx * tx + ty * ty) || 1;
873
- const ux = tx / tLen;
874
- const uy = ty / tLen;
875
-
876
- // End the curve path at the arrowhead BASE so the stroke doesn't
877
- // poke through the filled triangle.
878
- const baseX = to.x - ux * arrowLen;
879
- const baseY = tipY - uy * arrowLen;
880
-
881
- const path = createSVGElement('path');
882
- path.setAttribute('class', 'oc-annotation-connector');
883
- setAttrs(path, {
884
- d: `M ${from.x} ${from.y} C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${baseX} ${baseY}`,
885
- fill: 'none',
886
- stroke,
887
- 'stroke-width': 1.5,
888
- });
889
- parent.appendChild(path);
890
-
891
- // Arrowhead triangle: perpendicular to tangent direction.
892
- const px = -uy;
893
- const py = ux;
894
-
895
- const arrow = createSVGElement('polygon');
896
- arrow.setAttribute('class', 'oc-annotation-connector');
897
- setAttrs(arrow, {
898
- points: [
899
- `${to.x},${tipY}`,
900
- `${baseX + px * arrowWidth},${baseY + py * arrowWidth}`,
901
- `${baseX - px * arrowWidth},${baseY - py * arrowWidth}`,
902
- ].join(' '),
903
- fill: stroke,
904
- });
905
- parent.appendChild(arrow);
906
- }
907
-
908
- function renderAnnotation(
909
- parent: SVGElement,
910
- annotation: ResolvedAnnotation,
911
- index: number,
912
- bgColor?: string,
913
- ): void {
914
- const g = createSVGElement('g');
915
- g.setAttribute('class', `oc-annotation oc-annotation-${annotation.type}`);
916
- g.setAttribute('data-annotation-index', String(index));
917
- if (annotation.id) {
918
- g.setAttribute('data-annotation-id', annotation.id);
919
- }
920
-
921
- // Range rect
922
- if (annotation.rect) {
923
- const rect = createSVGElement('rect');
924
- rect.setAttribute('class', 'oc-annotation-range');
925
- setAttrs(rect, {
926
- x: annotation.rect.x,
927
- y: annotation.rect.y,
928
- width: annotation.rect.width,
929
- height: annotation.rect.height,
930
- });
931
- if (annotation.fill) rect.setAttribute('fill', annotation.fill);
932
- if (annotation.opacity !== undefined) {
933
- rect.setAttribute('fill-opacity', String(annotation.opacity));
934
- }
935
- g.appendChild(rect);
936
- }
937
-
938
- // Reference line
939
- if (annotation.line) {
940
- const line = createSVGElement('line');
941
- line.setAttribute('class', 'oc-annotation-line');
942
- setAttrs(line, {
943
- x1: annotation.line.start.x,
944
- y1: annotation.line.start.y,
945
- x2: annotation.line.end.x,
946
- y2: annotation.line.end.y,
947
- 'stroke-width': annotation.strokeWidth ?? 1,
948
- });
949
- if (annotation.stroke) line.setAttribute('stroke', annotation.stroke);
950
- if (annotation.strokeDasharray) {
951
- line.setAttribute('stroke-dasharray', annotation.strokeDasharray);
952
- }
953
- g.appendChild(line);
954
- }
955
-
956
- // Label with optional connector line
957
- if (annotation.label?.visible) {
958
- // Render connector first (behind the label text)
959
- if (annotation.label.connector) {
960
- const c = annotation.label.connector;
961
- if (c.style === 'curve') {
962
- renderCurvedArrow(g, c.from, c.to, c.stroke);
963
- } else {
964
- const connector = createSVGElement('line');
965
- connector.setAttribute('class', 'oc-annotation-connector');
966
- setAttrs(connector, {
967
- x1: c.from.x,
968
- y1: c.from.y,
969
- x2: c.to.x,
970
- y2: c.to.y,
971
- stroke: c.stroke,
972
- 'stroke-width': 1,
973
- 'stroke-opacity': 0.5,
974
- });
975
- g.appendChild(connector);
976
- }
977
- }
978
-
979
- const text = createSVGElement('text');
980
- text.setAttribute('class', 'oc-annotation-label');
981
- setAttrs(text, { x: annotation.label.x, y: annotation.label.y });
982
- applyTextStyle(text, annotation.label.style);
983
-
984
- const lines = annotation.label.text.split('\n');
985
- const fontSize = annotation.label.style.fontSize ?? 12;
986
- const lineHeight = fontSize * (annotation.label.style.lineHeight ?? 1.3);
987
- const isMultiLine = lines.length > 1;
988
-
989
- // Multi-line text uses center alignment for a cleaner look
990
- if (isMultiLine) {
991
- text.setAttribute('text-anchor', 'middle');
992
- for (let i = 0; i < lines.length; i++) {
993
- const tspan = createSVGElement('tspan');
994
- setAttrs(tspan, { x: annotation.label.x, dy: i === 0 ? 0 : lineHeight });
995
- tspan.textContent = lines[i];
996
- text.appendChild(tspan);
997
- }
998
- } else {
999
- text.textContent = annotation.label.text;
1000
- }
1001
-
1002
- // Render background rect behind text if specified, otherwise use
1003
- // paint-order stroke halo to knock out lines behind text
1004
- if (annotation.label.background) {
1005
- const charWidth = fontSize * 0.55;
1006
- const maxLineWidth = Math.max(...lines.map((l) => l.length)) * charWidth;
1007
- const totalHeight = lines.length * lineHeight;
1008
- const pad = 3;
1009
- const bgX = isMultiLine
1010
- ? annotation.label.x - maxLineWidth / 2 - pad
1011
- : annotation.label.x - pad;
1012
- const bgRect = createSVGElement('rect');
1013
- bgRect.setAttribute('class', 'oc-annotation-bg');
1014
- setAttrs(bgRect, {
1015
- x: bgX,
1016
- y: annotation.label.y - fontSize + (lineHeight - fontSize) / 2 - pad,
1017
- width: maxLineWidth + pad * 2,
1018
- height: totalHeight + pad * 2,
1019
- fill: annotation.label.background,
1020
- rx: 2,
1021
- });
1022
- g.appendChild(bgRect);
1023
- } else if (bgColor) {
1024
- text.style.paintOrder = 'stroke';
1025
- text.style.stroke = bgColor;
1026
- text.style.strokeWidth = `${Math.round(fontSize * 0.3)}px`;
1027
- text.style.strokeLinejoin = 'round';
1028
- }
1029
-
1030
- g.appendChild(text);
1031
- }
1032
-
1033
- parent.appendChild(g);
1034
- }
1035
-
1036
- // ---------------------------------------------------------------------------
1037
- // Legend rendering
1038
- // ---------------------------------------------------------------------------
1039
-
1040
- function renderLegend(parent: SVGElement, legend: LegendLayout): void {
1041
- if (legend.entries.length === 0) return;
1042
-
1043
- const g = createSVGElement('g');
1044
- g.setAttribute('class', 'oc-legend');
1045
- g.setAttribute('role', 'list');
1046
- g.setAttribute('aria-label', 'Chart legend');
1047
-
1048
- const isHorizontal = legend.position === 'top' || legend.position === 'bottom';
1049
- let offsetX = legend.bounds.x;
1050
- let offsetY = legend.bounds.y;
1051
-
1052
- for (let i = 0; i < legend.entries.length; i++) {
1053
- const entry = legend.entries[i];
1054
-
1055
- // Pre-check: wrap to next line if this entry would overflow bounds
1056
- if (isHorizontal && i > 0) {
1057
- const labelWidth = estimateTextWidth(
1058
- entry.label,
1059
- legend.labelStyle.fontSize,
1060
- legend.labelStyle.fontWeight,
1061
- );
1062
- const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
1063
- if (offsetX + entryWidth > legend.bounds.x + legend.bounds.width) {
1064
- offsetX = legend.bounds.x;
1065
- offsetY += legend.swatchSize + 6;
1066
- }
1067
- }
1068
- const entryG = createSVGElement('g');
1069
- entryG.setAttribute('class', 'oc-legend-entry');
1070
- entryG.setAttribute('role', 'listitem');
1071
- entryG.setAttribute('data-legend-index', String(i));
1072
- entryG.setAttribute('data-legend-label', entry.label);
1073
- if (entry.overflow) {
1074
- entryG.setAttribute('data-legend-overflow', 'true');
1075
- entryG.setAttribute('aria-label', entry.label);
1076
- entryG.setAttribute('opacity', '0.5');
1077
- } else {
1078
- entryG.setAttribute(
1079
- 'aria-label',
1080
- `${entry.label}: ${entry.active !== false ? 'visible' : 'hidden'}`,
1081
- );
1082
- entryG.setAttribute('style', 'cursor: pointer');
1083
-
1084
- // Apply dimming for inactive entries
1085
- if (entry.active === false) {
1086
- entryG.setAttribute('opacity', '0.3');
1087
- }
1088
- }
1089
-
1090
- // Swatch
1091
- if (entry.shape === 'circle') {
1092
- const circle = createSVGElement('circle');
1093
- setAttrs(circle, {
1094
- cx: offsetX + legend.swatchSize / 2,
1095
- cy: offsetY + legend.swatchSize / 2,
1096
- r: legend.swatchSize / 2,
1097
- fill: entry.color,
1098
- });
1099
- entryG.appendChild(circle);
1100
- } else if (entry.shape === 'line') {
1101
- // Line swatch: a short line segment with a dot in the middle
1102
- const line = createSVGElement('line');
1103
- setAttrs(line, {
1104
- x1: offsetX,
1105
- y1: offsetY + legend.swatchSize / 2,
1106
- x2: offsetX + legend.swatchSize,
1107
- y2: offsetY + legend.swatchSize / 2,
1108
- stroke: entry.color,
1109
- 'stroke-width': 2,
1110
- });
1111
- entryG.appendChild(line);
1112
- // Small dot at center
1113
- const dot = createSVGElement('circle');
1114
- setAttrs(dot, {
1115
- cx: offsetX + legend.swatchSize / 2,
1116
- cy: offsetY + legend.swatchSize / 2,
1117
- r: 2.5,
1118
- fill: entry.color,
1119
- });
1120
- entryG.appendChild(dot);
1121
- } else {
1122
- const rect = createSVGElement('rect');
1123
- setAttrs(rect, {
1124
- x: offsetX,
1125
- y: offsetY,
1126
- width: legend.swatchSize,
1127
- height: legend.swatchSize,
1128
- fill: entry.color,
1129
- rx: 2,
1130
- });
1131
- entryG.appendChild(rect);
1132
- }
1133
-
1134
- // Label
1135
- const label = createSVGElement('text');
1136
- setAttrs(label, {
1137
- x: offsetX + legend.swatchSize + legend.swatchGap,
1138
- y: offsetY + legend.swatchSize / 2,
1139
- 'dominant-baseline': 'central',
1140
- });
1141
- applyTextStyle(label, legend.labelStyle);
1142
- label.textContent = entry.label;
1143
- entryG.appendChild(label);
1144
-
1145
- g.appendChild(entryG);
1146
-
1147
- // Advance position for next entry
1148
- if (isHorizontal) {
1149
- const labelWidth = estimateTextWidth(
1150
- entry.label,
1151
- legend.labelStyle.fontSize,
1152
- legend.labelStyle.fontWeight,
1153
- );
1154
- const entryWidth = legend.swatchSize + legend.swatchGap + labelWidth + legend.entryGap;
1155
- offsetX += entryWidth;
1156
- } else {
1157
- offsetY += legend.swatchSize + legend.entryGap;
1158
- }
1159
- }
1160
-
1161
- parent.appendChild(g);
1162
- }
1163
-
1164
- // ---------------------------------------------------------------------------
1165
- // Brand rendering
1166
- // ---------------------------------------------------------------------------
1167
-
1168
- const BRAND_URL = 'https://tryopendata.ai';
1169
- const XLINK_NS = 'http://www.w3.org/1999/xlink';
1170
-
1171
- /**
1172
- * Render the "OpenData" brand as a footer-row element, right-aligned on the
1173
- * same baseline as the first bottom chrome text (source/byline/footer).
1174
- * Uses the same font size as chrome source text so it blends in as a subtle
1175
- * footer item rather than occupying independent visual space.
1176
- */
1177
- function renderBrand(parent: SVGElement, layout: ChartLayout): void {
1178
- if (layout.dimensions.width < BRAND_MIN_WIDTH) return;
1179
-
1180
- const { width } = layout.dimensions;
1181
- const padding = layout.theme.spacing.padding;
1182
- const rightEdge = width - padding;
1183
- const fill = layout.theme.colors.axis;
1184
-
1185
- // Vertically align with the first bottom chrome element.
1186
- const { chrome } = layout;
1187
- const xAxisExtent = computeXAxisExtent(layout);
1188
- const bottomOffset = layout.area.y + layout.area.height + xAxisExtent;
1189
- const firstBottom = chrome.source ?? chrome.byline ?? chrome.footer;
1190
- const chromeY = firstBottom
1191
- ? bottomOffset + firstBottom.y
1192
- : bottomOffset + layout.theme.spacing.chartToFooter;
1193
-
1194
- const a = createSVGElement('a');
1195
- a.setAttribute('href', BRAND_URL);
1196
- a.setAttributeNS(XLINK_NS, 'xlink:href', BRAND_URL);
1197
- a.setAttribute('target', '_blank');
1198
- a.setAttribute('rel', 'noopener');
1199
- a.setAttribute('class', 'oc-chrome-ref');
1200
-
1201
- // "try" in normal weight, "OpenData" in semibold, ".ai" in normal weight,
1202
- // rendered as a single right-aligned text element with three tspans.
1203
- // Use alphabetic baseline so mixed-size tspans share a common bottom line.
1204
- const BRAND_LARGE = 16;
1205
- const text = createSVGElement('text');
1206
- setAttrs(text, {
1207
- x: rightEdge,
1208
- y: chromeY + BRAND_LARGE,
1209
- 'dominant-baseline': 'alphabetic',
1210
- 'font-family': layout.theme.fonts.family,
1211
- 'font-size': BRAND_FONT_SIZE,
1212
- 'text-anchor': 'end',
1213
- 'fill-opacity': 0.55,
1214
- });
1215
- (text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', fill);
1216
-
1217
- const trySpan = createSVGElement('tspan');
1218
- trySpan.setAttribute('font-weight', '500');
1219
- trySpan.textContent = 'try';
1220
- text.appendChild(trySpan);
1221
-
1222
- const openDataSpan = createSVGElement('tspan');
1223
- openDataSpan.setAttribute('font-weight', '600');
1224
- openDataSpan.setAttribute('font-size', String(BRAND_LARGE));
1225
- openDataSpan.textContent = 'OpenData';
1226
- text.appendChild(openDataSpan);
1227
-
1228
- const aiSpan = createSVGElement('tspan');
1229
- aiSpan.setAttribute('font-weight', '500');
1230
- aiSpan.textContent = '.ai';
1231
- text.appendChild(aiSpan);
1232
-
1233
- a.appendChild(text);
1234
- parent.appendChild(a);
1235
- }
1236
-
1237
- // ---------------------------------------------------------------------------
1238
- // Main render function
1239
- // ---------------------------------------------------------------------------
1240
-
1241
34
  /**
1242
35
  * Render a compiled ChartLayout into an SVG element and append it to a container.
1243
36
  *
@@ -1253,9 +46,6 @@ export function renderChartSVG(
1253
46
  const { width, height } = layout.dimensions;
1254
47
  const animation = layout.animation;
1255
48
 
1256
- // Set module-level animation state so mark renderers can access it
1257
- currentAnimation = animation;
1258
-
1259
49
  const svg = createSVGElement('svg') as SVGSVGElement;
1260
50
  setAttrs(svg, {
1261
51
  viewBox: `0 0 ${width} ${height}`,
@@ -1321,7 +111,7 @@ export function renderChartSVG(
1321
111
  // Clip path to prevent marks (especially area fills) from overflowing
1322
112
  // into the chrome region (title/subtitle). Extends full width so
1323
113
  // end-of-line labels aren't clipped, but constrains vertically.
1324
- const clipId = `oc-clip-${Math.random().toString(36).slice(2, 8)}`;
114
+ const clipId = nextSvgId('oc-clip');
1325
115
  const defs = createSVGElement('defs');
1326
116
  const clipPath = createSVGElement('clipPath');
1327
117
  clipPath.setAttribute('id', clipId);
@@ -1336,57 +126,62 @@ export function renderChartSVG(
1336
126
  defs.appendChild(clipPath);
1337
127
 
1338
128
  // Build gradient defs for marks with gradient fills
1339
- currentGradientMap = buildGradientDefs(layout.marks as Array<{ fill?: unknown }>, defs);
129
+ const gradientMap = buildGradientDefs(layout.marks as Array<{ fill?: unknown }>, defs);
1340
130
 
1341
131
  svg.appendChild(defs);
1342
132
 
1343
- // Render layers in order (back to front)
1344
- // Axes render outside clip (labels extend beyond chart area)
1345
- renderAxes(svg, layout);
1346
-
1347
- // Marks are clipped to chart area so area fills don't cover chrome
1348
- const clippedGroup = createSVGElement('g');
1349
- clippedGroup.setAttribute('clip-path', `url(#${clipId})`);
1350
- renderMarks(clippedGroup, layout);
1351
-
1352
- // Add transparent overlay rect for line/area charts to enable voronoi tooltip lookup.
1353
- // Only added when there are line or area marks with dataPoints, and no explicit
1354
- // PointMark objects (which use per-element event handling instead).
1355
- const hasLineOrAreaWithDataPoints = layout.marks.some(
1356
- (m) => (m.type === 'line' || m.type === 'area') && m.dataPoints && m.dataPoints.length > 0,
1357
- );
1358
- const hasPointMarks = layout.marks.some((m) => m.type === 'point');
1359
- if (hasLineOrAreaWithDataPoints && !hasPointMarks) {
1360
- const overlay = createSVGElement('rect');
1361
- setAttrs(overlay, {
1362
- x: layout.area.x,
1363
- y: layout.area.y,
1364
- width: layout.area.width,
1365
- height: layout.area.height,
1366
- fill: 'transparent',
1367
- });
1368
- overlay.setAttribute('class', 'oc-voronoi-overlay');
1369
- overlay.setAttribute('data-voronoi-overlay', 'true');
1370
- clippedGroup.appendChild(overlay);
1371
- }
133
+ // Prime mark-renderer module-level state so mark sub-renderers can resolve
134
+ // animation + gradient fills without signature changes. try/finally guarantees
135
+ // the reset fires even if any downstream renderer throws, so the next render
136
+ // starts with a clean slate.
137
+ setMarkRenderState({ animation, gradientMap });
138
+ try {
139
+ // Render layers in order (back to front)
140
+ // Axes render outside clip (labels extend beyond chart area)
141
+ renderAxes(svg, layout);
142
+
143
+ // Marks are clipped to chart area so area fills don't cover chrome
144
+ const clippedGroup = createSVGElement('g');
145
+ clippedGroup.setAttribute('clip-path', `url(#${clipId})`);
146
+ renderMarks(clippedGroup, layout);
147
+
148
+ // Add transparent overlay rect for line/area charts to enable voronoi tooltip lookup.
149
+ // Only added when there are line or area marks with dataPoints, and no explicit
150
+ // PointMark objects (which use per-element event handling instead).
151
+ const hasLineOrAreaWithDataPoints = layout.marks.some(
152
+ (m) => (m.type === 'line' || m.type === 'area') && m.dataPoints && m.dataPoints.length > 0,
153
+ );
154
+ const hasPointMarks = layout.marks.some((m) => m.type === 'point');
155
+ if (hasLineOrAreaWithDataPoints && !hasPointMarks) {
156
+ const overlay = createSVGElement('rect');
157
+ setAttrs(overlay, {
158
+ x: layout.area.x,
159
+ y: layout.area.y,
160
+ width: layout.area.width,
161
+ height: layout.area.height,
162
+ fill: 'transparent',
163
+ });
164
+ overlay.setAttribute('class', 'oc-voronoi-overlay');
165
+ overlay.setAttribute('data-voronoi-overlay', 'true');
166
+ clippedGroup.appendChild(overlay);
167
+ }
1372
168
 
1373
- svg.appendChild(clippedGroup);
169
+ svg.appendChild(clippedGroup);
1374
170
 
1375
- renderAnnotations(svg, layout);
1376
- renderLegend(svg, layout.legend);
171
+ renderAnnotations(svg, layout);
172
+ renderLegend(svg, layout.legend);
1377
173
 
1378
- // Chrome renders on top so titles are never obscured by chart elements
1379
- renderChrome(svg, layout);
174
+ // Chrome renders on top so titles are never obscured by chart elements
175
+ renderChrome(svg, layout);
1380
176
 
1381
- // Brand renders as a footer item, right-aligned on the source/footer row
1382
- if (layout.watermark) {
1383
- renderBrand(svg, layout);
177
+ // Brand renders as a footer item, right-aligned on the source/footer row
178
+ if (layout.watermark) {
179
+ renderBrand(svg, layout);
180
+ }
181
+ } finally {
182
+ resetMarkRenderState();
1384
183
  }
1385
184
 
1386
- // Reset module-level state after rendering
1387
- currentAnimation = undefined;
1388
- currentGradientMap = new Map();
1389
-
1390
185
  container.appendChild(svg);
1391
186
  return svg;
1392
187
  }