@opendata-ai/openchart-vanilla 6.0.0 → 6.1.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.
@@ -1,42 +1,4 @@
1
1
  (() => {
2
- var __create = Object.create;
3
- var __getProtoOf = Object.getPrototypeOf;
4
- var __defProp = Object.defineProperty;
5
- var __getOwnPropNames = Object.getOwnPropertyNames;
6
- var __hasOwnProp = Object.prototype.hasOwnProperty;
7
- function __accessProp(key) {
8
- return this[key];
9
- }
10
- var __toESMCache_node;
11
- var __toESMCache_esm;
12
- var __toESM = (mod, isNodeMode, target) => {
13
- var canCache = mod != null && typeof mod === "object";
14
- if (canCache) {
15
- var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap;
16
- var cached = cache.get(mod);
17
- if (cached)
18
- return cached;
19
- }
20
- target = mod != null ? __create(__getProtoOf(mod)) : {};
21
- const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
22
- for (let key of __getOwnPropNames(mod))
23
- if (!__hasOwnProp.call(to, key))
24
- __defProp(to, key, {
25
- get: __accessProp.bind(mod, key),
26
- enumerable: true
27
- });
28
- if (canCache)
29
- cache.set(mod, to);
30
- return to;
31
- };
32
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
33
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
34
- }) : x)(function(x) {
35
- if (typeof require !== "undefined")
36
- return require.apply(this, arguments);
37
- throw Error('Dynamic require of "' + x + '" is not supported');
38
- });
39
-
40
2
  // ../../node_modules/.bun/d3-force@3.0.0/node_modules/d3-force/src/center.js
41
3
  function center_default(x, y) {
42
4
  var nodes, strength = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opendata-ai/openchart-vanilla",
3
- "version": "6.0.0",
3
+ "version": "6.1.0",
4
4
  "description": "Vanilla JS renderer for openchart: SVG charts, HTML tables, force-directed graphs",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Riley Hilliard",
@@ -50,8 +50,8 @@
50
50
  },
51
51
  "dependencies": {
52
52
  "@floating-ui/dom": "^1.7.6",
53
- "@opendata-ai/openchart-core": "6.0.0",
54
- "@opendata-ai/openchart-engine": "6.0.0",
53
+ "@opendata-ai/openchart-core": "workspace:*",
54
+ "@opendata-ai/openchart-engine": "workspace:*",
55
55
  "d3-force": "^3.0.0",
56
56
  "d3-quadtree": "^3.0.1"
57
57
  },
@@ -12,7 +12,7 @@ import type { ChartSpec, GraphSpec, TableSpec } from '@opendata-ai/openchart-eng
12
12
  // ---------------------------------------------------------------------------
13
13
 
