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