@pharos-ds/charts 0.0.0-bootstrap.0 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (139) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/LICENSE +24 -0
  3. package/README.md +118 -0
  4. package/dist/Chart.d.ts +62 -0
  5. package/dist/Chart.d.ts.map +1 -0
  6. package/dist/Chart.js +401 -0
  7. package/dist/ChartAxis.d.ts +37 -0
  8. package/dist/ChartAxis.d.ts.map +1 -0
  9. package/dist/ChartAxis.js +233 -0
  10. package/dist/ChartContext.d.ts +4 -0
  11. package/dist/ChartContext.d.ts.map +1 -0
  12. package/dist/ChartContext.js +15 -0
  13. package/dist/ChartGrid.d.ts +26 -0
  14. package/dist/ChartGrid.d.ts.map +1 -0
  15. package/dist/ChartGrid.js +118 -0
  16. package/dist/ChartLegend.d.ts +14 -0
  17. package/dist/ChartLegend.d.ts.map +1 -0
  18. package/dist/ChartLegend.js +62 -0
  19. package/dist/ChartSwatch.d.ts +36 -0
  20. package/dist/ChartSwatch.d.ts.map +1 -0
  21. package/dist/ChartSwatch.js +85 -0
  22. package/dist/ChartTooltip.d.ts +46 -0
  23. package/dist/ChartTooltip.d.ts.map +1 -0
  24. package/dist/ChartTooltip.js +330 -0
  25. package/dist/charts.css +33 -0
  26. package/dist/dataTable.d.ts +56 -0
  27. package/dist/dataTable.d.ts.map +1 -0
  28. package/dist/dataTable.js +88 -0
  29. package/dist/formatters.d.ts +37 -0
  30. package/dist/formatters.d.ts.map +1 -0
  31. package/dist/formatters.js +156 -0
  32. package/dist/getChartColors.d.ts +67 -0
  33. package/dist/getChartColors.d.ts.map +1 -0
  34. package/dist/getChartColors.js +191 -0
  35. package/dist/index.d.ts +17 -0
  36. package/dist/index.d.ts.map +1 -0
  37. package/dist/index.js +19 -0
  38. package/dist/layout.d.ts +40 -0
  39. package/dist/layout.d.ts.map +1 -0
  40. package/dist/layout.js +322 -0
  41. package/dist/legend.d.ts +17 -0
  42. package/dist/legend.d.ts.map +1 -0
  43. package/dist/legend.js +40 -0
  44. package/dist/markColor.d.ts +20 -0
  45. package/dist/markColor.d.ts.map +1 -0
  46. package/dist/markColor.js +56 -0
  47. package/dist/marks/area.d.ts +19 -0
  48. package/dist/marks/area.d.ts.map +1 -0
  49. package/dist/marks/area.js +121 -0
  50. package/dist/marks/band.d.ts +9 -0
  51. package/dist/marks/band.d.ts.map +1 -0
  52. package/dist/marks/band.js +64 -0
  53. package/dist/marks/bar.d.ts +21 -0
  54. package/dist/marks/bar.d.ts.map +1 -0
  55. package/dist/marks/bar.js +161 -0
  56. package/dist/marks/candlestick.d.ts +16 -0
  57. package/dist/marks/candlestick.d.ts.map +1 -0
  58. package/dist/marks/candlestick.js +110 -0
  59. package/dist/marks/dot.d.ts +19 -0
  60. package/dist/marks/dot.d.ts.map +1 -0
  61. package/dist/marks/dot.js +98 -0
  62. package/dist/marks/dotGL.d.ts +16 -0
  63. package/dist/marks/dotGL.d.ts.map +1 -0
  64. package/dist/marks/dotGL.js +231 -0
  65. package/dist/marks/dotGLInteractive.d.ts +35 -0
  66. package/dist/marks/dotGLInteractive.d.ts.map +1 -0
  67. package/dist/marks/dotGLInteractive.js +584 -0
  68. package/dist/marks/errorBar.d.ts +29 -0
  69. package/dist/marks/errorBar.d.ts.map +1 -0
  70. package/dist/marks/errorBar.js +95 -0
  71. package/dist/marks/heatmapGL.d.ts +27 -0
  72. package/dist/marks/heatmapGL.d.ts.map +1 -0
  73. package/dist/marks/heatmapGL.js +335 -0
  74. package/dist/marks/index.d.ts +13 -0
  75. package/dist/marks/index.d.ts.map +1 -0
  76. package/dist/marks/index.js +14 -0
  77. package/dist/marks/line.d.ts +18 -0
  78. package/dist/marks/line.d.ts.map +1 -0
  79. package/dist/marks/line.js +83 -0
  80. package/dist/marks/referenceLine.d.ts +37 -0
  81. package/dist/marks/referenceLine.d.ts.map +1 -0
  82. package/dist/marks/referenceLine.js +204 -0
  83. package/dist/marks/streamGL.d.ts +41 -0
  84. package/dist/marks/streamGL.d.ts.map +1 -0
  85. package/dist/marks/streamGL.js +317 -0
  86. package/dist/tooltip.d.ts +38 -0
  87. package/dist/tooltip.d.ts.map +1 -0
  88. package/dist/tooltip.js +57 -0
  89. package/dist/types.d.ts +193 -0
  90. package/dist/types.d.ts.map +1 -0
  91. package/dist/types.js +70 -0
  92. package/dist/useChartColors.d.ts +16 -0
  93. package/dist/useChartColors.d.ts.map +1 -0
  94. package/dist/useChartColors.js +37 -0
  95. package/dist/utils.d.ts +15 -0
  96. package/dist/utils.d.ts.map +1 -0
  97. package/dist/utils.js +31 -0
  98. package/dist/webgl.d.ts +77 -0
  99. package/dist/webgl.d.ts.map +1 -0
  100. package/dist/webgl.js +235 -0
  101. package/package.json +72 -4
  102. package/src/Chart.doc.mjs +174 -0
  103. package/src/Chart.test.tsx +273 -0
  104. package/src/Chart.tsx +535 -0
  105. package/src/ChartAxis.tsx +289 -0
  106. package/src/ChartContext.ts +18 -0
  107. package/src/ChartGrid.tsx +136 -0
  108. package/src/ChartLegend.tsx +77 -0
  109. package/src/ChartSwatch.doc.mjs +17 -0
  110. package/src/ChartSwatch.tsx +89 -0
  111. package/src/ChartTooltip.tsx +452 -0
  112. package/src/charts.css.d.ts +3 -0
  113. package/src/dataTable.test.ts +86 -0
  114. package/src/dataTable.ts +124 -0
  115. package/src/formatters.ts +167 -0
  116. package/src/getChartColors.test.ts +114 -0
  117. package/src/getChartColors.ts +282 -0
  118. package/src/index.ts +88 -0
  119. package/src/layout.ts +372 -0
  120. package/src/legend.ts +44 -0
  121. package/src/markColor.ts +70 -0
  122. package/src/marks/area.tsx +142 -0
  123. package/src/marks/band.tsx +86 -0
  124. package/src/marks/bar.tsx +210 -0
  125. package/src/marks/candlestick.tsx +128 -0
  126. package/src/marks/dot.tsx +119 -0
  127. package/src/marks/dotGL.tsx +274 -0
  128. package/src/marks/dotGLInteractive.tsx +660 -0
  129. package/src/marks/errorBar.tsx +119 -0
  130. package/src/marks/heatmapGL.tsx +392 -0
  131. package/src/marks/index.ts +17 -0
  132. package/src/marks/line.tsx +118 -0
  133. package/src/marks/referenceLine.tsx +252 -0
  134. package/src/marks/streamGL.tsx +381 -0
  135. package/src/tooltip.ts +73 -0
  136. package/src/types.ts +212 -0
  137. package/src/useChartColors.ts +41 -0
  138. package/src/utils.ts +38 -0
  139. package/src/webgl.ts +262 -0