14
14
  export const lineSpec: ChartSpec = {
15
- type: 'line',
15
+ mark: 'line',
16
16
  data: [
17
17
  { date: '2020-01-01', value: 10, country: 'US' },
18
18
  { date: '2021-01-01', value: 40, country: 'US' },
@@ -32,7 +32,7 @@ export const lineSpec: ChartSpec = {
32
32
  };
33
33
 
34
34
  export const singleSeriesLineSpec: ChartSpec = {
35
- type: 'line',
35
+ mark: 'line',
36
36
  data: [
37
37
  { date: '2020-01-01', value: 10 },
38
38
  { date: '2021-01-01', value: 40 },
@@ -49,7 +49,7 @@ export const singleSeriesLineSpec: ChartSpec = {
49
49
  // ---------------------------------------------------------------------------
50
50
 
51
51
  export const barSpec: ChartSpec = {
52
- type: 'bar',
52
+ mark: 'bar',
53
53
  data: [
54
54
  { name: 'A', value: 10 },
55
55
  { name: 'B', value: 30 },
@@ -69,7 +69,7 @@ export const barSpec: ChartSpec = {
69
69
  // ---------------------------------------------------------------------------
70
70
 
71
71
  export const columnSpec: ChartSpec = {
72
- type: 'column',
72
+ mark: 'bar',
73
73
  data: [
74
74
  { category: 'Q1', revenue: 100 },
75
75
  { category: 'Q2', revenue: 200 },
@@ -89,7 +89,7 @@ export const columnSpec: ChartSpec = {
89
89
  // ---------------------------------------------------------------------------
90
90
 
91
91
  export const scatterSpec: ChartSpec = {
92
- type: 'scatter',
92
+ mark: 'point',
93
93
  data: [
94
94
  { x: 10, y: 20, group: 'A' },
95
95
  { x: 30, y: 40, group: 'A' },
@@ -111,7 +111,7 @@ export const scatterSpec: ChartSpec = {
111
111
  // ---------------------------------------------------------------------------
112
112
 
113
113
  export const pieSpec: ChartSpec = {
114
- type: 'pie',
114
+ mark: 'arc',
115
115
  data: [
116
116
  { category: 'Red', value: 30 },
117
117
  { category: 'Blue', value: 50 },
@@ -131,7 +131,7 @@ export const pieSpec: ChartSpec = {
131
131
  // ---------------------------------------------------------------------------
132
132
 
133
133
  export const multiSeriesBarSpec: ChartSpec = {
134
- type: 'bar',
134
+ mark: 'bar',
135
135
  data: [
136
136
  { name: 'A', value: 10, group: 'X' },
137
137
  { name: 'B', value: 30, group: 'X' },
@@ -356,7 +356,7 @@ describe('chart chrome rendering', () => {
356
356
 
357
357
  it('wraps long title text into tspan elements at narrow widths', () => {
358
358
  const longTitleSpec: ChartSpec = {
359
- type: 'bar',
359
+ mark: 'bar',
360
360
  data: [
361
361
  { name: 'A', value: 10 },
362
362
  { name: 'B', value: 20 },
@@ -396,7 +396,7 @@ describe('chart chrome rendering', () => {
396
396
 
397
397
  it('chart with no chrome specified renders no chrome text elements', () => {
398
398
  const noChrome: ChartSpec = {
399
- type: 'bar',
399
+ mark: 'bar',
400
400
  data: [{ name: 'A', value: 10 }],
401
401
  encoding: {
402
402
  x: { field: 'value', type: 'quantitative' },
package/src/mount.ts CHANGED
@@ -18,6 +18,7 @@ import type {
18
18
  DarkMode,
19
19
  ElementEdit,
20
20
  GraphSpec,
21
+ LayerSpec,
21
22
  MeasureTextFn,
22
23
  RangeAnnotation,
23
24
  RefLineAnnotation,
@@ -25,7 +26,8 @@ import type {
25
26
  ThemeConfig,
26
27
  TooltipContent,
27
28
  } from '@opendata-ai/openchart-core';
28
- import { compileChart } from '@opendata-ai/openchart-engine';
29
+ import { isLayerSpec } from '@opendata-ai/openchart-core';
30
+ import { compileChart, compileLayer } from '@opendata-ai/openchart-engine';
29
31
  import {
30
32
  exportCSV,
31
33
  exportJPG,
@@ -60,7 +62,7 @@ export interface ExportOptions extends JPGExportOptions {
60
62
 
61
63
  export interface ChartInstance {
62
64
  /** Re-compile and re-render with a new spec. */
63
- update(spec: ChartSpec | GraphSpec): void;
65
+ update(spec: ChartSpec | LayerSpec | GraphSpec): void;
64
66
  /** Re-compile at current container dimensions. */
65
67
  resize(): void;
66
68
  /** Export the chart. */
@@ -203,6 +205,140 @@ function wireTooltipEvents(
203
205
  };
204
206
  }
205
207
 
208
+ // ---------------------------------------------------------------------------
209
+ // Voronoi overlay tooltip wiring (nearest-point lookup for line/area charts)
210
+ // ---------------------------------------------------------------------------
211
+
212
+ /** A single data point with pixel coordinates, datum, and pre-computed tooltip. */
213
+ interface VoronoiPoint {
214
+ x: number;
215
+ y: number;
216
+ datum: Record<string, unknown>;
217
+ tooltip?: TooltipContent;
218
+ color: string;
219
+ }
220
+
221
+ /**
222
+ * Collect all dataPoints from line and area marks for nearest-point lookup.
223
+ */
224
+ function collectVoronoiPoints(layout: ChartLayout): VoronoiPoint[] {
225
+ const points: VoronoiPoint[] = [];
226
+ for (const mark of layout.marks) {
227
+ if ((mark.type === 'line' || mark.type === 'area') && mark.dataPoints) {
228
+ const color = mark.type === 'line' ? mark.stroke : mark.fill;
229
+ for (const dp of mark.dataPoints) {
230
+ points.push({ ...dp, color });
231
+ }
232
+ }
233
+ }
234
+ return points;
235
+ }
236
+
237
+ /**
238
+ * Find the nearest VoronoiPoint to a given (x, y) position using linear scan.
239
+ * Returns null if no points exist.
240
+ */
241
+ function findNearestPoint(points: VoronoiPoint[], x: number, y: number): VoronoiPoint | null {
242
+ if (points.length === 0) return null;
243
+
244
+ let nearest = points[0];
245
+ let minDist = (points[0].x - x) ** 2 + (points[0].y - y) ** 2;
246
+
247
+ for (let i = 1; i < points.length; i++) {
248
+ const dist = (points[i].x - x) ** 2 + (points[i].y - y) ** 2;
249
+ if (dist < minDist) {
250
+ minDist = dist;
251
+ nearest = points[i];
252
+ }
253
+ }
254
+
255
+ return nearest;
256
+ }
257
+
258
+ /**
259
+ * Wire voronoi overlay tooltip events for line/area charts.
260
+ * Uses a transparent overlay rect with nearest-point lookup instead of
261
+ * per-point event listeners, eliminating DOM bloat.
262
+ * Returns a cleanup function.
263
+ */
264
+ function wireVoronoiTooltipEvents(
265
+ svg: SVGElement,
266
+ layout: ChartLayout,
267
+ tooltipManager: TooltipManager,
268
+ ): () => void {
269
+ const overlay = svg.querySelector('[data-voronoi-overlay]');
270
+ if (!overlay) return () => {};
271
+
272
+ const voronoiPoints = collectVoronoiPoints(layout);
273
+ if (voronoiPoints.length === 0) return () => {};
274
+
275
+ const cleanups: Array<() => void> = [];
276
+
277
+ const handleMouseMove = (e: Event) => {
278
+ const mouseEvent = e as MouseEvent;
279
+ const svgEl = svg as unknown as SVGSVGElement;
280
+ const svgRect = svgEl.getBoundingClientRect();
281
+ const viewBox = svgEl.viewBox?.baseVal;
282
+
283
+ // Convert client coordinates to SVG viewBox coordinates
284
+ const scaleX = viewBox?.width && svgRect.width ? viewBox.width / svgRect.width : 1;
285
+ const scaleY = viewBox?.height && svgRect.height ? viewBox.height / svgRect.height : 1;
286
+ const svgX = (mouseEvent.clientX - svgRect.left) * scaleX;
287
+ const svgY = (mouseEvent.clientY - svgRect.top) * scaleY;
288
+
289
+ const nearest = findNearestPoint(voronoiPoints, svgX, svgY);
290
+ if (!nearest?.tooltip) return;
291
+
292
+ // Show tooltip at the mouse position (relative to container, not SVG viewBox)
293
+ const containerX = mouseEvent.clientX - svgRect.left;
294
+ const containerY = mouseEvent.clientY - svgRect.top;
295
+ tooltipManager.show(nearest.tooltip, containerX, containerY);
296
+ };
297
+
298
+ const handleMouseLeave = () => {
299
+ tooltipManager.hide();
300
+ };
301
+
302
+ // Touch support
303
+ const handleTouchStart = (e: Event) => {
304
+ const touchEvent = e as TouchEvent;
305
+ if (touchEvent.touches.length > 0) {
306
+ const touch = touchEvent.touches[0];
307
+ const svgEl = svg as unknown as SVGSVGElement;
308
+ const svgRect = svgEl.getBoundingClientRect();
309
+ const viewBox = svgEl.viewBox?.baseVal;
310
+
311
+ const scaleX = viewBox?.width && svgRect.width ? viewBox.width / svgRect.width : 1;
312
+ const scaleY = viewBox?.height && svgRect.height ? viewBox.height / svgRect.height : 1;
313
+ const svgX = (touch.clientX - svgRect.left) * scaleX;
314
+ const svgY = (touch.clientY - svgRect.top) * scaleY;
315
+
316
+ const nearest = findNearestPoint(voronoiPoints, svgX, svgY);
317
+ if (!nearest?.tooltip) return;
318
+
319
+ const containerX = touch.clientX - svgRect.left;
320
+ const containerY = touch.clientY - svgRect.top;
321
+ tooltipManager.show(nearest.tooltip, containerX, containerY);
322
+ }
323
+ };
324
+
325
+ overlay.addEventListener('mousemove', handleMouseMove);
326
+ overlay.addEventListener('mouseleave', handleMouseLeave);
327
+ overlay.addEventListener('touchstart', handleTouchStart);
328
+
329
+ cleanups.push(() => {
330
+ overlay.removeEventListener('mousemove', handleMouseMove);
331
+ overlay.removeEventListener('mouseleave', handleMouseLeave);
332
+ overlay.removeEventListener('touchstart', handleTouchStart);
333
+ });
334
+
335
+ return () => {
336
+ for (const cleanup of cleanups) {
337
+ cleanup();
338
+ }
339
+ };
340
+ }
341
+
206
342
  // ---------------------------------------------------------------------------
207
343
  // Chart event wiring (click, hover, leave on marks; legend toggle; annotation click)
208
344
  // ---------------------------------------------------------------------------
@@ -692,10 +828,10 @@ function wireConnectorEndpointDrag(
692
828
  // Determine connector endpoint positions from the connector element
693
829
  let fromX: number, fromY: number, toX: number, toY: number;
694
830
  if (connectorLine) {
695
- fromX = Number(connectorLine.getAttribute('x1'));
696
- fromY = Number(connectorLine.getAttribute('y1'));
697
- toX = Number(connectorLine.getAttribute('x2'));
698
- toY = Number(connectorLine.getAttribute('y2'));
831
+ fromX = Number(connectorLine.getAttribute('x1')) || 0;
832
+ fromY = Number(connectorLine.getAttribute('y1')) || 0;
833
+ toX = Number(connectorLine.getAttribute('x2')) || 0;
834
+ toY = Number(connectorLine.getAttribute('y2')) || 0;
699
835
  } else {
700
836
  // For curved connectors, get positions from the path data
701
837
  // The path starts at M x y, so parse the first coordinates
@@ -708,8 +844,8 @@ function wireConnectorEndpointDrag(
708
844
  const points = arrowhead?.getAttribute('points') ?? '';
709
845
  const firstPoint = points.split(' ')[0] ?? '0,0';
710
846
  const [px, py] = firstPoint.split(',');
711
- toX = Number(px);
712
- toY = Number(py);
847
+ toX = Number(px) || 0;
848
+ toY = Number(py) || 0;
713
849
  }
714
850
 
715
851
  // Create handles dynamically
@@ -721,6 +857,9 @@ function wireConnectorEndpointDrag(
721
857
  const createdHandles: SVGCircleElement[] = [];
722
858
 
723
859
  for (const ep of endpoints) {
860
+ // Skip endpoints with invalid coordinates to prevent NaN in SVG attributes
861
+ if (!Number.isFinite(ep.cx) || !Number.isFinite(ep.cy)) continue;
862
+
724
863
  const handleEl = document.createElementNS(SVG_NS, 'circle') as SVGCircleElement;
725
864
  handleEl.setAttribute('class', 'viz-connector-handle');
726
865
  handleEl.setAttribute('data-endpoint', ep.name);
@@ -1372,15 +1511,16 @@ function createScreenReaderTable(
1372
1511
  */
1373
1512
  export function createChart(
1374
1513
  container: HTMLElement,
1375
- spec: ChartSpec | GraphSpec,
1514
+ spec: ChartSpec | LayerSpec | GraphSpec,
1376
1515
  options?: MountOptions,
1377
1516
  ): ChartInstance {
1378
- let currentSpec: ChartSpec | GraphSpec = spec;
1517
+ let currentSpec: ChartSpec | LayerSpec | GraphSpec = spec;
1379
1518
  let currentLayout: ChartLayout;
1380
1519
  let svgElement: SVGElement | null = null;
1381
1520
  let tooltipManager: TooltipManager | null = null;
1382
1521
  let disconnectResize: (() => void) | null = null;
1383
1522
  let cleanupTooltipEvents: (() => void) | null = null;
1523
+ let cleanupVoronoiEvents: (() => void) | null = null;
1384
1524
  let cleanupKeyboardNav: (() => void) | null = null;
1385
1525
  let cleanupLegend: (() => void) | null = null;
1386
1526
  let cleanupChartEvents: (() => void) | null = null;
@@ -1406,7 +1546,10 @@ export function createChart(
1406
1546
  measureText,
1407
1547
  };
1408
1548
 
1409
- return compileChart(currentSpec, compileOpts);
1549
+ if (isLayerSpec(currentSpec)) {
1550
+ return compileLayer(currentSpec as LayerSpec, compileOpts);
1551
+ }
1552
+ return compileChart(currentSpec as ChartSpec | GraphSpec, compileOpts);
1410
1553
  }
1411
1554
 
1412
1555
  function getContainerDimensions(): { width: number; height: number } {
@@ -1429,6 +1572,10 @@ export function createChart(
1429
1572
  cleanupTooltipEvents();
1430
1573
  cleanupTooltipEvents = null;
1431
1574
  }
1575
+ if (cleanupVoronoiEvents) {
1576
+ cleanupVoronoiEvents();
1577
+ cleanupVoronoiEvents = null;
1578
+ }
1432
1579
  if (cleanupKeyboardNav) {
1433
1580
  cleanupKeyboardNav();
1434
1581
  cleanupKeyboardNav = null;
@@ -1471,6 +1618,9 @@ export function createChart(
1471
1618
  tooltipManager,
1472
1619
  );
1473
1620
 
1621
+ // Wire voronoi overlay tooltip events for line/area charts
1622
+ cleanupVoronoiEvents = wireVoronoiTooltipEvents(svgElement, currentLayout, tooltipManager);
1623
+
1474
1624
  // Wire keyboard navigation
1475
1625
  cleanupKeyboardNav = wireKeyboardNav(
1476
1626
  svgElement,
@@ -1543,13 +1693,14 @@ export function createChart(
1543
1693
  );
1544
1694
 
1545
1695
  // Chrome text drag
1546
- editCleanups.push(wireChromeDrag(svgElement, currentSpec, options.onEdit, setDragging));
1696
+ const editSpec = currentSpec as ChartSpec | GraphSpec;
1697
+ editCleanups.push(wireChromeDrag(svgElement, editSpec, options.onEdit, setDragging));
1547
1698
 
1548
1699
  // Legend drag
1549
- editCleanups.push(wireLegendDrag(svgElement, currentSpec, options.onEdit, setDragging));
1700
+ editCleanups.push(wireLegendDrag(svgElement, editSpec, options.onEdit, setDragging));
1550
1701
 
1551
1702
  // Series label drag
1552
- editCleanups.push(wireSeriesLabelDrag(svgElement, currentSpec, options.onEdit, setDragging));
1703
+ editCleanups.push(wireSeriesLabelDrag(svgElement, editSpec, options.onEdit, setDragging));
1553
1704
 
1554
1705
  cleanupEditDrags = () => {
1555
1706
  for (const cleanup of editCleanups) {
@@ -1625,6 +1776,10 @@ export function createChart(
1625
1776
  cleanupTooltipEvents();
1626
1777
  cleanupTooltipEvents = null;
1627
1778
  }
1779
+ if (cleanupVoronoiEvents) {
1780
+ cleanupVoronoiEvents();
1781
+ cleanupVoronoiEvents = null;
1782
+ }
1628
1783
  if (cleanupKeyboardNav) {
1629
1784
  cleanupKeyboardNav();
1630
1785
  cleanupKeyboardNav = null;
@@ -22,7 +22,10 @@ import type {
22
22
  RectMark,
23
23
  ResolvedAnnotation,
24
24
  ResolvedChromeElement,
25
+ RuleMarkLayout,
26
+ TextMarkLayout,
25
27
  TextStyle,
28
+ TickMarkLayout,
26
29
  } from '@opendata-ai/openchart-core';
27
30
  import { estimateTextWidth } from '@opendata-ai/openchart-core';
28
31
 
@@ -80,15 +83,7 @@ function applyTextStyle(el: SVGElement, style: TextStyle): void {
80
83
  el.setAttribute('text-anchor', style.textAnchor);
81
84
  }
82
85
  if (style.dominantBaseline) {
83
- // WebKit/iOS Safari has a getBBox() bug with dominant-baseline: hanging
84
- // where the bounding box extends above y=0, causing the SVG's default
85
- // overflow:hidden to clip the text. Use a dy offset instead, which
86
- // achieves the same visual positioning without the bbox issue.
87
- if (style.dominantBaseline === 'hanging') {
88
- el.setAttribute('dy', `${style.fontSize * 0.8}px`);
89
- } else {
90
- el.setAttribute('dominant-baseline', style.dominantBaseline);
91
- }
86
+ el.setAttribute('dominant-baseline', style.dominantBaseline);
92
87
  }
93
88
  if (style.fontVariant) {
94
89
  el.setAttribute('font-variant', style.fontVariant);
@@ -314,7 +309,7 @@ function renderAxis(
314
309
  y2: gridline.position,
315
310
  stroke: layout.theme.colors.gridline,
316
311
  'stroke-width': 1,
317
- 'stroke-opacity': 0.35,
312
+ 'stroke-opacity': 0.6,
318
313
  });
319
314
  } else {
320
315
  setAttrs(gl, {
@@ -324,7 +319,7 @@ function renderAxis(
324
319
  y2: area.y + area.height,
325
320
  stroke: layout.theme.colors.gridline,
326
321
  'stroke-width': 1,
327
- 'stroke-opacity': 0.35,
322
+ 'stroke-opacity': 0.6,
328
323
  });
329
324
  }
330
325
  g.appendChild(gl);
@@ -581,12 +576,93 @@ function renderPointMark(mark: PointMark, index: number): SVGElement {
581
576
  return circle;
582
577
  }
583
578
 
579
+ function renderTextMark(mark: TextMarkLayout, index: number): SVGElement {
580
+ const text = createSVGElement('text');
581
+ text.setAttribute('data-mark-id', `textMark-${index}`);
582
+ text.setAttribute('class', 'viz-mark viz-mark-text');
583
+ setAttrs(text, {
584
+ x: mark.x,
585
+ y: mark.y,
586
+ 'font-size': mark.fontSize,
587
+ 'text-anchor': mark.textAnchor,
588
+ });
589
+ (text as SVGElement & ElementCSSInlineStyle).style.setProperty('fill', mark.fill);
590
+ if (mark.fontWeight) {
591
+ text.setAttribute('font-weight', String(mark.fontWeight));
592
+ }
593
+ if (mark.fontFamily) {
594
+ text.setAttribute('font-family', mark.fontFamily);
595
+ }
596
+ if (mark.angle) {
597
+ text.setAttribute('transform', `rotate(${mark.angle}, ${mark.x}, ${mark.y})`);
598
+ }
599
+ text.textContent = mark.text;
600
+ return text;
601
+ }
602
+
603
+ function renderRuleMark(mark: RuleMarkLayout, index: number): SVGElement {
604
+ const line = createSVGElement('line');
605
+ line.setAttribute('data-mark-id', `rule-${index}`);
606
+ line.setAttribute('class', 'viz-mark viz-mark-rule');
607
+ setAttrs(line, {
608
+ x1: mark.x1,
609
+ y1: mark.y1,
610
+ x2: mark.x2,
611
+ y2: mark.y2,
612
+ stroke: mark.stroke,
613
+ 'stroke-width': mark.strokeWidth,
614
+ });
615
+ if (mark.strokeDasharray) {
616
+ line.setAttribute('stroke-dasharray', mark.strokeDasharray);
617
+ }
618
+ if (mark.opacity != null) {
619
+ line.setAttribute('opacity', String(mark.opacity));
620
+ }
621
+ return line;
622
+ }
623
+
624
+ function renderTickMark(mark: TickMarkLayout, index: number): SVGElement {
625
+ const line = createSVGElement('line');
626
+ line.setAttribute('data-mark-id', `tick-${index}`);
627
+ line.setAttribute('class', 'viz-mark viz-mark-tick');
628
+
629
+ // Tick is a short line segment centered at (x, y)
630
+ const half = mark.length / 2;
631
+ if (mark.orient === 'vertical') {
632
+ setAttrs(line, {
633
+ x1: mark.x,
634
+ y1: mark.y - half,
635
+ x2: mark.x,
636
+ y2: mark.y + half,
637
+ stroke: mark.stroke,
638
+ 'stroke-width': mark.strokeWidth,
639
+ });
640
+ } else {
641
+ setAttrs(line, {
642
+ x1: mark.x - half,
643
+ y1: mark.y,
644
+ x2: mark.x + half,
645
+ y2: mark.y,
646
+ stroke: mark.stroke,
647
+ 'stroke-width': mark.strokeWidth,
648
+ });
649
+ }
650
+
651
+ if (mark.opacity != null) {
652
+ line.setAttribute('opacity', String(mark.opacity));
653
+ }
654
+ return line;
655
+ }
656
+
584
657
  // Register built-in renderers
585
658
  registerMarkRenderer('line', renderLineMark as MarkRenderer<Mark>);
586
659
  registerMarkRenderer('area', renderAreaMark as MarkRenderer<Mark>);
587
660
  registerMarkRenderer('rect', renderRectMark as MarkRenderer<Mark>);
588
661
  registerMarkRenderer('arc', renderArcMark as MarkRenderer<Mark>);
589
662
  registerMarkRenderer('point', renderPointMark as MarkRenderer<Mark>);
663
+ registerMarkRenderer('textMark', renderTextMark as MarkRenderer<Mark>);
664
+ registerMarkRenderer('rule', renderRuleMark as MarkRenderer<Mark>);
665
+ registerMarkRenderer('tick', renderTickMark as MarkRenderer<Mark>);
590
666
 
591
667
  /** Extract series name from a mark for legend toggle matching. */
592
668
  function getMarkSeries(mark: Mark): string | undefined {
@@ -1031,7 +1107,7 @@ function renderBrand(parent: SVGElement, layout: ChartLayout): void {
1031
1107
  setAttrs(text, {
1032
1108
  x: rightEdge,
1033
1109
  y: chromeY,
1034
- dy: BRAND_FONT_SIZE * 0.8,
1110
+ 'dominant-baseline': 'hanging',
1035
1111
  'font-family': layout.theme.fonts.family,
1036
1112
  'font-size': BRAND_FONT_SIZE,
1037
1113
  'text-anchor': 'end',
@@ -1071,6 +1147,12 @@ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVG
1071
1147
  setAttrs(svg, {
1072
1148
  viewBox: `0 0 ${width} ${height}`,
1073
1149
  xmlns: SVG_NS,
1150
+ // WebKit/iOS Safari getBBox() bug: text with dominant-baseline:hanging
1151
+ // reports bounding boxes extending above y=0. The SVG spec default
1152
+ // overflow is "hidden", which clips this phantom extent. Setting
1153
+ // overflow:visible prevents the clipping. Chart marks are already
1154
+ // constrained by a clipPath, so nothing bleeds out.
1155
+ overflow: 'visible',
1074
1156
  });
1075
1157
  // Set explicit pixel height via inline style. iOS Safari misresolves CSS
1076
1158
  // height:100% when the ancestor chain uses minHeight instead of height,
@@ -1117,6 +1199,28 @@ export function renderChartSVG(layout: ChartLayout, container: HTMLElement): SVG
1117
1199
  const clippedGroup = createSVGElement('g');
1118
1200
  clippedGroup.setAttribute('clip-path', `url(#${clipId})`);
1119
1201
  renderMarks(clippedGroup, layout);
1202
+
1203
+ // Add transparent overlay rect for line/area charts to enable voronoi tooltip lookup.
1204
+ // Only added when there are line or area marks with dataPoints, and no explicit
1205
+ // PointMark objects (which use per-element event handling instead).
1206
+ const hasLineOrAreaWithDataPoints = layout.marks.some(
1207
+ (m) => (m.type === 'line' || m.type === 'area') && m.dataPoints && m.dataPoints.length > 0,
1208
+ );
1209
+ const hasPointMarks = layout.marks.some((m) => m.type === 'point');
1210
+ if (hasLineOrAreaWithDataPoints && !hasPointMarks) {
1211
+ const overlay = createSVGElement('rect');
1212
+ setAttrs(overlay, {
1213
+ x: layout.area.x,
1214
+ y: layout.area.y,
1215
+ width: layout.area.width,
1216
+ height: layout.area.height,
1217
+ fill: 'transparent',
1218
+ });
1219
+ overlay.setAttribute('class', 'viz-voronoi-overlay');
1220
+ overlay.setAttribute('data-voronoi-overlay', 'true');
1221
+ clippedGroup.appendChild(overlay);
1222
+ }
1223
+
1120
1224
  svg.appendChild(clippedGroup);
1121
1225
 
1122
1226
  renderAnnotations(svg, layout);