@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.
- package/CHANGELOG.md +73 -0
- package/LICENSE +24 -0
- package/README.md +118 -0
- package/dist/Chart.d.ts +62 -0
- package/dist/Chart.d.ts.map +1 -0
- package/dist/Chart.js +401 -0
- package/dist/ChartAxis.d.ts +37 -0
- package/dist/ChartAxis.d.ts.map +1 -0
- package/dist/ChartAxis.js +233 -0
- package/dist/ChartContext.d.ts +4 -0
- package/dist/ChartContext.d.ts.map +1 -0
- package/dist/ChartContext.js +15 -0
- package/dist/ChartGrid.d.ts +26 -0
- package/dist/ChartGrid.d.ts.map +1 -0
- package/dist/ChartGrid.js +118 -0
- package/dist/ChartLegend.d.ts +14 -0
- package/dist/ChartLegend.d.ts.map +1 -0
- package/dist/ChartLegend.js +62 -0
- package/dist/ChartSwatch.d.ts +36 -0
- package/dist/ChartSwatch.d.ts.map +1 -0
- package/dist/ChartSwatch.js +85 -0
- package/dist/ChartTooltip.d.ts +46 -0
- package/dist/ChartTooltip.d.ts.map +1 -0
- package/dist/ChartTooltip.js +330 -0
- package/dist/charts.css +33 -0
- package/dist/dataTable.d.ts +56 -0
- package/dist/dataTable.d.ts.map +1 -0
- package/dist/dataTable.js +88 -0
- package/dist/formatters.d.ts +37 -0
- package/dist/formatters.d.ts.map +1 -0
- package/dist/formatters.js +156 -0
- package/dist/getChartColors.d.ts +67 -0
- package/dist/getChartColors.d.ts.map +1 -0
- package/dist/getChartColors.js +191 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/layout.d.ts +40 -0
- package/dist/layout.d.ts.map +1 -0
- package/dist/layout.js +322 -0
- package/dist/legend.d.ts +17 -0
- package/dist/legend.d.ts.map +1 -0
- package/dist/legend.js +40 -0
- package/dist/markColor.d.ts +20 -0
- package/dist/markColor.d.ts.map +1 -0
- package/dist/markColor.js +56 -0
- package/dist/marks/area.d.ts +19 -0
- package/dist/marks/area.d.ts.map +1 -0
- package/dist/marks/area.js +121 -0
- package/dist/marks/band.d.ts +9 -0
- package/dist/marks/band.d.ts.map +1 -0
- package/dist/marks/band.js +64 -0
- package/dist/marks/bar.d.ts +21 -0
- package/dist/marks/bar.d.ts.map +1 -0
- package/dist/marks/bar.js +161 -0
- package/dist/marks/candlestick.d.ts +16 -0
- package/dist/marks/candlestick.d.ts.map +1 -0
- package/dist/marks/candlestick.js +110 -0
- package/dist/marks/dot.d.ts +19 -0
- package/dist/marks/dot.d.ts.map +1 -0
- package/dist/marks/dot.js +98 -0
- package/dist/marks/dotGL.d.ts +16 -0
- package/dist/marks/dotGL.d.ts.map +1 -0
- package/dist/marks/dotGL.js +231 -0
- package/dist/marks/dotGLInteractive.d.ts +35 -0
- package/dist/marks/dotGLInteractive.d.ts.map +1 -0
- package/dist/marks/dotGLInteractive.js +584 -0
- package/dist/marks/errorBar.d.ts +29 -0
- package/dist/marks/errorBar.d.ts.map +1 -0
- package/dist/marks/errorBar.js +95 -0
- package/dist/marks/heatmapGL.d.ts +27 -0
- package/dist/marks/heatmapGL.d.ts.map +1 -0
- package/dist/marks/heatmapGL.js +335 -0
- package/dist/marks/index.d.ts +13 -0
- package/dist/marks/index.d.ts.map +1 -0
- package/dist/marks/index.js +14 -0
- package/dist/marks/line.d.ts +18 -0
- package/dist/marks/line.d.ts.map +1 -0
- package/dist/marks/line.js +83 -0
- package/dist/marks/referenceLine.d.ts +37 -0
- package/dist/marks/referenceLine.d.ts.map +1 -0
- package/dist/marks/referenceLine.js +204 -0
- package/dist/marks/streamGL.d.ts +41 -0
- package/dist/marks/streamGL.d.ts.map +1 -0
- package/dist/marks/streamGL.js +317 -0
- package/dist/tooltip.d.ts +38 -0
- package/dist/tooltip.d.ts.map +1 -0
- package/dist/tooltip.js +57 -0
- package/dist/types.d.ts +193 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +70 -0
- package/dist/useChartColors.d.ts +16 -0
- package/dist/useChartColors.d.ts.map +1 -0
- package/dist/useChartColors.js +37 -0
- package/dist/utils.d.ts +15 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +31 -0
- package/dist/webgl.d.ts +77 -0
- package/dist/webgl.d.ts.map +1 -0
- package/dist/webgl.js +235 -0
- package/package.json +72 -4
- package/src/Chart.doc.mjs +174 -0
- package/src/Chart.test.tsx +273 -0
- package/src/Chart.tsx +535 -0
- package/src/ChartAxis.tsx +289 -0
- package/src/ChartContext.ts +18 -0
- package/src/ChartGrid.tsx +136 -0
- package/src/ChartLegend.tsx +77 -0
- package/src/ChartSwatch.doc.mjs +17 -0
- package/src/ChartSwatch.tsx +89 -0
- package/src/ChartTooltip.tsx +452 -0
- package/src/charts.css.d.ts +3 -0
- package/src/dataTable.test.ts +86 -0
- package/src/dataTable.ts +124 -0
- package/src/formatters.ts +167 -0
- package/src/getChartColors.test.ts +114 -0
- package/src/getChartColors.ts +282 -0
- package/src/index.ts +88 -0
- package/src/layout.ts +372 -0
- package/src/legend.ts +44 -0
- package/src/markColor.ts +70 -0
- package/src/marks/area.tsx +142 -0
- package/src/marks/band.tsx +86 -0
- package/src/marks/bar.tsx +210 -0
- package/src/marks/candlestick.tsx +128 -0
- package/src/marks/dot.tsx +119 -0
- package/src/marks/dotGL.tsx +274 -0
- package/src/marks/dotGLInteractive.tsx +660 -0
- package/src/marks/errorBar.tsx +119 -0
- package/src/marks/heatmapGL.tsx +392 -0
- package/src/marks/index.ts +17 -0
- package/src/marks/line.tsx +118 -0
- package/src/marks/referenceLine.tsx +252 -0
- package/src/marks/streamGL.tsx +381 -0
- package/src/tooltip.ts +73 -0
- package/src/types.ts +212 -0
- package/src/useChartColors.ts +41 -0
- package/src/utils.ts +38 -0
- 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
|
+
}
|