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