@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,328 +0,0 @@
1
- import {
2
- useEffect,
3
- useRef,
4
- useState,
5
- type ReactNode,
6
- type RefObject,
7
- } from "react";
8
- import { cn } from "../lib/cn";
9
- import {
10
- DEFAULT_CHART_MARGIN,
11
- chartColor,
12
- formatAxisValue,
13
- formatChartNumber,
14
- type ChartAxisConfig,
15
- type ChartMargin,
16
- } from "../lib/charts";
17
-
18
- export type ChartTooltipItem = {
19
- color: string;
20
- label: string;
21
- value: string;
22
- };
23
-
24
- export type ChartLegendItem = {
25
- id: string;
26
- label: string;
27
- color: string;
28
- };
29
-
30
- export type ChartHover = {
31
- x: number;
32
- y: number;
33
- title?: string;
34
- items: ChartTooltipItem[];
35
- } | null;
36
-
37
- export type ChartFrameProps = {
38
- slot: string;
39
- className?: string;
40
- width?: number;
41
- height?: number;
42
- margin?: ChartMargin;
43
- legend?: ChartLegendItem[];
44
- hiddenIds?: Set<string>;
45
- onToggleSeries?: (id: string) => void;
46
- hover?: ChartHover;
47
- "aria-label"?: string;
48
- children: (plot: { width: number; height: number; m: Required<ChartMargin> }) => ReactNode;
49
- };
50
-
51
- export function resolveMargin(margin?: ChartMargin): Required<ChartMargin> {
52
- return {
53
- top: margin?.top ?? DEFAULT_CHART_MARGIN.top,
54
- right: margin?.right ?? DEFAULT_CHART_MARGIN.right,
55
- bottom: margin?.bottom ?? DEFAULT_CHART_MARGIN.bottom,
56
- left: margin?.left ?? DEFAULT_CHART_MARGIN.left,
57
- };
58
- }
59
-
60
- function useElementSize(
61
- ref: RefObject<HTMLElement | null>,
62
- fallbackWidth: number,
63
- fallbackHeight: number,
64
- ) {
65
- const [size, setSize] = useState({ width: fallbackWidth, height: fallbackHeight });
66
-
67
- useEffect(() => {
68
- const el = ref.current;
69
- if (!el) return;
70
- const apply = () => {
71
- setSize({
72
- width: el.clientWidth || fallbackWidth,
73
- height: el.clientHeight || fallbackHeight,
74
- });
75
- };
76
- apply();
77
- if (typeof ResizeObserver === "undefined") return;
78
- const observer = new ResizeObserver(apply);
79
- observer.observe(el);
80
- return () => observer.disconnect();
81
- }, [fallbackWidth, fallbackHeight, ref]);
82
-
83
- return size;
84
- }
85
-
86
- export function ChartFrame({
87
- slot,
88
- className,
89
- width,
90
- height = 280,
91
- margin,
92
- legend,
93
- hiddenIds,
94
- onToggleSeries,
95
- hover,
96
- "aria-label": ariaLabel,
97
- children,
98
- }: ChartFrameProps) {
99
- const surfaceRef = useRef<HTMLDivElement>(null);
100
- const measured = useElementSize(surfaceRef, width ?? 320, height);
101
- const plotWidth = width ?? measured.width;
102
- const plotHeight = height;
103
- const m = resolveMargin(margin);
104
-
105
- return (
106
- <div
107
- data-slot={slot}
108
- className={cn("spk-chart", className)}
109
- role="img"
110
- aria-label={ariaLabel}
111
- >
112
- {legend?.length ? (
113
- <ChartLegend items={legend} hiddenIds={hiddenIds} onToggle={onToggleSeries} />
114
- ) : null}
115
- <div
116
- ref={surfaceRef}
117
- className="spk-chart-surface"
118
- style={{ height: plotHeight }}
119
- >
120
- {plotWidth > 0 ? children({ width: plotWidth, height: plotHeight, m }) : null}
121
- <ChartTooltip hover={hover ?? null} boundsWidth={plotWidth} />
122
- </div>
123
- </div>
124
- );
125
- }
126
-
127
- export function ChartLegend({
128
- items,
129
- hiddenIds,
130
- onToggle,
131
- }: {
132
- items: ChartLegendItem[];
133
- hiddenIds?: Set<string>;
134
- onToggle?: (id: string) => void;
135
- }) {
136
- return (
137
- <div className="spk-chart-legend">
138
- {items.map((item) => (
139
- <button
140
- key={item.id}
141
- type="button"
142
- className="spk-chart-legend-item"
143
- data-hidden={hiddenIds?.has(item.id) ? "true" : undefined}
144
- onClick={() => onToggle?.(item.id)}
145
- >
146
- <span className="spk-chart-swatch" style={{ background: item.color }} />
147
- {item.label}
148
- </button>
149
- ))}
150
- </div>
151
- );
152
- }
153
-
154
- export function ChartTooltip({
155
- hover,
156
- boundsWidth,
157
- }: {
158
- hover: ChartHover;
159
- boundsWidth: number;
160
- }) {
161
- if (!hover || !hover.items.length) return null;
162
- const left = clampTooltip(hover.x, boundsWidth);
163
- return (
164
- <div className="spk-chart-tooltip" style={{ left, top: hover.y }} role="tooltip">
165
- {hover.title ? <p className="spk-chart-tooltip-title">{hover.title}</p> : null}
166
- <ul>
167
- {hover.items.map((item) => (
168
- <li key={item.label}>
169
- <span className="spk-chart-swatch" style={{ background: item.color }} />
170
- <span>{item.label}</span>
171
- <strong>{item.value}</strong>
172
- </li>
173
- ))}
174
- </ul>
175
- </div>
176
- );
177
- }
178
-
179
- function clampTooltip(x: number, width: number) {
180
- return clampNum(x, 72, Math.max(72, width - 72));
181
- }
182
-
183
- function clampNum(value: number, min: number, max: number) {
184
- return Math.min(max, Math.max(min, value));
185
- }
186
-
187
- export function CartesianGrid({
188
- ticks,
189
- scale,
190
- left,
191
- right,
192
- top,
193
- bottom,
194
- }: {
195
- ticks: number[];
196
- scale: (value: number) => number;
197
- left: number;
198
- right: number;
199
- top: number;
200
- bottom: number;
201
- }) {
202
- return (
203
- <g data-slot="chart-grid">
204
- {ticks.map((tick) => (
205
- <line
206
- key={tick}
207
- className="spk-chart-grid"
208
- x1={left}
209
- x2={right}
210
- y1={scale(tick)}
211
- y2={scale(tick)}
212
- />
213
- ))}
214
- <line className="spk-chart-axis" x1={left} x2={right} y1={bottom} y2={bottom} />
215
- <line className="spk-chart-axis" x1={left} x2={left} y1={top} y2={bottom} />
216
- </g>
217
- );
218
- }
219
-
220
- export function ValueAxis({
221
- ticks,
222
- scale,
223
- left,
224
- width,
225
- }: {
226
- ticks: number[];
227
- scale: (value: number) => number;
228
- left: number;
229
- width?: number;
230
- }) {
231
- return (
232
- <g data-slot="chart-y-axis">
233
- {ticks.map((tick) => (
234
- <text
235
- key={tick}
236
- className="spk-chart-tick"
237
- x={left - 8}
238
- y={scale(tick)}
239
- textAnchor="end"
240
- dominantBaseline="middle"
241
- >
242
- {formatChartNumber(tick)}
243
- </text>
244
- ))}
245
- {width ? (
246
- <text
247
- className="spk-chart-axis-label"
248
- x={12}
249
- y={width / 2}
250
- transform={`rotate(-90 12 ${width / 2})`}
251
- textAnchor="middle"
252
- />
253
- ) : null}
254
- </g>
255
- );
256
- }
257
-
258
- export function CategoryAxis({
259
- labels,
260
- position,
261
- y,
262
- }: {
263
- labels: Array<string | number | Date>;
264
- position: (index: number) => number;
265
- y: number;
266
- }) {
267
- const maxLabels = labels.length > 12 ? Math.ceil(labels.length / 8) : 1;
268
- return (
269
- <g data-slot="chart-x-axis">
270
- {labels.map((label, index) => {
271
- if (index % maxLabels !== 0) return null;
272
- return (
273
- <text
274
- key={`${index}-${String(label)}`}
275
- className="spk-chart-tick"
276
- x={position(index)}
277
- y={y + 16}
278
- textAnchor="middle"
279
- >
280
- {formatAxisValue(label)}
281
- </text>
282
- );
283
- })}
284
- </g>
285
- );
286
- }
287
-
288
- export function useHiddenSeries(ids: string[]) {
289
- const [hidden, setHidden] = useState<Set<string>>(new Set());
290
- const toggle = (id: string) => {
291
- setHidden((current) => {
292
- const next = new Set(current);
293
- if (next.has(id)) next.delete(id);
294
- else next.add(id);
295
- return next;
296
- });
297
- };
298
- const visible = ids.filter((id) => !hidden.has(id));
299
- return { hidden, toggle, visible };
300
- }
301
-
302
- export function seriesMeta(
303
- series: Array<{ id?: string; label?: string; color?: string }>,
304
- colors?: string[],
305
- ): ChartLegendItem[] {
306
- return series.map((item, index) => ({
307
- id: item.id ?? item.label ?? `series-${index}`,
308
- label: item.label ?? item.id ?? `Series ${index + 1}`,
309
- color: item.color ?? chartColor(index, colors),
310
- }));
311
- }
312
-
313
- export function useChartHover() {
314
- const [hover, setHover] = useState<ChartHover>(null);
315
- return { hover, setHover, clear: () => setHover(null) };
316
- }
317
-
318
- export function categoryLabels(axis?: ChartAxisConfig, length = 0): Array<string | number | Date> {
319
- if (axis?.data?.length) return axis.data;
320
- return Array.from({ length }, (_, i) => i + 1);
321
- }
322
-
323
- export function formatSeriesValue(value: number | [number, number] | undefined) {
324
- if (value == null) return "—";
325
- if (Array.isArray(value)) return `${formatChartNumber(value[0])} – ${formatChartNumber(value[1])}`;
326
- return formatChartNumber(value);
327
- }
328
-
@@ -1,25 +0,0 @@
1
- import { render } from "@testing-library/react";
2
- import { describe, expect, it } from "vitest";
3
- import { BarChart3D, PieChart3D } from "./charts-3d";
4
-
5
- describe("charts-3d", () => {
6
- it("renders isometric bars and an extruded pie", () => {
7
- const { container } = render(
8
- <div>
9
- <BarChart3D
10
- width={240}
11
- height={160}
12
- xAxis={[{ data: ["A", "B"] }]}
13
- series={[{ label: "Views", data: [3, 5] }]}
14
- />
15
- <PieChart3D
16
- width={200}
17
- height={160}
18
- series={[{ data: [{ value: 2, label: "A" }, { value: 3, label: "B" }] }]}
19
- />
20
- </div>,
21
- );
22
- expect(container.querySelector('[data-slot="bar-chart-3d"]')).toBeTruthy();
23
- expect(container.querySelector('[data-slot="pie-chart-3d"]')).toBeTruthy();
24
- });
25
- });
@@ -1,318 +0,0 @@
1
- import {
2
- extent,
3
- formatChartNumber,
4
- niceTicks,
5
- pieSlices,
6
- type ChartAxisConfig,
7
- type ChartMargin,
8
- } from "../lib/charts";
9
- import { ellipseSlicePath, ellipseWallPath, isoBoxFaces, isoPoint } from "../lib/iso";
10
- import {
11
- CartesianGrid,
12
- ChartFrame,
13
- categoryLabels,
14
- formatSeriesValue,
15
- seriesMeta,
16
- useChartHover,
17
- useHiddenSeries,
18
- } from "./chart-ui";
19
- import type { NumericSeries } from "./cartesian-charts";
20
-
21
- function box(width: number, height: number, m: Required<ChartMargin>) {
22
- return {
23
- left: m.left,
24
- right: width - m.right,
25
- top: m.top,
26
- bottom: height - m.bottom,
27
- iw: Math.max(1, width - m.left - m.right),
28
- ih: Math.max(1, height - m.top - m.bottom),
29
- };
30
- }
31
-
32
- export type BarChart3DProps = {
33
- series: NumericSeries[];
34
- xAxis?: ChartAxisConfig[];
35
- yAxis?: ChartAxisConfig[];
36
- height?: number;
37
- width?: number;
38
- colors?: string[];
39
- hideLegend?: boolean;
40
- hideGrid?: boolean;
41
- margin?: ChartMargin;
42
- className?: string;
43
- depth?: number;
44
- };
45
-
46
- export function BarChart3D({
47
- series,
48
- xAxis,
49
- yAxis,
50
- height = 280,
51
- width,
52
- colors,
53
- hideLegend,
54
- hideGrid,
55
- margin,
56
- className,
57
- depth = 14,
58
- }: BarChart3DProps) {
59
- const meta = seriesMeta(series, colors);
60
- const ids = meta.map((item) => item.id);
61
- const { hidden, toggle } = useHiddenSeries(ids);
62
- const { hover, setHover, clear } = useChartHover();
63
- const categories = categoryLabels(xAxis?.[0], series[0]?.data?.length ?? 0);
64
- const values = series.flatMap((item) => (item.data ?? []).map((v) => v ?? 0));
65
- const domain: [number, number] = [
66
- yAxis?.[0]?.min ?? Math.min(0, extent(values, 0.08)[0]),
67
- yAxis?.[0]?.max ?? extent(values, 0.08)[1],
68
- ];
69
- const ticks = niceTicks(domain[0], domain[1], 4);
70
-
71
- return (
72
- <ChartFrame
73
- slot="bar-chart-3d"
74
- className={className}
75
- width={width}
76
- height={height}
77
- margin={margin ?? { top: 24, right: 28, bottom: 40, left: 48 }}
78
- legend={hideLegend ? undefined : meta}
79
- hiddenIds={hidden}
80
- onToggleSeries={toggle}
81
- hover={hover}
82
- aria-label="3D bar chart"
83
- >
84
- {({ width: w, height: h, m }) => {
85
- const plot = box(w, h, m);
86
- const originX = plot.left + plot.iw * 0.16;
87
- const originY = plot.bottom - 6;
88
- const visible = series
89
- .map((item, s) => ({ item, info: meta[s]!, s }))
90
- .filter(({ info }) => !hidden.has(info.id));
91
- const n = Math.max(categories.length, 1);
92
- const groupW = Math.max(18, (plot.iw * 0.72) / n);
93
- const barW = Math.max(8, (groupW * 0.78) / Math.max(visible.length, 1));
94
- const gap = Math.max(2, (groupW - barW * visible.length) / Math.max(visible.length + 1, 1));
95
- const maxH = plot.ih * 0.78;
96
- const yScale = (value: number) =>
97
- ((value - domain[0]) / (domain[1] - domain[0] || 1)) * maxH;
98
- const yAt = (value: number) => originY - yScale(value);
99
-
100
- return (
101
- <svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
102
- {hideGrid ? null : (
103
- <CartesianGrid
104
- ticks={ticks}
105
- scale={yAt}
106
- left={plot.left}
107
- right={plot.right}
108
- top={plot.top}
109
- bottom={plot.bottom}
110
- />
111
- )}
112
- {ticks.map((tick) => (
113
- <text key={tick} className="spk-chart-tick" x={plot.left - 8} y={yAt(tick)} textAnchor="end">
114
- {formatChartNumber(tick)}
115
- </text>
116
- ))}
117
- {categories.map((label, i) => {
118
- const x0 = i * groupW;
119
- const foot = isoPoint(x0 + groupW / 2, 0, depth, originX, originY);
120
- return (
121
- <text
122
- key={String(label)}
123
- className="spk-chart-tick"
124
- x={foot.x}
125
- y={plot.bottom + 16}
126
- textAnchor="middle"
127
- >
128
- {String(label)}
129
- </text>
130
- );
131
- })}
132
- {categories.flatMap((_, i) =>
133
- visible.map(({ item, info }, s) => {
134
- const value = item.data?.[i] ?? 0;
135
- const barH = Math.max(2, yScale(value));
136
- const x = i * groupW + gap + s * (barW + gap * 0.2);
137
- const faces = isoBoxFaces(x, 0, 0, barW, barH, depth, originX, originY);
138
- const color = info.color;
139
- return (
140
- <g
141
- key={`${info.id}-${i}`}
142
- className="spk-chart-mark"
143
- onMouseEnter={(event) =>
144
- setHover({
145
- x: event.nativeEvent.offsetX,
146
- y: event.nativeEvent.offsetY,
147
- title: String(categories[i]),
148
- items: [
149
- {
150
- color,
151
- label: info.label,
152
- value: formatSeriesValue(value),
153
- },
154
- ],
155
- })
156
- }
157
- onMouseLeave={clear}
158
- >
159
- <path d={faces.side} fill={`color-mix(in srgb, ${color} 78%, black)`} />
160
- <path d={faces.top} fill={`color-mix(in srgb, ${color} 72%, white)`} />
161
- <path d={faces.front} fill={color} />
162
- </g>
163
- );
164
- }),
165
- )}
166
- </svg>
167
- );
168
- }}
169
- </ChartFrame>
170
- );
171
- }
172
-
173
- export type PieChart3DProps = {
174
- series: Array<{
175
- data: Array<{ id?: string | number; value: number; label?: string; color?: string }>;
176
- innerRadius?: number;
177
- paddingAngle?: number;
178
- }>;
179
- height?: number;
180
- width?: number;
181
- colors?: string[];
182
- hideLegend?: boolean;
183
- margin?: ChartMargin;
184
- className?: string;
185
- thickness?: number;
186
- };
187
-
188
- export function PieChart3D({
189
- series,
190
- height = 280,
191
- width,
192
- colors,
193
- hideLegend,
194
- margin,
195
- className,
196
- thickness = 18,
197
- }: PieChart3DProps) {
198
- const first = series[0] ?? { data: [] };
199
- const meta = seriesMeta(
200
- first.data.map((item, i) => ({
201
- id: String(item.id ?? item.label ?? i),
202
- label: item.label ?? `Slice ${i + 1}`,
203
- color: item.color,
204
- })),
205
- colors,
206
- );
207
- const ids = meta.map((item) => item.id);
208
- const { hidden, toggle } = useHiddenSeries(ids);
209
- const { hover, setHover, clear } = useChartHover();
210
-
211
- return (
212
- <ChartFrame
213
- slot="pie-chart-3d"
214
- className={className}
215
- width={width}
216
- height={height}
217
- margin={margin ?? { top: 12, right: 12, bottom: 20, left: 12 }}
218
- legend={hideLegend ? undefined : meta}
219
- hiddenIds={hidden}
220
- onToggleSeries={toggle}
221
- hover={hover}
222
- aria-label="3D pie chart"
223
- >
224
- {({ width: w, height: h, m }) => {
225
- const cx = w / 2;
226
- const cy = (h - thickness) / 2;
227
- const rx = Math.min(w - m.left - m.right, (h - m.top - m.bottom - thickness) * 1.7) / 2;
228
- const ry = rx * 0.42;
229
- const visible = first.data.map((item, i) => (hidden.has(meta[i]!.id) ? 0 : item.value));
230
- const slices = pieSlices(visible, {
231
- cx,
232
- cy,
233
- innerRadius: 0,
234
- outerRadius: rx,
235
- paddingAngle: first.paddingAngle,
236
- });
237
- const ordered = slices
238
- .map((slice, i) => ({ slice, info: meta[i]!, item: first.data[i]! }))
239
- .filter(({ info, slice }) => !hidden.has(info.id) && slice.path)
240
- .sort((a, b) => ellipsePointY(cx, cy, rx, ry, a.slice.midAngle) - ellipsePointY(cx, cy, rx, ry, b.slice.midAngle));
241
-
242
- return (
243
- <svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
244
- {ordered.map(({ slice, info, item }) => (
245
- <g
246
- key={`${info.id}-wall`}
247
- className="spk-chart-mark"
248
- onMouseEnter={(event) =>
249
- setHover({
250
- x: event.nativeEvent.offsetX,
251
- y: event.nativeEvent.offsetY,
252
- title: info.label,
253
- items: [
254
- {
255
- color: info.color,
256
- label: info.label,
257
- value: `${formatChartNumber(item.value)} (${Math.round(slice.percent * 100)}%)`,
258
- },
259
- ],
260
- })
261
- }
262
- onMouseLeave={clear}
263
- >
264
- <path
265
- d={ellipseWallPath(cx, cy, rx, ry, slice.startAngle, slice.endAngle, thickness)}
266
- fill={`color-mix(in srgb, ${info.color} 70%, black)`}
267
- />
268
- </g>
269
- ))}
270
- {ordered.map(({ slice, info, item }) => (
271
- <g
272
- key={info.id}
273
- className="spk-chart-mark"
274
- onMouseEnter={(event) =>
275
- setHover({
276
- x: event.nativeEvent.offsetX,
277
- y: event.nativeEvent.offsetY,
278
- title: info.label,
279
- items: [
280
- {
281
- color: info.color,
282
- label: info.label,
283
- value: `${formatChartNumber(item.value)} (${Math.round(slice.percent * 100)}%)`,
284
- },
285
- ],
286
- })
287
- }
288
- onMouseLeave={clear}
289
- >
290
- <path
291
- d={ellipseSlicePath(cx, cy, rx, ry, slice.startAngle, slice.endAngle)}
292
- fill={info.color}
293
- />
294
- {slice.percent > 0.08 ? (
295
- <text
296
- className="spk-chart-label"
297
- x={cx + Math.cos(((slice.midAngle - 90) * Math.PI) / 180) * rx * 0.55}
298
- y={cy + Math.sin(((slice.midAngle - 90) * Math.PI) / 180) * ry * 0.55}
299
- textAnchor="middle"
300
- dominantBaseline="middle"
301
- fill="var(--primary-foreground)"
302
- >
303
- {Math.round(slice.percent * 100)}%
304
- </text>
305
- ) : null}
306
- </g>
307
- ))}
308
- </svg>
309
- );
310
- }}
311
- </ChartFrame>
312
- );
313
- }
314
-
315
- function ellipsePointY(cx: number, cy: number, rx: number, ry: number, angleDeg: number) {
316
- const rad = ((angleDeg - 90) * Math.PI) / 180;
317
- return cy + ry * Math.sin(rad);
318
- }