package/src/Chart.tsx ADDED
@@ -0,0 +1,535 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ /**
4
+ * @file Chart.tsx (v2)
5
+ * @output Root chart — coordinates layout, rendering, and events
6
+ * @position Top-level; delegates everything to series configs and interaction slots
7
+ *
8
+ * The chart root has ZERO knowledge of mark types. It:
9
+ * 1. Runs the layout engine (scales + stacking + grouping)
10
+ * 2. Calls each series' resolve() and render() methods
11
+ * 3. Provides a single event layer for interaction children
12
+ * 4. Exposes the text alternative (WCAG 1.1.1): the SVG is `role="img"` with
13
+ * an accessible name (`aria-label` → `title` → series labels), optional
14
+ * `<desc>` from `subtitle`/`description`, and a visually hidden data table
15
+ * built from `data` (opt out with `dataTable={false}`). Plot internals are
16
+ * `aria-hidden`.
17
+ * 5. Transposes the plot for `orientation="horizontal"`: marks resolve in the
18
+ * vertical plot space and are drawn through a 90° transform, while axes
19
+ * and grid read display-space scales from context.
20
+ *
21
+ * SYNC: When modified, update /packages/charts/src/Chart.doc.mjs and
22
+ * /packages/charts/README.md
23
+ */
24
+
25
+ 'use client';
26
+
27
+ import {
28
+ type ReactNode,
29
+ useId,
30
+ useMemo,
31
+ useRef,
32
+ useState,
33
+ useCallback,
34
+ useLayoutEffect,
35
+ } from 'react';
36
+ import type {SeriesDef, YBaseline, ChartOrientation} from './types';
37
+ import type {ChartContext, ChartMargin, ChartPointerEvent} from './types';
38
+ import {computeLayout} from './layout';
39
+ import {ChartProvider} from './ChartContext';
40
+ import {Text} from '@pharos-ds/core';
41
+ import {VStack, HStack} from '@pharos-ds/core';
42
+ import {VisuallyHidden} from '@pharos-ds/core';
43
+ import * as stylex from '@stylexjs/stylex';
44
+ import {scaleLinear} from 'd3-scale';
45
+ import {
46
+ buildDataTable,
47
+ fallbackChartLabel,
48
+ type ChartDataTableOptions,
49
+ } from './dataTable';
50
+ import {isBandScale} from './utils';
51
+ import {ChartLegend, type ChartLegendProps} from './ChartLegend';
52
+ import {deriveLegendItems} from './legend';
53
+ import {ChartTooltip, type ChartTooltipProps} from './ChartTooltip';
54
+ import {useChartColors} from './useChartColors';
55
+ import {isUtilityMarkType} from './types';
56
+
57
+ export interface ChartProps {
58
+ data: Record<string, unknown>[];
59
+ xKey: string;
60
+ series: SeriesDef[];
61
+ height?: number;
62
+ margin?: Partial<ChartMargin>;
63
+ /** How the y-domain is derived when `yDomain` is not set. Default `'auto'`. */
64
+ yBaseline?: YBaseline;
65
+ /** Explicit y-domain [min, max]. Authoritative — disables baseline/headroom. */
66
+ yDomain?: [number, number];
67
+ /**
68
+ * Explicit x-domain [min, max] for numeric/linear x. Honored even when `data`
69
+ * is empty (stable streaming window). Ignored for categorical (band) scales.
70
+ */
71
+ xDomain?: [number, number];
72
+ grid?: ReactNode;
73
+ axes?: ReactNode;
74
+ legend?: boolean | ChartLegendProps;
75
+ tooltip?: boolean | Omit<ChartTooltipProps, 'series'>;
76
+ interactions?: ReactNode;
77
+ children?: ReactNode;
78
+ /**
79
+ * Visible heading above the chart. Also the chart's accessible name and the
80
+ * hidden data table's caption, unless `aria-label` is given.
81
+ */
82
+ title?: string;
83
+ /** Visible supporting line under the title; exposed as the SVG description. */
84
+ subtitle?: string;
85
+ /**
86
+ * Accessible name for the chart (`role="img"`). Defaults to `title`, then to
87
+ * the data series labels. Use it when the visible title lives outside the
88
+ * chart (e.g. a Card header).
89
+ */
90
+ 'aria-label'?: string;
91
+ /**
92
+ * Text alternative summarizing what the chart shows (trend, highlights). Not
93
+ * rendered visibly; exposed via `aria-describedby` alongside `subtitle`.
94
+ */
95
+ description?: string;
96
+ /**
97
+ * Visually hidden `<table>` with the chart's data, for screen readers
98
+ * (caption = accessible name, header row = x label + series labels, one row
99
+ * per x value). Default `true`. Pass `false` to opt out (e.g. when the same
100
+ * data is already on the page as a table) or an object to customize labels
101
+ * and formatting.
102
+ */
103
+ dataTable?: boolean | ChartDataTableOptions;
104
+ /**
105
+ * `'vertical'` (default) or `'horizontal'` — categories on the y-axis and
106
+ * values growing right (horizontal bars). Axes and grid follow automatically;
107
+ * for the tooltip use the `tooltip` prop (not a `ChartTooltip` child).
108
+ */
109
+ orientation?: ChartOrientation;
110
+ }
111
+
112
+ const DEFAULT_MARGIN: ChartMargin = {top: 24, right: 24, bottom: 32, left: 48};
113
+
114
+ const styles = stylex.create({
115
+ container: {
116
+ width: '100%',
117
+ },
118
+ title: {
119
+ marginBottom: 16,
120
+ },
121
+ chartArea: {
122
+ flex: 1,
123
+ minWidth: 0,
124
+ overflow: 'hidden',
125
+ },
126
+ });
127
+
128
+ export function Chart({
129
+ data,
130
+ xKey,
131
+ series,
132
+ height = 300,
133
+ margin: marginOverride,
134
+ yBaseline,
135
+ yDomain,
136
+ xDomain,
137
+ grid,
138
+ axes,
139
+ legend,
140
+ tooltip,
141
+ interactions,
142
+ children,
143
+ title,
144
+ subtitle,
145
+ 'aria-label': ariaLabel,
146
+ description,
147
+ dataTable = true,
148
+ orientation = 'vertical',
149
+ }: ChartProps) {
150
+ const chartId = useId();
151
+ const descId = `${chartId}-desc`;
152
+ const isHorizontal = orientation === 'horizontal';
153
+ // useId() can contain ':' which is invalid in an SVG url(#id) reference.
154
+ const clipId = `plot-${chartId.replace(/:/g, '')}`;
155
+ const containerRef = useRef<HTMLDivElement>(null);
156
+ const svgRef = useRef<SVGSVGElement>(null);
157
+ const [containerWidth, setContainerWidth] = useState(0);
158
+ const pointerHandlers = useRef<Set<(e: ChartPointerEvent) => void>>(
159
+ new Set(),
160
+ );
161
+
162
+ useLayoutEffect(() => {
163
+ if (!containerRef.current) {
164
+ return;
165
+ }
166
+ const observer = new ResizeObserver(entries => {
167
+ const entry = entries[0];
168
+ if (entry) {
169
+ setContainerWidth(entry.contentRect.width);
170
+ }
171
+ });
172
+ observer.observe(containerRef.current);
173
+ return () => observer.disconnect();
174
+ }, []);
175
+
176
+ const margin = useMemo(
177
+ () => ({...DEFAULT_MARGIN, ...marginOverride}),
178
+ [marginOverride],
179
+ );
180
+ // Clamp plot dimensions to finite, non-negative values. layout.ts hardens
181
+ // against NaN in the *data*, but a non-finite `height` prop or margin override
182
+ // would still collapse every scale range — and the clipPath/event-rect
183
+ // geometry — into NaN. `containerWidth` is always finite (from ResizeObserver).
184
+ const safeHeight = Number.isFinite(height) ? Math.max(0, height) : 0;
185
+ const rawInnerWidth = containerWidth - margin.left - margin.right;
186
+ const rawInnerHeight = safeHeight - margin.top - margin.bottom;
187
+ const innerWidth = Number.isFinite(rawInnerWidth)
188
+ ? Math.max(0, rawInnerWidth)
189
+ : 0;
190
+ const innerHeight = Number.isFinite(rawInnerHeight)
191
+ ? Math.max(0, rawInnerHeight)
192
+ : 0;
193
+ // Plot space: marks always resolve as if the chart were vertical. For a
194
+ // horizontal chart the category axis runs along the display height and the
195
+ // value axis along the display width, so the plot box is transposed.
196
+ const plotWidth = isHorizontal ? innerHeight : innerWidth;
197
+ const plotHeight = isHorizontal ? innerWidth : innerHeight;
198
+
199
+ // ─── Color assignment ─────────────────────────────────────────────────
200
+ // Give every primary series that doesn't supply a static color (auto-colored
201
+ // or accessor-colored) a distinct color from the theme's categorical palette.
202
+ // Utility marks (band/errorBar/referenceLine) don't consume palette slots.
203
+ // Assigned during render (like the layout pass annotates `_uid`) so the first
204
+ // paint is already colored; intentionally not a `useMemo` — it mutates the
205
+ // caller's series defs, and useMemo is allowed to drop its cached result.
206
+ const chartColors = useChartColors();
207
+ const needsColor = series.filter(
208
+ s => !isUtilityMarkType(s.type) && s.color == null,
209
+ );
210
+ const palette = chartColors.categorical(Math.max(needsColor.length, 1));
211
+ for (let i = 0; i < needsColor.length; i++) {
212
+ needsColor[i]._resolvedColor = palette[i % palette.length];
213
+ }
214
+
215
+ // ─── Layout pass ──────────────────────────────────────────────────────
216
+ const layout = useMemo(
217
+ () =>
218
+ computeLayout({
219
+ data,
220
+ xKey,
221
+ series,
222
+ width: plotWidth,
223
+ height: plotHeight,
224
+ yBaseline,
225
+ yDomain,
226
+ xDomain,
227
+ }),
228
+ [data, xKey, series, plotWidth, plotHeight, yBaseline, yDomain, xDomain],
229
+ );
230
+
231
+ const seriesCtx = useMemo(
232
+ () => ({
233
+ data,
234
+ xKey,
235
+ xScale: layout.xScale,
236
+ yScale: layout.yScale,
237
+ yBandScale: layout.yBandScale,
238
+ width: plotWidth,
239
+ height: plotHeight,
240
+ }),
241
+ [data, xKey, layout, plotWidth, plotHeight],
242
+ );
243
+
244
+ // ─── Event dispatch ───────────────────────────────────────────────────
245
+ const onPointer = useCallback((handler: (e: ChartPointerEvent) => void) => {
246
+ pointerHandlers.current.add(handler);
247
+ return () => {
248
+ pointerHandlers.current.delete(handler);
249
+ };
250
+ }, []);
251
+
252
+ const dispatch = useCallback((e: ChartPointerEvent) => {
253
+ for (const handler of pointerHandlers.current) {
254
+ handler(e);
255
+ }
256
+ }, []);
257
+
258
+ const handlePointerMove = useCallback(
259
+ (e: React.PointerEvent<SVGRectElement>) => {
260
+ const svg = svgRef.current;
261
+ if (!svg) {
262
+ return;
263
+ }
264
+ const point = svg.createSVGPoint();
265
+ point.x = e.clientX;
266
+ point.y = e.clientY;
267
+ const ctm = e.currentTarget.getScreenCTM()?.inverse();
268
+ if (!ctm) {
269
+ return;
270
+ }
271
+ const cursor = point.matrixTransform(ctm);
272
+
273
+ // Find nearest point by category distance only (the full column — or
274
+ // row, when horizontal — is the hover zone). Resolved points live in
275
+ // plot space, where the category position is always `px`.
276
+ const cursorCategory = isHorizontal ? cursor.y : cursor.x;
277
+ let nearest: ChartPointerEvent['nearest'] = null;
278
+ let bestXDist = Infinity;
279
+ for (const [seriesKey, points] of layout.resolved) {
280
+ for (const p of points) {
281
+ const xDist = Math.abs(p.px - cursorCategory);
282
+ if (xDist < bestXDist) {
283
+ bestXDist = xDist;
284
+ nearest = {...p, seriesKey};
285
+ }
286
+ }
287
+ }
288
+ dispatch({x: cursor.x, y: cursor.y, nearest, active: e.buttons > 0});
289
+ },
290
+ [layout.resolved, dispatch, isHorizontal],
291
+ );
292
+
293
+ const handlePointerLeave = useCallback(() => {
294
+ dispatch({x: -1, y: -1, nearest: null, active: false});
295
+ }, [dispatch]);
296
+
297
+ // ─── Context for interactions + v1 chrome ─────────────────────────────
298
+ // `plotCtx` is the vertical plot space marks resolve in (it is also the
299
+ // display space for vertical charts). For horizontal charts, chrome gets a
300
+ // display-space view: the value scale runs along x (left → right) and the
301
+ // category scale along y (top → bottom).
302
+ const plotCtx: ChartContext = useMemo(
303
+ () => ({
304
+ orientation,
305
+ width: plotWidth,
306
+ height: plotHeight,
307
+ margin,
308
+ data,
309
+ xKey,
310
+ xScale: layout.xScale,
311
+ yScale: layout.yScale,
312
+ yBandScale: layout.yBandScale,
313
+ resolved: layout.resolved,
314
+ onPointer,
315
+ svgRef,
316
+ }),
317
+ [orientation, plotWidth, plotHeight, margin, data, xKey, layout, onPointer],
318
+ );
319
+
320
+ const ctx: ChartContext = useMemo(() => {
321
+ if (!isHorizontal) {
322
+ return plotCtx;
323
+ }
324
+ const valueScale = layout.yScale.copy().range([0, innerWidth]);
325
+ const plotX = layout.xScale;
326
+ return {
327
+ ...plotCtx,
328
+ width: innerWidth,
329
+ height: innerHeight,
330
+ xScale: valueScale,
331
+ ...(isBandScale(plotX)
332
+ ? {
333
+ yBandScale: plotX.copy().range([0, innerHeight]),
334
+ // Unused for a categorical y; kept so `yScale` stays a valid scale.
335
+ yScale: scaleLinear().range([innerHeight, 0]),
336
+ }
337
+ : {
338
+ yBandScale: undefined,
339
+ yScale: plotX.copy().range([0, innerHeight]),
340
+ }),
341
+ };
342
+ }, [plotCtx, isHorizontal, layout, innerWidth, innerHeight]);
343
+
344
+ // ─── Accessibility ────────────────────────────────────────────────────
345
+ const accessibleName =
346
+ ariaLabel || title || fallbackChartLabel(series) || undefined;
347
+ const descriptionText = [subtitle, description].filter(Boolean).join('. ');
348
+ const tableModel = useMemo(
349
+ () =>
350
+ dataTable === false
351
+ ? null
352
+ : buildDataTable(
353
+ data,
354
+ xKey,
355
+ series,
356
+ dataTable === true ? {} : dataTable,
357
+ ),
358
+ [data, xKey, series, dataTable],
359
+ );
360
+ const tableElement = tableModel ? (
361
+ <VisuallyHidden as="div">
362
+ <table>
363
+ {accessibleName && <caption>{accessibleName}</caption>}
364
+ <thead>
365
+ <tr>
366
+ {tableModel.headers.map((h, i) => (
367
+ <th key={i} scope="col">
368
+ {h}
369
+ </th>
370
+ ))}
371
+ </tr>
372
+ </thead>
373
+ <tbody>
374
+ {tableModel.rows.map(row => (
375
+ <tr key={row.key}>
376
+ <th scope="row">{row.header}</th>
377
+ {row.cells.map((cell, i) => (
378
+ <td key={i}>{cell}</td>
379
+ ))}
380
+ </tr>
381
+ ))}
382
+ </tbody>
383
+ </table>
384
+ </VisuallyHidden>
385
+ ) : null;
386
+
387
+ // ─── Legend ────────────────────────────────────────────────────────────
388
+ const legendConfig = legend === true ? {} : legend || null;
389
+
390
+ const legendElement = legendConfig ? (
391
+ <ChartLegend
392
+ items={legendConfig.items ?? deriveLegendItems(series)}
393
+ position={legendConfig.position}
394
+ alignment={legendConfig.alignment}
395
+ />
396
+ ) : null;
397
+
398
+ const legendPosition = legendConfig?.position ?? 'bottom';
399
+ const isLegendHorizontal =
400
+ legendPosition === 'start' || legendPosition === 'end';
401
+
402
+ // ─── Render ───────────────────────────────────────────────────────────
403
+ // Before the first measurement (and during SSR) only the sized placeholder
404
+ // renders — plus the hidden data table, which doesn't depend on width, so
405
+ // the text alternative is in the server HTML too.
406
+ if (containerWidth === 0) {
407
+ return (
408
+ <div
409
+ ref={containerRef}
410
+ {...stylex.props(styles.container)}
411
+ style={{height: safeHeight}}>
412
+ {tableElement}
413
+ </div>
414
+ );
415
+ }
416
+
417
+ return (
418
+ <div ref={containerRef} {...stylex.props(styles.container)}>
419
+ {(title || subtitle) && (
420
+ <div {...stylex.props(styles.title)}>
421
+ {title && (
422
+ <Text type="body" weight="semibold" display="block">
423
+ {title}
424
+ </Text>
425
+ )}
426
+ {subtitle && (
427
+ <Text type="supporting" display="block">
428
+ {subtitle}
429
+ </Text>
430
+ )}
431
+ </div>
432
+ )}
433
+ <ChartProvider value={ctx}>
434
+ {legendConfig == null ? (
435
+ renderSvg()
436
+ ) : isLegendHorizontal ? (
437
+ <HStack gap={4}>
438
+ {legendPosition === 'start' && legendElement}
439
+ <div {...stylex.props(styles.chartArea)}>{renderSvg()}</div>
440
+ {legendPosition === 'end' && legendElement}
441
+ </HStack>
442
+ ) : (
443
+ <VStack gap={4}>
444
+ {legendPosition === 'top' && legendElement}
445
+ {renderSvg()}
446
+ {legendPosition === 'bottom' && legendElement}
447
+ </VStack>
448
+ )}
449
+ </ChartProvider>
450
+ {tableElement}
451
+ </div>
452
+ );
453
+
454
+ function renderSvg() {
455
+ const plotTransform = isHorizontal
456
+ ? `matrix(0,1,-1,0,${innerWidth},0)`
457
+ : undefined;
458
+ return (
459
+ <svg
460
+ ref={svgRef}
461
+ width="100%"
462
+ height={safeHeight}
463
+ role="img"
464
+ aria-label={accessibleName}
465
+ aria-describedby={descriptionText ? descId : undefined}>
466
+ {descriptionText && <desc id={descId}>{descriptionText}</desc>}
467
+ <defs>
468
+ <clipPath id={clipId}>
469
+ <rect x={0} y={0} width={innerWidth} height={innerHeight} />
470
+ </clipPath>
471
+ </defs>
472
+ <g transform={`translate(${margin.left},${margin.top})`}>
473
+ {/* 1-3. Decorative plot internals — the text alternative is the
474
+ accessible name + hidden data table, so these stay out of the
475
+ accessibility tree. */}
476
+ <g aria-hidden="true">
477
+ {/* 1. Grid */}
478
+ {grid}
479
+
480
+ {/* 2. Marks — each series renders itself. Clipped to the plot area
481
+ so curve overshoot (monotone/natural) can't escape into the
482
+ margins. Horizontal charts draw the vertical plot space through
483
+ a transpose: plot (px, py) → display (innerWidth − py, px). */}
484
+ <g clipPath={`url(#${clipId})`}>
485
+ <g transform={plotTransform}>
486
+ {series.map(s => {
487
+ const resolved = s._uid
488
+ ? layout.resolved.get(s._uid)
489
+ : undefined;
490
+ if (!resolved) {
491
+ return null;
492
+ }
493
+ return <g key={s._uid}>{s.render(resolved, seriesCtx)}</g>;
494
+ })}
495
+ </g>
496
+ </g>
497
+
498
+ {/* 3. Axes */}
499
+ {axes}
500
+ </g>
501
+
502
+ {/* 4. Event capture + interactions */}
503
+ <rect
504
+ x={0}
505
+ y={0}
506
+ width={innerWidth}
507
+ height={innerHeight}
508
+ fill="transparent"
509
+ onPointerMove={handlePointerMove}
510
+ onPointerLeave={handlePointerLeave}
511
+ />
512
+ {interactions}
513
+
514
+ {/* 5. Tooltip — declarative shortcut. For full control, render
515
+ <ChartTooltip> directly via the `children` slot. Pointer-only
516
+ and never focusable (the card is pointer-events: none), so it
517
+ can't trap keyboard focus; its hover marks are decorative. */}
518
+ {tooltip && (
519
+ <g aria-hidden="true" transform={plotTransform}>
520
+ <ChartProvider value={plotCtx}>
521
+ <ChartTooltip
522
+ series={series}
523
+ {...(tooltip === true ? {} : tooltip)}
524
+ />
525
+ </ChartProvider>
526
+ </g>
527
+ )}
528
+
529
+ {/* 6. Escape hatch */}
530
+ {children}
531
+ </g>
532
+ </svg>
533
+ );
534
+ }
535
+ }