@spatika/react 1.3.1 → 1.5.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.
Files changed (127) hide show
  1. package/AGENTS.md +6 -3
  2. package/README.md +1 -1
  3. package/dist/composites/EventCalendar.d.ts +13 -2
  4. package/dist/composites/EventCalendar.d.ts.map +1 -1
  5. package/dist/composites/EventCalendar.js +13 -27
  6. package/dist/composites/GlassCard.d.ts +1 -1
  7. package/dist/composites/RichTextEditor.d.ts +1 -4
  8. package/dist/composites/RichTextEditor.d.ts.map +1 -1
  9. package/dist/composites/RichTextEditor.js +12 -19
  10. package/dist/composites/SurfaceCard.d.ts +1 -1
  11. package/dist/composites/Tag.d.ts +2 -2
  12. package/dist/composites/cartesian-charts.d.ts +12 -2
  13. package/dist/composites/cartesian-charts.d.ts.map +1 -1
  14. package/dist/composites/cartesian-charts.js +27 -13
  15. package/dist/composites/chart-composition.d.ts +19 -1
  16. package/dist/composites/chart-composition.d.ts.map +1 -1
  17. package/dist/composites/chart-composition.js +124 -62
  18. package/dist/composites/chart-interaction.d.ts +81 -0
  19. package/dist/composites/chart-interaction.d.ts.map +1 -0
  20. package/dist/composites/chart-interaction.js +56 -0
  21. package/dist/composites/chart-ui.d.ts +8 -13
  22. package/dist/composites/chart-ui.d.ts.map +1 -1
  23. package/dist/composites/chart-ui.js +9 -6
  24. package/dist/composites/flow-charts.d.ts +6 -1
  25. package/dist/composites/flow-charts.d.ts.map +1 -1
  26. package/dist/composites/flow-charts.js +28 -14
  27. package/dist/composites/radial-charts.d.ts +11 -1
  28. package/dist/composites/radial-charts.d.ts.map +1 -1
  29. package/dist/composites/radial-charts.js +33 -14
  30. package/dist/composites/scheduler-ui.d.ts +14 -1
  31. package/dist/composites/scheduler-ui.d.ts.map +1 -1
  32. package/dist/composites/scheduler-ui.js +23 -2
  33. package/dist/index.d.ts +14 -26
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +8 -14
  36. package/dist/lib/breakpoints.d.ts +17 -0
  37. package/dist/lib/breakpoints.d.ts.map +1 -0
  38. package/dist/lib/breakpoints.js +23 -0
  39. package/dist/lib/chips.d.ts +3 -0
  40. package/dist/lib/chips.d.ts.map +1 -1
  41. package/dist/lib/chips.js +10 -0
  42. package/dist/lib/create-theme.d.ts +162 -0
  43. package/dist/lib/create-theme.d.ts.map +1 -0
  44. package/dist/lib/create-theme.js +138 -0
  45. package/dist/lib/scheduler.d.ts +12 -0
  46. package/dist/lib/scheduler.d.ts.map +1 -1
  47. package/dist/lib/scheduler.js +24 -2
  48. package/dist/lib/themes.d.ts +17 -7
  49. package/dist/lib/themes.d.ts.map +1 -1
  50. package/dist/lib/themes.js +22 -8
  51. package/dist/lib/use-media-query.d.ts +9 -0
  52. package/dist/lib/use-media-query.d.ts.map +1 -1
  53. package/dist/lib/use-media-query.js +28 -1
  54. package/dist/primitives/Alert.d.ts +1 -1
  55. package/dist/primitives/Badge.d.ts +2 -2
  56. package/dist/primitives/Button.d.ts +2 -2
  57. package/dist/primitives/Container.d.ts +2 -2
  58. package/dist/primitives/Fab.d.ts +4 -4
  59. package/dist/primitives/Grid.d.ts +2 -1
  60. package/dist/primitives/Grid.d.ts.map +1 -1
  61. package/dist/primitives/Grid.js +10 -2
  62. package/dist/primitives/IconButton.d.ts +2 -2
  63. package/dist/primitives/NativeSelect.d.ts +10 -1
  64. package/dist/primitives/NativeSelect.d.ts.map +1 -1
  65. package/dist/primitives/NativeSelect.js +2 -2
  66. package/dist/primitives/ToggleButton.d.ts +1 -1
  67. package/dist/theme/SpatikaThemeProvider.d.ts +4 -5
  68. package/dist/theme/SpatikaThemeProvider.d.ts.map +1 -1
  69. package/dist/theme/SpatikaThemeProvider.js +28 -13
  70. package/package.json +4 -2
  71. package/skills/spatika-ui/SKILL.md +28 -5
  72. package/src/composites/EventCalendar.test.tsx +293 -0
  73. package/src/composites/EventCalendar.tsx +38 -27
  74. package/src/composites/RichTextEditor.test.tsx +51 -0
  75. package/src/composites/RichTextEditor.tsx +20 -84
  76. package/src/composites/chart-ui.test.tsx +21 -0
  77. package/src/composites/scheduler-ui.tsx +128 -26
  78. package/src/composites/scheduler.test.tsx +0 -219
  79. package/src/index.test.ts +10 -0
  80. package/src/index.ts +148 -65
  81. package/src/lib/breakpoints.test.ts +29 -0
  82. package/src/lib/breakpoints.ts +28 -0
  83. package/src/lib/chips.ts +16 -0
  84. package/src/lib/create-theme.test.ts +79 -0
  85. package/src/lib/create-theme.ts +249 -0
  86. package/src/lib/scheduler.test.ts +26 -0
  87. package/src/lib/scheduler.ts +36 -2
  88. package/src/lib/themes.test.ts +17 -0
  89. package/src/lib/themes.ts +43 -12
  90. package/src/lib/use-media-query.test.tsx +80 -0
  91. package/src/lib/use-media-query.ts +35 -1
  92. package/src/primitives/Grid.test.tsx +55 -0
  93. package/src/primitives/Grid.tsx +13 -3
  94. package/src/primitives/NativeSelect.test.tsx +28 -0
  95. package/src/primitives/NativeSelect.tsx +14 -4
  96. package/src/primitives/mui-more.test.tsx +17 -0
  97. package/src/theme/SpatikaThemeProvider.test.tsx +38 -0
  98. package/src/theme/SpatikaThemeProvider.tsx +43 -15
  99. package/src/composites/ChartDataGrid.test.tsx +0 -57
  100. package/src/composites/ChartDataGrid.tsx +0 -196
  101. package/src/composites/MapChart.test.tsx +0 -39
  102. package/src/composites/MapChart.tsx +0 -130
  103. package/src/composites/ScatterWebGL.test.tsx +0 -25
  104. package/src/composites/ScatterWebGL.tsx +0 -79
  105. package/src/composites/cartesian-charts.test.tsx +0 -52
  106. package/src/composites/cartesian-charts.tsx +0 -1287
  107. package/src/composites/chart-composition.test.tsx +0 -53
  108. package/src/composites/chart-composition.tsx +0 -898
  109. package/src/composites/chart-ui.tsx +0 -328
  110. package/src/composites/charts-3d.test.tsx +0 -25
  111. package/src/composites/charts-3d.tsx +0 -318
  112. package/src/composites/charts.test.tsx +0 -254
  113. package/src/composites/flow-charts.test.tsx +0 -79
  114. package/src/composites/flow-charts.tsx +0 -607
  115. package/src/composites/radial-charts.test.tsx +0 -74
  116. package/src/composites/radial-charts.tsx +0 -617
  117. package/src/lib/chart-export.test.ts +0 -20
  118. package/src/lib/chart-export.ts +0 -99
  119. package/src/lib/charts-platform.test.ts +0 -93
  120. package/src/lib/charts.test.ts +0 -132
  121. package/src/lib/charts.ts +0 -1021
  122. package/src/lib/geo.test.ts +0 -42
  123. package/src/lib/geo.ts +0 -138
  124. package/src/lib/iso.test.ts +0 -22
  125. package/src/lib/iso.ts +0 -102
  126. package/src/lib/webgl-scatter.test.ts +0 -36
  127. package/src/lib/webgl-scatter.ts +0 -230
@@ -1,898 +0,0 @@
1
- import {
2
- createContext,
3
- useCallback,
4
- useContext,
5
- useEffect,
6
- useId,
7
- useMemo,
8
- useRef,
9
- useState,
10
- type PointerEvent as ReactPointerEvent,
11
- type ReactNode,
12
- type RefObject,
13
- } from "react";
14
- import { Download, Maximize2, Move, SquareDashedMousePointer, ZoomIn, ZoomOut } from "lucide-react";
15
- import { cn } from "../lib/cn";
16
- import { exportChart } from "../lib/chart-export";
17
- import {
18
- DEFAULT_CHART_MARGIN,
19
- DEFAULT_ZOOM,
20
- applyZoom,
21
- areaPath,
22
- axisDataFromDataset,
23
- bandScale,
24
- chartColor,
25
- clampZoom,
26
- extent,
27
- formatAxisValue,
28
- formatChartNumber,
29
- inferScaleType,
30
- linePath,
31
- linearScale,
32
- markOpacity,
33
- niceTicks,
34
- panZoom,
35
- plotBox,
36
- pointScale,
37
- rangeAreaPath,
38
- roundedRectPath,
39
- seriesDataFromDataset,
40
- stackSeries,
41
- wheelZoom,
42
- zoomFromBrush,
43
- type ChartAxisConfig,
44
- type ChartCurve,
45
- type ChartDataset,
46
- type ChartInteraction,
47
- type ChartMargin,
48
- type ChartZoom,
49
- type HighlightScope,
50
- type PlotBox,
51
- } from "../lib/charts";
52
- import { IconButton } from "../primitives/IconButton";
53
- import {
54
- CartesianGrid,
55
- CategoryAxis,
56
- ChartLegend,
57
- ChartTooltip,
58
- ValueAxis,
59
- categoryLabels,
60
- formatSeriesValue,
61
- resolveMargin,
62
- seriesMeta,
63
- type ChartHover,
64
- } from "./chart-ui";
65
-
66
- export type ComposedSeriesType = "bar" | "line" | "area" | "scatter";
67
-
68
- export type ComposedSeries = {
69
- id?: string;
70
- type?: ComposedSeriesType;
71
- label?: string;
72
- color?: string;
73
- data?: Array<number | null>;
74
- dataKey?: string;
75
- stack?: string;
76
- yAxisId?: string;
77
- curve?: ChartCurve;
78
- showMark?: boolean;
79
- area?: boolean;
80
- scatter?: Array<{ x: number; y: number; id?: string | number }>;
81
- };
82
-
83
- type ResolvedSeries = {
84
- id: string;
85
- type: ComposedSeriesType;
86
- label: string;
87
- color: string;
88
- data: Array<number | null>;
89
- stack?: string;
90
- yAxisId?: string;
91
- curve: ChartCurve;
92
- showMark: boolean;
93
- scatter?: Array<{ x: number; y: number; id?: string | number }>;
94
- };
95
-
96
- export type HighlightedItem = { seriesId: string; dataIndex: number } | null;
97
-
98
- type ChartContextValue = {
99
- slot: string;
100
- width: number;
101
- height: number;
102
- margin: Required<ChartMargin>;
103
- plot: PlotBox;
104
- categories: Array<string | number | Date>;
105
- series: ResolvedSeries[];
106
- hidden: Set<string>;
107
- toggleSeries: (id: string) => void;
108
- xAt: (index: number) => number;
109
- yAt: (value: number, yAxisId?: string) => number;
110
- bandwidth: number;
111
- ticksLeft: number[];
112
- ticksRight: number[];
113
- rightAxis: ChartAxisConfig | undefined;
114
- leftAxis: ChartAxisConfig | undefined;
115
- xAxis?: ChartAxisConfig;
116
- hideGrid?: boolean;
117
- stacked: boolean;
118
- barGapRatio: number;
119
- borderRadius: number;
120
- zoom: ChartZoom;
121
- setZoom: (zoom: ChartZoom) => void;
122
- resetZoom: () => void;
123
- zoomEnabled: boolean;
124
- interaction: ChartInteraction;
125
- setInteraction: (next: ChartInteraction) => void;
126
- highlightScope: HighlightScope;
127
- highlighted: HighlightedItem;
128
- setHighlighted: (item: HighlightedItem) => void;
129
- hover: ChartHover;
130
- setHover: (hover: ChartHover) => void;
131
- svgRef: RefObject<SVGSVGElement | null>;
132
- clipPathId: string;
133
- locale?: string;
134
- animated: boolean;
135
- brush: { x0: number; x1: number } | null;
136
- setBrush: (brush: { x0: number; x1: number } | null) => void;
137
- exportChart: (format: "svg" | "png") => Promise<void>;
138
- };
139
-
140
- const ChartContext = createContext<ChartContextValue | null>(null);
141
-
142
- export function useChartContext(): ChartContextValue {
143
- const value = useContext(ChartContext);
144
- if (!value) throw new Error("Chart composition components must be used inside ChartContainer");
145
- return value;
146
- }
147
-
148
- function useOptionalChartContext() {
149
- return useContext(ChartContext);
150
- }
151
-
152
- export type ChartContainerProps = {
153
- slot?: string;
154
- className?: string;
155
- width?: number;
156
- height?: number;
157
- margin?: ChartMargin;
158
- dataset?: ChartDataset;
159
- xAxis?: ChartAxisConfig[];
160
- yAxis?: ChartAxisConfig[];
161
- series: ComposedSeries[];
162
- colors?: string[];
163
- hideLegend?: boolean;
164
- hideGrid?: boolean;
165
- stacked?: boolean;
166
- barGapRatio?: number;
167
- borderRadius?: number;
168
- zoom?: boolean | ChartZoom;
169
- defaultZoom?: ChartZoom;
170
- onZoomChange?: (zoom: ChartZoom) => void;
171
- showToolbar?: boolean;
172
- highlightScope?: HighlightScope;
173
- selected?: HighlightedItem;
174
- animated?: boolean;
175
- locale?: string;
176
- children?: ReactNode;
177
- "aria-label"?: string;
178
- };
179
-
180
- function useElementSize(ref: RefObject<HTMLElement | null>, fallbackWidth: number, fallbackHeight: number) {
181
- const [size, setSize] = useState({ width: fallbackWidth, height: fallbackHeight });
182
- useEffect(() => {
183
- const el = ref.current;
184
- if (!el) return;
185
- const apply = () =>
186
- setSize({
187
- width: el.clientWidth || fallbackWidth,
188
- height: el.clientHeight || fallbackHeight,
189
- });
190
- apply();
191
- if (typeof ResizeObserver === "undefined") return;
192
- const observer = new ResizeObserver(apply);
193
- observer.observe(el);
194
- return () => observer.disconnect();
195
- }, [fallbackHeight, fallbackWidth, ref]);
196
- return size;
197
- }
198
-
199
- function resolveSeries(series: ComposedSeries[], dataset: ChartDataset | undefined, colors?: string[]): ResolvedSeries[] {
200
- return series.map((item, index) => {
201
- const data =
202
- item.data ??
203
- (dataset && item.dataKey ? seriesDataFromDataset(dataset, item.dataKey) : []);
204
- const type: ComposedSeriesType = item.type ?? (item.area ? "area" : "bar");
205
- return {
206
- id: item.id ?? item.label ?? item.dataKey ?? `series-${index}`,
207
- type,
208
- label: item.label ?? item.dataKey ?? item.id ?? `Series ${index + 1}`,
209
- color: item.color ?? chartColor(index, colors),
210
- data,
211
- stack: item.stack,
212
- yAxisId: item.yAxisId,
213
- curve: item.curve ?? "monotone",
214
- showMark: item.showMark ?? type !== "bar",
215
- scatter: item.scatter,
216
- };
217
- });
218
- }
219
-
220
- function numericDomain(series: ResolvedSeries[], stacked: boolean, axis?: ChartAxisConfig): [number, number] {
221
- if (axis?.min != null && axis?.max != null) return [axis.min, axis.max];
222
- const values: number[] = [];
223
- const subset = series.filter((item) => (item.yAxisId ?? "left") === (axis?.id ?? item.yAxisId ?? "left") || !axis?.id);
224
- const relevant = axis?.id ? series.filter((item) => (item.yAxisId ?? leftId(axis)) === axis.id) : series;
225
- const used = relevant.length ? relevant : subset;
226
- if (stacked) {
227
- for (const kind of ["bar", "area"] as const) {
228
- const group = used.filter((item) => item.type === kind);
229
- if (!group.length) continue;
230
- const stacks = stackSeries(group.map((item) => item.data.map((v) => v ?? 0)));
231
- for (const segs of stacks) for (const seg of segs) values.push(seg.start, seg.end);
232
- }
233
- for (const item of used) {
234
- if (item.type === "bar" || item.type === "area") continue;
235
- if (item.scatter) for (const p of item.scatter) values.push(p.y);
236
- else for (const value of item.data) if (value != null) values.push(value);
237
- }
238
- } else {
239
- for (const item of used) {
240
- if (item.scatter) for (const p of item.scatter) values.push(p.y);
241
- else for (const value of item.data) if (value != null) values.push(value);
242
- }
243
- }
244
- if (!values.length) values.push(0, 1);
245
- if (Math.min(...values) > 0) values.push(0);
246
- const [min, max] = extent(values, 0.04);
247
- return [axis?.min ?? min, axis?.max ?? max];
248
- }
249
-
250
- function leftId(axis?: ChartAxisConfig) {
251
- return axis?.id ?? "left";
252
- }
253
-
254
- export function ChartContainer({
255
- slot = "chart-container",
256
- className,
257
- width,
258
- height = 280,
259
- margin,
260
- dataset,
261
- xAxis,
262
- yAxis,
263
- series,
264
- colors,
265
- hideLegend,
266
- hideGrid,
267
- stacked = false,
268
- barGapRatio = 0.15,
269
- borderRadius = 6,
270
- zoom,
271
- defaultZoom,
272
- onZoomChange,
273
- showToolbar,
274
- highlightScope = "item",
275
- selected,
276
- animated = true,
277
- locale,
278
- children,
279
- "aria-label": ariaLabel,
280
- }: ChartContainerProps) {
281
- const surfaceRef = useRef<HTMLDivElement>(null);
282
- const svgRef = useRef<SVGSVGElement>(null);
283
- const clipPathId = `spk-chart-clip-${useId().replace(/:/g, "")}`;
284
- const measured = useElementSize(surfaceRef, width ?? 320, height);
285
- const plotWidth = width ?? measured.width;
286
- const hasRight = yAxis?.some((axis) => axis.position === "right");
287
- const m = resolveMargin(
288
- margin ?? (hasRight ? { ...DEFAULT_CHART_MARGIN, right: 48 } : undefined),
289
- );
290
- const resolved = useMemo(() => resolveSeries(series, dataset, colors), [series, dataset, colors]);
291
- const xConfig = xAxis?.[0];
292
- const categories = useMemo(() => {
293
- if (dataset && xConfig?.dataKey) return axisDataFromDataset(dataset, xConfig.dataKey);
294
- return categoryLabels(xConfig, resolved[0]?.data.length ?? resolved[0]?.scatter?.length ?? 0);
295
- }, [dataset, xConfig, resolved]);
296
- const leftAxis = yAxis?.find((axis) => (axis.position ?? "left") === "left") ?? yAxis?.[0];
297
- const rightAxis = yAxis?.find((axis) => axis.position === "right");
298
- const [hidden, setHidden] = useState<Set<string>>(new Set());
299
- const toggleSeries = (id: string) => {
300
- setHidden((current) => {
301
- const next = new Set(current);
302
- if (next.has(id)) next.delete(id);
303
- else next.add(id);
304
- return next;
305
- });
306
- };
307
- const zoomEnabled = zoom === true || typeof zoom === "object";
308
- const controlledZoom = typeof zoom === "object" ? zoom : undefined;
309
- const [innerZoom, setInnerZoom] = useState<ChartZoom>(defaultZoom ?? DEFAULT_ZOOM);
310
- const currentZoom = clampZoom(controlledZoom ?? innerZoom);
311
- const setZoom = (next: ChartZoom) => {
312
- const clamped = clampZoom(next);
313
- if (!controlledZoom) setInnerZoom(clamped);
314
- onZoomChange?.(clamped);
315
- };
316
- const resetZoom = () => setZoom(DEFAULT_ZOOM);
317
- const [interaction, setInteraction] = useState<ChartInteraction>("none");
318
- const [highlighted, setHighlighted] = useState<HighlightedItem>(null);
319
- const [hover, setHover] = useState<ChartHover>(null);
320
- const [brush, setBrush] = useState<{ x0: number; x1: number } | null>(null);
321
- const activeHighlight = selected ?? highlighted;
322
-
323
- const plot = plotBox(plotWidth, height, m);
324
- const visible = resolved.filter((item) => !hidden.has(item.id));
325
- const leftSeries = visible.filter((item) => !item.yAxisId || item.yAxisId === (leftAxis?.id ?? item.yAxisId));
326
- const rightSeries = visible.filter((item) => rightAxis && item.yAxisId === rightAxis.id);
327
- const leftDomain = numericDomain(leftSeries.length ? leftSeries : visible, stacked, leftAxis);
328
- const rightDomain = rightAxis ? numericDomain(rightSeries.length ? rightSeries : visible, false, rightAxis) : leftDomain;
329
- const ticksLeft = niceTicks(leftDomain[0], leftDomain[1], 5);
330
- const ticksRight = niceTicks(rightDomain[0], rightDomain[1], 5);
331
- const scaleType = xConfig?.scaleType ?? inferScaleType(xConfig?.data ?? categories);
332
- const yLeft = linearScale(leftDomain, [plot.bottom, plot.top]);
333
- const yRight = linearScale(rightDomain, [plot.bottom, plot.top]);
334
-
335
- const { xAt, bandwidth } = useMemo(() => {
336
- let xAtInner = (index: number) =>
337
- plot.left + plot.iw * (categories.length <= 1 ? 0.5 : index / (categories.length - 1));
338
- let bw = 0;
339
- if (scaleType === "band" || scaleType === undefined) {
340
- const scale = bandScale(categories.map(String), [plot.left, plot.right], 0.24);
341
- xAtInner = (index) => scale(String(categories[index]));
342
- bw = scale.bandwidth;
343
- } else if (scaleType === "point") {
344
- const scale = pointScale(categories.map(String), [plot.left, plot.right]);
345
- xAtInner = (index) => scale(String(categories[index]));
346
- } else if (xConfig?.data?.every((v) => typeof v === "number" || v instanceof Date)) {
347
- const xs = xConfig.data.map((v) => (v instanceof Date ? v.getTime() : Number(v)));
348
- const [min, max] = extent(xs);
349
- const scale = linearScale([min, max], [plot.left, plot.right]);
350
- xAtInner = (index) => scale(xs[index] ?? min);
351
- }
352
- const zoomX = (index: number) => applyZoom(xAtInner(index), plot.left, plot.iw, currentZoom.x);
353
- return { xAt: zoomX, bandwidth: bw / (currentZoom.x[1] - currentZoom.x[0] || 1) };
354
- }, [categories, currentZoom.x, plot.iw, plot.left, plot.right, scaleType, xConfig?.data]);
355
-
356
- const yAt = useCallback(
357
- (value: number, yAxisId?: string) => {
358
- const raw = yAxisId && rightAxis && yAxisId === rightAxis.id ? yRight(value) : yLeft(value);
359
- if (!currentZoom.y) return raw;
360
- return applyZoom(raw, plot.top, plot.ih, currentZoom.y);
361
- },
362
- [currentZoom.y, plot.ih, plot.top, rightAxis, yLeft, yRight],
363
- );
364
-
365
- const doExport = useCallback(
366
- async (format: "svg" | "png") => {
367
- await exportChart(svgRef.current, format);
368
- },
369
- [],
370
- );
371
-
372
- const ctx: ChartContextValue = {
373
- slot,
374
- width: plotWidth,
375
- height,
376
- margin: m,
377
- plot,
378
- categories,
379
- series: resolved,
380
- hidden,
381
- toggleSeries,
382
- xAt,
383
- yAt,
384
- bandwidth,
385
- ticksLeft,
386
- ticksRight,
387
- rightAxis,
388
- leftAxis,
389
- xAxis: xConfig,
390
- hideGrid,
391
- stacked,
392
- barGapRatio,
393
- borderRadius,
394
- zoom: currentZoom,
395
- setZoom,
396
- resetZoom,
397
- zoomEnabled,
398
- interaction,
399
- setInteraction,
400
- highlightScope,
401
- highlighted: activeHighlight,
402
- setHighlighted,
403
- hover,
404
- setHover,
405
- svgRef,
406
- clipPathId,
407
- locale,
408
- animated,
409
- brush,
410
- setBrush,
411
- exportChart: doExport,
412
- };
413
-
414
- const legend = seriesMeta(resolved, colors);
415
- const label =
416
- ariaLabel ??
417
- `Chart of ${resolved.map((item) => item.label).join(", ")}`;
418
-
419
- return (
420
- <ChartContext.Provider value={ctx}>
421
- <div
422
- data-slot={slot}
423
- className={cn("spk-chart", animated && "spk-chart--animated", className)}
424
- role="img"
425
- aria-label={label}
426
- >
427
- {showToolbar ? <ChartsToolbar /> : null}
428
- {hideLegend ? null : (
429
- <ChartLegend items={legend} hiddenIds={hidden} onToggle={toggleSeries} />
430
- )}
431
- <div ref={surfaceRef} className="spk-chart-surface" style={{ height }}>
432
- {plotWidth > 0 ? children ?? <DefaultChartSurface /> : null}
433
- <ChartTooltip hover={hover} boundsWidth={plotWidth} />
434
- </div>
435
- <ChartA11yTable />
436
- </div>
437
- </ChartContext.Provider>
438
- );
439
- }
440
-
441
- function DefaultChartSurface() {
442
- const { rightAxis } = useChartContext();
443
- return (
444
- <ChartSurface>
445
- <ChartsGrid />
446
- <BarPlot />
447
- <AreaPlot />
448
- <LinePlot />
449
- <ScatterPlot />
450
- <ChartsXAxis />
451
- <ChartsYAxis />
452
- {rightAxis ? <ChartsYAxis position="right" /> : null}
453
- <ChartsBrush />
454
- </ChartSurface>
455
- );
456
- }
457
-
458
- export function ChartSurface({ children, className }: { children?: ReactNode; className?: string }) {
459
- const ctx = useChartContext();
460
- const drag = useRef<{ x: number; y: number; zoom: ChartZoom } | null>(null);
461
- const { plot, zoomEnabled, interaction, setZoom, zoom, setBrush, setHover, setHighlighted, svgRef } = ctx;
462
-
463
- useEffect(() => {
464
- const svg = svgRef.current;
465
- if (!svg || !zoomEnabled) return;
466
- const onWheelNative = (event: WheelEvent) => {
467
- event.preventDefault();
468
- const bounds = svg.getBoundingClientRect();
469
- const x = event.clientX - bounds.left;
470
- const anchor = (x - plot.left) / plot.iw;
471
- const factor = event.deltaY > 0 ? 1.12 : 1 / 1.12;
472
- setZoom(wheelZoom(zoom, clamp01(anchor), factor));
473
- };
474
- svg.addEventListener("wheel", onWheelNative, { passive: false });
475
- return () => svg.removeEventListener("wheel", onWheelNative);
476
- }, [plot.iw, plot.left, setZoom, svgRef, zoom, zoomEnabled]);
477
-
478
- const onPointerDown = (event: ReactPointerEvent<SVGSVGElement>) => {
479
- if (!zoomEnabled) return;
480
- const svg = event.currentTarget;
481
- const bounds = svg.getBoundingClientRect();
482
- const x = event.clientX - bounds.left;
483
- const y = event.clientY - bounds.top;
484
- if (x < plot.left || x > plot.right || y < plot.top || y > plot.bottom) return;
485
- svg.setPointerCapture(event.pointerId);
486
- if (interaction === "brush") {
487
- setBrush({ x0: x, x1: x });
488
- return;
489
- }
490
- if (interaction === "pan" || event.shiftKey) {
491
- drag.current = { x, y, zoom };
492
- }
493
- };
494
-
495
- const onPointerMove = (event: ReactPointerEvent<SVGSVGElement>) => {
496
- const svg = event.currentTarget;
497
- const bounds = svg.getBoundingClientRect();
498
- const x = event.clientX - bounds.left;
499
- if (ctx.brush) {
500
- setBrush({ x0: ctx.brush.x0, x1: x });
501
- return;
502
- }
503
- if (!drag.current) return;
504
- const dx = (x - drag.current.x) / plot.iw;
505
- setZoom(panZoom(drag.current.zoom, dx));
506
- };
507
-
508
- const onPointerUp = () => {
509
- if (ctx.brush) {
510
- const next = zoomFromBrush(ctx.brush.x0, ctx.brush.x1, plot.left, plot.iw, zoom);
511
- setZoom(next);
512
- setBrush(null);
513
- }
514
- drag.current = null;
515
- };
516
-
517
- return (
518
- <svg
519
- ref={ctx.svgRef}
520
- className={className}
521
- width={ctx.width}
522
- height={ctx.height}
523
- viewBox={`0 0 ${ctx.width} ${ctx.height}`}
524
- data-slot="chart-surface"
525
- onPointerDown={onPointerDown}
526
- onPointerMove={onPointerMove}
527
- onPointerUp={onPointerUp}
528
- onPointerCancel={onPointerUp}
529
- onMouseLeave={() => {
530
- setHover(null);
531
- setHighlighted(null);
532
- }}
533
- >
534
- <defs>
535
- <clipPath id={ctx.clipPathId}>
536
- <rect x={plot.left} y={plot.top} width={plot.iw} height={plot.ih} />
537
- </clipPath>
538
- </defs>
539
- {children}
540
- </svg>
541
- );
542
- }
543
-
544
- function clamp01(value: number) {
545
- return Math.min(1, Math.max(0, value));
546
- }
547
-
548
- export function ChartsGrid() {
549
- const { hideGrid, ticksLeft, yAt, plot } = useChartContext();
550
- if (hideGrid) return null;
551
- return (
552
- <CartesianGrid
553
- ticks={ticksLeft}
554
- scale={(value) => yAt(value)}
555
- left={plot.left}
556
- right={plot.right}
557
- top={plot.top}
558
- bottom={plot.bottom}
559
- />
560
- );
561
- }
562
-
563
- export function ChartsXAxis() {
564
- const { xAxis, categories, xAt, plot } = useChartContext();
565
- if (xAxis?.hide) return null;
566
- return <CategoryAxis labels={categories} position={xAt} y={plot.bottom} />;
567
- }
568
-
569
- export function ChartsYAxis({ position = "left" }: { position?: "left" | "right" }) {
570
- const { ticksLeft, ticksRight, yAt, plot, leftAxis, rightAxis, locale } = useChartContext();
571
- if (position === "right") {
572
- if (!rightAxis || rightAxis.hide) return null;
573
- return (
574
- <g data-slot="chart-y-axis-right">
575
- {ticksRight.map((tick) => (
576
- <text
577
- key={tick}
578
- className="spk-chart-tick"
579
- x={plot.right + 8}
580
- y={yAt(tick, rightAxis.id)}
581
- dominantBaseline="middle"
582
- >
583
- {formatChartNumber(tick, locale)}
584
- </text>
585
- ))}
586
- </g>
587
- );
588
- }
589
- if (leftAxis?.hide) return null;
590
- return <ValueAxis ticks={ticksLeft} scale={(value) => yAt(value, leftAxis?.id)} left={plot.left} />;
591
- }
592
-
593
- function PlotClip({ children, slot }: { children: ReactNode; slot: string }) {
594
- const { clipPathId } = useChartContext();
595
- return (
596
- <g data-slot={slot} clipPath={`url(#${clipPathId})`}>
597
- {children}
598
- </g>
599
- );
600
- }
601
-
602
- export function BarPlot() {
603
- const ctx = useChartContext();
604
- const bars = ctx.series.filter((item) => item.type === "bar" && !ctx.hidden.has(item.id));
605
- if (!bars.length) return null;
606
- const stacks = ctx.stacked ? stackSeries(bars.map((item) => item.data.map((v) => v ?? 0))) : null;
607
- const n = ctx.stacked ? 1 : Math.max(bars.length, 1);
608
- const gap = n > 1 ? ctx.bandwidth * ctx.barGapRatio : 0;
609
- const barW = (ctx.bandwidth - gap * (n - 1)) / n;
610
- const zero = ctx.yAt(0);
611
- return (
612
- <PlotClip slot="bar-plot">
613
- {bars.map((item, s) =>
614
- item.data.map((value, i) => {
615
- if (value == null) return null;
616
- let x = ctx.xAt(i);
617
- let y1: number;
618
- let y2: number;
619
- if (stacks) {
620
- const seg = stacks[s]![i]!;
621
- y1 = ctx.yAt(seg.end, item.yAxisId);
622
- y2 = ctx.yAt(seg.start, item.yAxisId);
623
- } else {
624
- x = ctx.xAt(i) + s * (barW + gap);
625
- y1 = ctx.yAt(value, item.yAxisId);
626
- y2 = zero;
627
- }
628
- const y = Math.min(y1, y2);
629
- const h = Math.max(1, Math.abs(y2 - y1));
630
- const opacity = markOpacity(ctx.highlightScope, ctx.highlighted, item.id, i);
631
- return (
632
- <path
633
- key={`${item.id}-${i}`}
634
- className="spk-chart-mark"
635
- d={roundedRectPath(x, y, ctx.stacked ? ctx.bandwidth : barW, h, ctx.borderRadius)}
636
- fill={item.color}
637
- opacity={opacity}
638
- onMouseEnter={(event) => {
639
- ctx.setHighlighted({ seriesId: item.id, dataIndex: i });
640
- ctx.setHover({
641
- x: event.nativeEvent.offsetX,
642
- y: event.nativeEvent.offsetY,
643
- title: String(ctx.categories[i]),
644
- items: [{ color: item.color, label: item.label, value: formatSeriesValue(value) }],
645
- });
646
- }}
647
- onMouseLeave={() => {
648
- ctx.setHighlighted(null);
649
- ctx.setHover(null);
650
- }}
651
- />
652
- );
653
- }),
654
- )}
655
- </PlotClip>
656
- );
657
- }
658
-
659
- function LineLikePlot({ types, slot }: { types: ComposedSeriesType[]; slot: string }) {
660
- const ctx = useChartContext();
661
- const items = ctx.series.filter((item) => types.includes(item.type) && !ctx.hidden.has(item.id));
662
- if (!items.length) return null;
663
- const stackAreas = ctx.stacked && types.includes("area") && items.every((item) => item.type === "area");
664
- const stacks = stackAreas ? stackSeries(items.map((item) => item.data.map((v) => v ?? 0))) : null;
665
- return (
666
- <PlotClip slot={slot}>
667
- {items.map((item, s) => {
668
- const fill = item.type === "area";
669
- const stackedSegs = stacks?.[s];
670
- const pts = item.data
671
- .map((value, i) => {
672
- if (value == null) return null;
673
- const yValue = stackedSegs ? stackedSegs[i]!.end : value;
674
- return { x: ctx.xAt(i), y: ctx.yAt(yValue, item.yAxisId), value, i };
675
- })
676
- .filter((p): p is { x: number; y: number; value: number; i: number } => p != null);
677
- const baseline = stackedSegs
678
- ? pts.map((pt) => ({
679
- x: pt.x,
680
- y: ctx.yAt(stackedSegs[pt.i]!.start, item.yAxisId),
681
- }))
682
- : null;
683
- const opacity = markOpacity(ctx.highlightScope, ctx.highlighted, item.id);
684
- return (
685
- <g key={item.id} opacity={opacity}>
686
- {fill ? (
687
- <path
688
- d={
689
- baseline
690
- ? rangeAreaPath(pts, baseline, item.curve)
691
- : areaPath(pts, ctx.plot.bottom, item.curve)
692
- }
693
- fill={item.color}
694
- opacity={baseline ? 0.88 : 0.18}
695
- className="spk-chart-mark"
696
- />
697
- ) : null}
698
- <path
699
- d={linePath(pts, item.curve)}
700
- fill="none"
701
- stroke={item.color}
702
- strokeWidth={2.25}
703
- strokeLinejoin="round"
704
- strokeLinecap="round"
705
- className="spk-chart-mark"
706
- />
707
- {item.showMark
708
- ? pts.map((pt) => (
709
- <circle
710
- key={pt.i}
711
- cx={pt.x}
712
- cy={pt.y}
713
- r={3.4}
714
- fill={item.color}
715
- className="spk-chart-mark"
716
- onMouseEnter={() => {
717
- ctx.setHighlighted({ seriesId: item.id, dataIndex: pt.i });
718
- ctx.setHover({
719
- x: pt.x,
720
- y: pt.y,
721
- title: String(ctx.categories[pt.i]),
722
- items: [{ color: item.color, label: item.label, value: formatSeriesValue(pt.value) }],
723
- });
724
- }}
725
- onMouseLeave={() => {
726
- ctx.setHighlighted(null);
727
- ctx.setHover(null);
728
- }}
729
- />
730
- ))
731
- : null}
732
- </g>
733
- );
734
- })}
735
- </PlotClip>
736
- );
737
- }
738
-
739
- export function LinePlot() {
740
- return <LineLikePlot types={["line"]} slot="line-plot" />;
741
- }
742
-
743
- export function AreaPlot() {
744
- return <LineLikePlot types={["area"]} slot="area-plot" />;
745
- }
746
-
747
- export function ScatterPlot() {
748
- const ctx = useChartContext();
749
- const items = ctx.series.filter((item) => item.type === "scatter" && !ctx.hidden.has(item.id));
750
- if (!items.length) return null;
751
- return (
752
- <PlotClip slot="scatter-plot">
753
- {items.map((item) => {
754
- const points =
755
- item.scatter ??
756
- item.data
757
- .map((value, i) => (value == null ? null : { x: i, y: value, id: i }))
758
- .filter((p): p is { x: number; y: number; id: number } => p != null);
759
- const xs = points.map((p) => p.x);
760
- const xDomain = extent(xs, 0.04);
761
- const xScale = linearScale(xDomain, [ctx.plot.left, ctx.plot.right]);
762
- return points.map((point, i) => {
763
- const cx = applyZoom(xScale(point.x), ctx.plot.left, ctx.plot.iw, ctx.zoom.x);
764
- const cy = ctx.yAt(point.y, item.yAxisId);
765
- const opacity = markOpacity(ctx.highlightScope, ctx.highlighted, item.id, i);
766
- return (
767
- <circle
768
- key={`${item.id}-${point.id ?? i}`}
769
- cx={cx}
770
- cy={cy}
771
- r={4.5}
772
- fill={item.color}
773
- opacity={opacity}
774
- className="spk-chart-mark"
775
- onMouseEnter={(event) => {
776
- ctx.setHighlighted({ seriesId: item.id, dataIndex: i });
777
- ctx.setHover({
778
- x: event.nativeEvent.offsetX,
779
- y: event.nativeEvent.offsetY,
780
- title: item.label,
781
- items: [
782
- { color: item.color, label: "x", value: formatChartNumber(point.x, ctx.locale) },
783
- { color: item.color, label: "y", value: formatChartNumber(point.y, ctx.locale) },
784
- ],
785
- });
786
- }}
787
- onMouseLeave={() => {
788
- ctx.setHighlighted(null);
789
- ctx.setHover(null);
790
- }}
791
- />
792
- );
793
- });
794
- })}
795
- </PlotClip>
796
- );
797
- }
798
-
799
- export function ChartsBrush() {
800
- const { brush, plot } = useChartContext();
801
- if (!brush) return null;
802
- const x = Math.min(brush.x0, brush.x1);
803
- const width = Math.abs(brush.x1 - brush.x0);
804
- return (
805
- <rect
806
- data-slot="chart-brush"
807
- className="spk-chart-brush"
808
- x={x}
809
- y={plot.top}
810
- width={width}
811
- height={plot.ih}
812
- />
813
- );
814
- }
815
-
816
- export function ChartsLegend() {
817
- const { series, hidden, toggleSeries } = useChartContext();
818
- return (
819
- <ChartLegend
820
- items={series.map((item) => ({ id: item.id, label: item.label, color: item.color }))}
821
- hiddenIds={hidden}
822
- onToggle={toggleSeries}
823
- />
824
- );
825
- }
826
-
827
- export function ChartsToolbar({ className }: { className?: string }) {
828
- const ctx = useOptionalChartContext();
829
- if (!ctx) return null;
830
- const zoomIn = () => ctx.setZoom(wheelZoom(ctx.zoom, 0.5, 1 / 1.25));
831
- const zoomOut = () => ctx.setZoom(wheelZoom(ctx.zoom, 0.5, 1.25));
832
- return (
833
- <div className={cn("spk-chart-toolbar", className)} role="toolbar" aria-label="Chart tools">
834
- <IconButton size="sm" aria-label="Zoom in" onClick={zoomIn}>
835
- <ZoomIn />
836
- </IconButton>
837
- <IconButton size="sm" aria-label="Zoom out" onClick={zoomOut}>
838
- <ZoomOut />
839
- </IconButton>
840
- <IconButton size="sm" aria-label="Reset zoom" onClick={ctx.resetZoom}>
841
- <Maximize2 />
842
- </IconButton>
843
- <IconButton
844
- size="sm"
845
- aria-label="Pan"
846
- aria-pressed={ctx.interaction === "pan"}
847
- data-active={ctx.interaction === "pan" ? "true" : undefined}
848
- onClick={() => ctx.setInteraction(ctx.interaction === "pan" ? "none" : "pan")}
849
- >
850
- <Move />
851
- </IconButton>
852
- <IconButton
853
- size="sm"
854
- aria-label="Brush zoom"
855
- aria-pressed={ctx.interaction === "brush"}
856
- data-active={ctx.interaction === "brush" ? "true" : undefined}
857
- onClick={() => ctx.setInteraction(ctx.interaction === "brush" ? "none" : "brush")}
858
- >
859
- <SquareDashedMousePointer />
860
- </IconButton>
861
- <IconButton size="sm" aria-label="Export SVG" onClick={() => void ctx.exportChart("svg")}>
862
- <Download />
863
- </IconButton>
864
- <IconButton size="sm" aria-label="Export PNG" onClick={() => void ctx.exportChart("png")}>
865
- <span className="spk-chart-toolbar-png">PNG</span>
866
- </IconButton>
867
- </div>
868
- );
869
- }
870
-
871
- function ChartA11yTable() {
872
- const { series, categories, slot } = useChartContext();
873
- return (
874
- <table className="spk-chart-a11y">
875
- <caption>{slot} data</caption>
876
- <thead>
877
- <tr>
878
- <th>Category</th>
879
- {series.map((item) => (
880
- <th key={item.id}>{item.label}</th>
881
- ))}
882
- </tr>
883
- </thead>
884
- <tbody>
885
- {categories.map((category, i) => (
886
- <tr key={`${i}-${String(category)}`}>
887
- <th scope="row">{formatAxisValue(category)}</th>
888
- {series.map((item) => (
889
- <td key={item.id}>{item.data[i] == null ? "—" : formatChartNumber(item.data[i] as number)}</td>
890
- ))}
891
- </tr>
892
- ))}
893
- </tbody>
894
- </table>
895
- );
896
- }
897
-
898
- export { ChartContext };