@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.
- package/AGENTS.md +6 -3
- package/README.md +1 -1
- package/dist/composites/EventCalendar.js +3 -3
- package/dist/composites/GlassCard.d.ts +1 -1
- package/dist/composites/RichTextEditor.d.ts +1 -4
- package/dist/composites/RichTextEditor.d.ts.map +1 -1
- package/dist/composites/RichTextEditor.js +12 -19
- package/dist/composites/SurfaceCard.d.ts +1 -1
- package/dist/composites/Tag.d.ts +2 -2
- package/dist/index.d.ts +8 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -11
- package/dist/lib/breakpoints.d.ts +17 -0
- package/dist/lib/breakpoints.d.ts.map +1 -0
- package/dist/lib/breakpoints.js +23 -0
- package/dist/lib/create-theme.d.ts +162 -0
- package/dist/lib/create-theme.d.ts.map +1 -0
- package/dist/lib/create-theme.js +138 -0
- package/dist/lib/scheduler.js +2 -2
- package/dist/lib/themes.d.ts +17 -7
- package/dist/lib/themes.d.ts.map +1 -1
- package/dist/lib/themes.js +22 -8
- package/dist/lib/use-media-query.d.ts +9 -0
- package/dist/lib/use-media-query.d.ts.map +1 -1
- package/dist/lib/use-media-query.js +28 -1
- package/dist/primitives/Alert.d.ts +1 -1
- package/dist/primitives/Badge.d.ts +2 -2
- package/dist/primitives/Button.d.ts +2 -2
- package/dist/primitives/Container.d.ts +2 -2
- package/dist/primitives/Fab.d.ts +4 -4
- package/dist/primitives/Grid.d.ts +2 -1
- package/dist/primitives/Grid.d.ts.map +1 -1
- package/dist/primitives/Grid.js +10 -2
- package/dist/primitives/IconButton.d.ts +2 -2
- package/dist/primitives/ToggleButton.d.ts +1 -1
- package/dist/theme/SpatikaThemeProvider.d.ts +4 -5
- package/dist/theme/SpatikaThemeProvider.d.ts.map +1 -1
- package/dist/theme/SpatikaThemeProvider.js +28 -13
- package/package.json +4 -2
- package/skills/spatika-ui/SKILL.md +25 -6
- package/src/composites/EventCalendar.test.tsx +293 -0
- package/src/composites/EventCalendar.tsx +3 -3
- package/src/composites/RichTextEditor.test.tsx +51 -0
- package/src/composites/RichTextEditor.tsx +20 -84
- package/src/composites/chart-ui.test.tsx +21 -0
- package/src/composites/scheduler.test.tsx +0 -258
- package/src/index.test.ts +6 -0
- package/src/index.ts +138 -72
- package/src/lib/breakpoints.test.ts +29 -0
- package/src/lib/breakpoints.ts +28 -0
- package/src/lib/create-theme.test.ts +79 -0
- package/src/lib/create-theme.ts +249 -0
- package/src/lib/scheduler.test.ts +10 -0
- package/src/lib/scheduler.ts +2 -2
- package/src/lib/themes.test.ts +17 -0
- package/src/lib/themes.ts +43 -12
- package/src/lib/use-media-query.test.tsx +80 -0
- package/src/lib/use-media-query.ts +35 -1
- package/src/primitives/Grid.test.tsx +55 -0
- package/src/primitives/Grid.tsx +13 -3
- package/src/primitives/mui-more.test.tsx +17 -0
- package/src/theme/SpatikaThemeProvider.test.tsx +38 -0
- package/src/theme/SpatikaThemeProvider.tsx +43 -15
- package/src/composites/ChartDataGrid.test.tsx +0 -57
- package/src/composites/ChartDataGrid.tsx +0 -196
- package/src/composites/MapChart.test.tsx +0 -39
- package/src/composites/MapChart.tsx +0 -130
- package/src/composites/ScatterWebGL.test.tsx +0 -25
- package/src/composites/ScatterWebGL.tsx +0 -79
- package/src/composites/cartesian-charts.test.tsx +0 -136
- package/src/composites/cartesian-charts.tsx +0 -1355
- package/src/composites/chart-composition.test.tsx +0 -68
- package/src/composites/chart-composition.tsx +0 -1039
- package/src/composites/chart-interaction.ts +0 -136
- package/src/composites/chart-ui.tsx +0 -336
- package/src/composites/charts-3d.test.tsx +0 -25
- package/src/composites/charts-3d.tsx +0 -318
- package/src/composites/charts.test.tsx +0 -254
- package/src/composites/flow-charts.test.tsx +0 -98
- package/src/composites/flow-charts.tsx +0 -641
- package/src/composites/radial-charts.test.tsx +0 -89
- package/src/composites/radial-charts.tsx +0 -643
- package/src/lib/chart-export.test.ts +0 -20
- package/src/lib/chart-export.ts +0 -99
- package/src/lib/charts-platform.test.ts +0 -93
- package/src/lib/charts.test.ts +0 -132
- package/src/lib/charts.ts +0 -1021
- package/src/lib/geo.test.ts +0 -42
- package/src/lib/geo.ts +0 -138
- package/src/lib/iso.test.ts +0 -22
- package/src/lib/iso.ts +0 -102
- package/src/lib/webgl-scatter.test.ts +0 -36
- package/src/lib/webgl-scatter.ts +0 -230
package/src/primitives/Grid.tsx
CHANGED
|
@@ -53,6 +53,7 @@ export type GridProps = React.ComponentPropsWithoutRef<"div"> & {
|
|
|
53
53
|
sm?: number | "auto";
|
|
54
54
|
md?: number | "auto";
|
|
55
55
|
lg?: number | "auto";
|
|
56
|
+
xl?: number | "auto";
|
|
56
57
|
size?: number | "grow";
|
|
57
58
|
};
|
|
58
59
|
|
|
@@ -61,6 +62,13 @@ function spanFor(value?: number | "auto" | "grow") {
|
|
|
61
62
|
return spanClass[value] ?? "col-span-12";
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
function prefixedSpan(prefix: string, value?: number | "auto") {
|
|
66
|
+
if (typeof value !== "number") return undefined;
|
|
67
|
+
const base = spanClass[value];
|
|
68
|
+
if (!base) return undefined;
|
|
69
|
+
return base.replace("col-span", `${prefix}:col-span`);
|
|
70
|
+
}
|
|
71
|
+
|
|
64
72
|
/**
|
|
65
73
|
* 12-column CSS grid. Use `container` on the parent and breakpoint spans on items.
|
|
66
74
|
*/
|
|
@@ -73,6 +81,7 @@ function Grid({
|
|
|
73
81
|
sm,
|
|
74
82
|
md,
|
|
75
83
|
lg,
|
|
84
|
+
xl,
|
|
76
85
|
size,
|
|
77
86
|
className,
|
|
78
87
|
...props
|
|
@@ -87,9 +96,10 @@ function Grid({
|
|
|
87
96
|
container && "grid grid-cols-12",
|
|
88
97
|
container && (gapClass[spacing] ?? "gap-2"),
|
|
89
98
|
!container && (span ?? (size === "grow" ? "col-span-12" : "col-span-12")),
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
99
|
+
prefixedSpan("sm", sm),
|
|
100
|
+
prefixedSpan("md", md),
|
|
101
|
+
prefixedSpan("lg", lg),
|
|
102
|
+
prefixedSpan("xl", xl),
|
|
93
103
|
className,
|
|
94
104
|
)}
|
|
95
105
|
{...props}
|
|
@@ -62,6 +62,23 @@ describe("layout and form extras", () => {
|
|
|
62
62
|
expect(onTransfer).toHaveBeenCalled();
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
+
it("applies Grid breakpoint span classes including xl", () => {
|
|
66
|
+
const { container } = render(
|
|
67
|
+
<Grid container>
|
|
68
|
+
<Grid xs={12} sm={6} md={4} lg={3} xl={2}>
|
|
69
|
+
Cell
|
|
70
|
+
</Grid>
|
|
71
|
+
</Grid>,
|
|
72
|
+
);
|
|
73
|
+
const item = container.querySelector("[data-slot='grid'][data-item]");
|
|
74
|
+
expect(item?.className).toContain("col-span-12");
|
|
75
|
+
expect(item?.className).toContain("sm:col-span-6");
|
|
76
|
+
expect(item?.className).toContain("md:col-span-4");
|
|
77
|
+
expect(item?.className).toContain("lg:col-span-3");
|
|
78
|
+
expect(item?.className).toContain("xl:col-span-2");
|
|
79
|
+
expect(screen.getByText("Cell")).toBeInTheDocument();
|
|
80
|
+
});
|
|
81
|
+
|
|
65
82
|
it("switches BottomNavigation actions", async () => {
|
|
66
83
|
const user = userEvent.setup();
|
|
67
84
|
render(
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { cleanup, render, screen } from "@testing-library/react";
|
|
2
2
|
import userEvent from "@testing-library/user-event";
|
|
3
3
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { createTheme } from "../lib/create-theme";
|
|
4
5
|
import { SPATIKA_THEME_STORAGE_KEY } from "../lib/themes";
|
|
5
6
|
import { SpatikaThemeProvider, useSpatikaTheme } from "./SpatikaThemeProvider";
|
|
6
7
|
|
|
@@ -21,6 +22,7 @@ function resetDocumentTheme() {
|
|
|
21
22
|
document.documentElement.className = "";
|
|
22
23
|
document.documentElement.removeAttribute("data-spk-theme");
|
|
23
24
|
document.documentElement.style.colorScheme = "";
|
|
25
|
+
document.getElementById("spk-custom-themes")?.remove();
|
|
24
26
|
localStorage.removeItem(SPATIKA_THEME_STORAGE_KEY);
|
|
25
27
|
localStorage.removeItem("spk-theme-test");
|
|
26
28
|
}
|
|
@@ -79,4 +81,40 @@ describe("SpatikaThemeProvider", () => {
|
|
|
79
81
|
expect(() => render(<ThemeProbe />)).toThrow(/SpatikaThemeProvider/);
|
|
80
82
|
spy.mockRestore();
|
|
81
83
|
});
|
|
84
|
+
|
|
85
|
+
it("registers a custom theme and applies its base class on the wrapper", async () => {
|
|
86
|
+
const user = userEvent.setup();
|
|
87
|
+
const brand = createTheme({
|
|
88
|
+
id: "brand",
|
|
89
|
+
extends: "neelam",
|
|
90
|
+
palette: { primary: "#22c55e" },
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
function CustomProbe() {
|
|
94
|
+
const { theme, setTheme, themes } = useSpatikaTheme();
|
|
95
|
+
return (
|
|
96
|
+
<div>
|
|
97
|
+
<span data-testid="theme">{theme}</span>
|
|
98
|
+
<span data-testid="count">{themes.length}</span>
|
|
99
|
+
<button type="button" onClick={() => setTheme("brand")}>
|
|
100
|
+
Brand
|
|
101
|
+
</button>
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const { container } = render(
|
|
107
|
+
<SpatikaThemeProvider defaultTheme="mukta" syncDocument={false} customThemes={[brand]}>
|
|
108
|
+
<CustomProbe />
|
|
109
|
+
</SpatikaThemeProvider>,
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
expect(screen.getByTestId("count")).toHaveTextContent("5");
|
|
113
|
+
await user.click(screen.getByRole("button", { name: "Brand" }));
|
|
114
|
+
expect(screen.getByTestId("theme")).toHaveTextContent("brand");
|
|
115
|
+
const root = container.querySelector("[data-slot='spatika-theme-provider']");
|
|
116
|
+
expect(root).toHaveAttribute("data-spk-theme", "brand");
|
|
117
|
+
expect(root).toHaveClass("neelam");
|
|
118
|
+
expect(document.getElementById("spk-custom-themes")?.textContent).toContain("--primary: #22c55e;");
|
|
119
|
+
});
|
|
82
120
|
});
|
|
@@ -7,12 +7,21 @@ import {
|
|
|
7
7
|
useState,
|
|
8
8
|
type ReactNode,
|
|
9
9
|
} from "react";
|
|
10
|
+
import { cn } from "../lib/cn";
|
|
11
|
+
import {
|
|
12
|
+
findCustomTheme,
|
|
13
|
+
injectCustomThemeStyles,
|
|
14
|
+
type SpatikaTheme,
|
|
15
|
+
} from "../lib/create-theme";
|
|
10
16
|
import {
|
|
11
17
|
applyTheme,
|
|
12
18
|
DEFAULT_THEME,
|
|
19
|
+
isDarkTheme,
|
|
20
|
+
isThemeId,
|
|
13
21
|
readStoredTheme,
|
|
14
22
|
THEME_IDS,
|
|
15
23
|
writeStoredTheme,
|
|
24
|
+
type BuiltinThemeId,
|
|
16
25
|
type ThemeId,
|
|
17
26
|
} from "../lib/themes";
|
|
18
27
|
|
|
@@ -44,12 +53,24 @@ export type SpatikaThemeProviderProps = {
|
|
|
44
53
|
/** Optional element to theme instead of (or in addition to) document */
|
|
45
54
|
target?: HTMLElement | null;
|
|
46
55
|
className?: string;
|
|
56
|
+
/** Custom themes from `createTheme`. Switch them with `setTheme(id)`. */
|
|
57
|
+
customThemes?: readonly SpatikaTheme[];
|
|
47
58
|
};
|
|
48
59
|
|
|
60
|
+
function baseThemeClass(
|
|
61
|
+
theme: ThemeId,
|
|
62
|
+
custom: SpatikaTheme | undefined,
|
|
63
|
+
): BuiltinThemeId | undefined {
|
|
64
|
+
const base = custom?.extends ?? (isThemeId(theme) ? theme : DEFAULT_THEME);
|
|
65
|
+
return base === DEFAULT_THEME ? undefined : base;
|
|
66
|
+
}
|
|
67
|
+
|
|
49
68
|
/**
|
|
50
|
-
*
|
|
69
|
+
* Theme provider for Spatika.
|
|
51
70
|
* Wraps the app (or a subtree) and applies theme classes / data attributes.
|
|
52
71
|
*/
|
|
72
|
+
const EMPTY_CUSTOM_THEMES: readonly SpatikaTheme[] = [];
|
|
73
|
+
|
|
53
74
|
export function SpatikaThemeProvider({
|
|
54
75
|
children,
|
|
55
76
|
theme: themeProp,
|
|
@@ -59,11 +80,14 @@ export function SpatikaThemeProvider({
|
|
|
59
80
|
syncDocument = true,
|
|
60
81
|
target,
|
|
61
82
|
className,
|
|
83
|
+
customThemes = EMPTY_CUSTOM_THEMES,
|
|
62
84
|
}: SpatikaThemeProviderProps) {
|
|
85
|
+
const customIds = useMemo(() => customThemes.map((item) => item.id), [customThemes]);
|
|
63
86
|
const [uncontrolled, setUncontrolled] = useState<ThemeId>(
|
|
64
|
-
() => readStoredTheme(storageKey) ?? defaultTheme,
|
|
87
|
+
() => readStoredTheme(storageKey, customIds) ?? defaultTheme,
|
|
65
88
|
);
|
|
66
89
|
const theme = themeProp ?? uncontrolled;
|
|
90
|
+
const custom = findCustomTheme(theme, customThemes);
|
|
67
91
|
|
|
68
92
|
const setTheme = useCallback(
|
|
69
93
|
(next: ThemeId) => {
|
|
@@ -74,27 +98,31 @@ export function SpatikaThemeProvider({
|
|
|
74
98
|
);
|
|
75
99
|
|
|
76
100
|
useLayoutEffect(() => {
|
|
77
|
-
|
|
78
|
-
if (
|
|
101
|
+
injectCustomThemeStyles(customThemes);
|
|
102
|
+
if (syncDocument) applyTheme(theme, undefined, custom);
|
|
103
|
+
if (target) applyTheme(theme, target, custom);
|
|
79
104
|
writeStoredTheme(storageKey, theme);
|
|
80
|
-
}, [theme, syncDocument, target, storageKey]);
|
|
105
|
+
}, [theme, syncDocument, target, storageKey, custom, customThemes]);
|
|
81
106
|
|
|
82
|
-
const value = useMemo(
|
|
83
|
-
() =>
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
107
|
+
const value = useMemo(() => {
|
|
108
|
+
const extras = customThemes.map((item) => item.id).filter((id) => !isThemeId(id));
|
|
109
|
+
return {
|
|
110
|
+
theme,
|
|
111
|
+
setTheme,
|
|
112
|
+
themes: extras.length ? [...THEME_IDS, ...extras] : THEME_IDS,
|
|
113
|
+
} satisfies SpatikaThemeContextValue;
|
|
114
|
+
}, [theme, setTheme, customThemes]);
|
|
115
|
+
|
|
116
|
+
const dark = custom ? custom.colorScheme === "dark" : isDarkTheme(theme);
|
|
117
|
+
const themeClass = baseThemeClass(theme, custom);
|
|
91
118
|
|
|
92
119
|
return (
|
|
93
120
|
<SpatikaThemeContext.Provider value={value}>
|
|
94
121
|
<div
|
|
95
|
-
className={className}
|
|
122
|
+
className={cn(className, themeClass)}
|
|
96
123
|
data-spk-theme={theme}
|
|
97
124
|
data-slot="spatika-theme-provider"
|
|
125
|
+
style={{ colorScheme: dark ? "dark" : "light" }}
|
|
98
126
|
>
|
|
99
127
|
{children}
|
|
100
128
|
</div>
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import { render, screen } from "@testing-library/react";
|
|
2
|
-
import userEvent from "@testing-library/user-event";
|
|
3
|
-
import { describe, expect, it } from "vitest";
|
|
4
|
-
import { ChartDataGrid, renderChartCell } from "./ChartDataGrid";
|
|
5
|
-
|
|
6
|
-
describe("ChartDataGrid", () => {
|
|
7
|
-
it("renders a linked table and highlights a selected row", async () => {
|
|
8
|
-
const user = userEvent.setup();
|
|
9
|
-
const { container } = render(
|
|
10
|
-
<ChartDataGrid
|
|
11
|
-
height={140}
|
|
12
|
-
categoryField="name"
|
|
13
|
-
valueField="views"
|
|
14
|
-
rows={[
|
|
15
|
-
{ id: "web", name: "Web", views: 10, trend: [1, 2, 3] },
|
|
16
|
-
{ id: "ios", name: "iOS", views: 6, trend: [2, 2, 1] },
|
|
17
|
-
]}
|
|
18
|
-
columns={[
|
|
19
|
-
{ field: "name", headerName: "Channel" },
|
|
20
|
-
{ field: "views", headerName: "Views", type: "number" },
|
|
21
|
-
{ field: "trend", headerName: "Trend", type: "sparkline" },
|
|
22
|
-
]}
|
|
23
|
-
/>,
|
|
24
|
-
);
|
|
25
|
-
expect(container.querySelector('[data-slot="chart-data-grid"]')).toBeTruthy();
|
|
26
|
-
expect(screen.getByText("Channel")).toBeInTheDocument();
|
|
27
|
-
const row = container.querySelector(
|
|
28
|
-
'[data-slot="chart-data-grid"] tbody [data-slot="table-row"]:nth-child(2)',
|
|
29
|
-
);
|
|
30
|
-
await user.click(row!);
|
|
31
|
-
expect(row).toHaveAttribute("data-state", "selected");
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
it("hides the chart when requested", () => {
|
|
35
|
-
const { container } = render(
|
|
36
|
-
<ChartDataGrid
|
|
37
|
-
hideChart
|
|
38
|
-
categoryField="name"
|
|
39
|
-
valueField="views"
|
|
40
|
-
rows={[{ id: "web", name: "Web", views: 10 }]}
|
|
41
|
-
columns={[{ field: "name", headerName: "Channel" }]}
|
|
42
|
-
/>,
|
|
43
|
-
);
|
|
44
|
-
expect(container.querySelector('[data-slot="chart-container"]')).toBeNull();
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
describe("renderChartCell", () => {
|
|
49
|
-
it("returns a dash for non-numeric values", () => {
|
|
50
|
-
expect(renderChartCell("n/a")).toBe("—");
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
it("renders a sparkline for numeric arrays", () => {
|
|
54
|
-
const { container } = render(<>{renderChartCell([1, 4, 2], { variant: "sparkline" })}</>);
|
|
55
|
-
expect(container.querySelector('[data-slot="sparkline"]')).toBeTruthy();
|
|
56
|
-
});
|
|
57
|
-
});
|
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
import { useState, type ReactNode } from "react";
|
|
2
|
-
import { chartColor, formatChartNumber } from "../lib/charts";
|
|
3
|
-
import { cn } from "../lib/cn";
|
|
4
|
-
import {
|
|
5
|
-
Table,
|
|
6
|
-
TableBody,
|
|
7
|
-
TableCell,
|
|
8
|
-
TableHead,
|
|
9
|
-
TableHeader,
|
|
10
|
-
TableRow,
|
|
11
|
-
} from "../primitives/Table";
|
|
12
|
-
import { ChartContainer } from "./chart-composition";
|
|
13
|
-
import { SparkLineChart } from "./cartesian-charts";
|
|
14
|
-
import { PieChart } from "./radial-charts";
|
|
15
|
-
|
|
16
|
-
export type ChartGridColumnType = "text" | "number" | "sparkline" | "bar";
|
|
17
|
-
|
|
18
|
-
export type ChartGridColumn = {
|
|
19
|
-
field: string;
|
|
20
|
-
headerName: string;
|
|
21
|
-
type?: ChartGridColumnType;
|
|
22
|
-
width?: number;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export type ChartDataGridRow = { id: string } & Record<string, unknown>;
|
|
26
|
-
|
|
27
|
-
export type ChartDataGridProps = {
|
|
28
|
-
rows: ChartDataGridRow[];
|
|
29
|
-
columns: ChartGridColumn[];
|
|
30
|
-
chartType?: "bar" | "line" | "pie";
|
|
31
|
-
categoryField: string;
|
|
32
|
-
valueField: string;
|
|
33
|
-
height?: number;
|
|
34
|
-
className?: string;
|
|
35
|
-
selectedIds?: string[];
|
|
36
|
-
defaultSelectedIds?: string[];
|
|
37
|
-
onSelectionChange?: (ids: string[]) => void;
|
|
38
|
-
hideChart?: boolean;
|
|
39
|
-
hideLegend?: boolean;
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
export function renderChartCell(
|
|
43
|
-
value: unknown,
|
|
44
|
-
options?: {
|
|
45
|
-
variant?: "sparkline" | "bar";
|
|
46
|
-
color?: string;
|
|
47
|
-
height?: number;
|
|
48
|
-
max?: number;
|
|
49
|
-
},
|
|
50
|
-
): ReactNode {
|
|
51
|
-
const color = options?.color ?? "var(--chart-1)";
|
|
52
|
-
if (Array.isArray(value) && value.every((item) => typeof item === "number")) {
|
|
53
|
-
return (
|
|
54
|
-
<SparkLineChart
|
|
55
|
-
data={value as number[]}
|
|
56
|
-
plotType={options?.variant === "bar" ? "bar" : "line"}
|
|
57
|
-
color={color}
|
|
58
|
-
height={options?.height ?? 32}
|
|
59
|
-
showHighlight={false}
|
|
60
|
-
area={options?.variant !== "bar"}
|
|
61
|
-
/>
|
|
62
|
-
);
|
|
63
|
-
}
|
|
64
|
-
const n = typeof value === "number" ? value : Number(value);
|
|
65
|
-
if (!Number.isFinite(n)) return "—";
|
|
66
|
-
const max = options?.max && options.max > 0 ? options.max : Math.max(n, 1);
|
|
67
|
-
const pct = Math.max(0, Math.min(100, (n / max) * 100));
|
|
68
|
-
return (
|
|
69
|
-
<div className="spk-chart-cell-bar" title={formatChartNumber(n)}>
|
|
70
|
-
<span style={{ width: `${pct}%`, background: color }} />
|
|
71
|
-
</div>
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export function ChartDataGrid({
|
|
76
|
-
rows,
|
|
77
|
-
columns,
|
|
78
|
-
chartType = "bar",
|
|
79
|
-
categoryField,
|
|
80
|
-
valueField,
|
|
81
|
-
height = 200,
|
|
82
|
-
className,
|
|
83
|
-
selectedIds,
|
|
84
|
-
defaultSelectedIds,
|
|
85
|
-
onSelectionChange,
|
|
86
|
-
hideChart,
|
|
87
|
-
hideLegend = true,
|
|
88
|
-
}: ChartDataGridProps) {
|
|
89
|
-
const [internal, setInternal] = useState<string[]>(defaultSelectedIds ?? []);
|
|
90
|
-
const selected = selectedIds ?? internal;
|
|
91
|
-
const setSelected = (ids: string[]) => {
|
|
92
|
-
if (selectedIds == null) setInternal(ids);
|
|
93
|
-
onSelectionChange?.(ids);
|
|
94
|
-
};
|
|
95
|
-
const selectedIndex = selected.length ? rows.findIndex((row) => row.id === selected[0]) : -1;
|
|
96
|
-
const categories = rows.map((row) => String(row[categoryField] ?? row.id));
|
|
97
|
-
const values = rows.map((row) => Number(row[valueField] ?? 0));
|
|
98
|
-
const maxByField = new Map<string, number>();
|
|
99
|
-
for (const col of columns) {
|
|
100
|
-
if (col.type === "bar" || col.type === "number") {
|
|
101
|
-
const nums = rows.map((row) => Number(row[col.field] ?? 0)).filter((n) => Number.isFinite(n));
|
|
102
|
-
maxByField.set(col.field, nums.length ? Math.max(...nums) : 1);
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const toggleRow = (id: string) => {
|
|
107
|
-
setSelected(selected[0] === id ? [] : [id]);
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
return (
|
|
111
|
-
<div data-slot="chart-data-grid" className={cn("spk-chart-data-grid", className)}>
|
|
112
|
-
{hideChart ? null : chartType === "pie" ? (
|
|
113
|
-
<PieChart
|
|
114
|
-
height={height}
|
|
115
|
-
hideLegend={hideLegend}
|
|
116
|
-
series={[
|
|
117
|
-
{
|
|
118
|
-
innerRadius: 36,
|
|
119
|
-
paddingAngle: 2,
|
|
120
|
-
data: rows.map((row, i) => ({
|
|
121
|
-
id: row.id,
|
|
122
|
-
value: values[i] ?? 0,
|
|
123
|
-
label: categories[i],
|
|
124
|
-
color:
|
|
125
|
-
selected.length && !selected.includes(row.id)
|
|
126
|
-
? `color-mix(in srgb, ${chartColor(i)} 28%, transparent)`
|
|
127
|
-
: undefined,
|
|
128
|
-
})),
|
|
129
|
-
},
|
|
130
|
-
]}
|
|
131
|
-
/>
|
|
132
|
-
) : (
|
|
133
|
-
<ChartContainer
|
|
134
|
-
height={height}
|
|
135
|
-
hideLegend={hideLegend}
|
|
136
|
-
highlightScope="item"
|
|
137
|
-
selected={
|
|
138
|
-
selectedIndex >= 0 ? { seriesId: "value", dataIndex: selectedIndex } : null
|
|
139
|
-
}
|
|
140
|
-
xAxis={[{ data: categories }]}
|
|
141
|
-
series={[{ type: chartType, id: "value", label: valueField, data: values }]}
|
|
142
|
-
aria-label="Linked chart"
|
|
143
|
-
/>
|
|
144
|
-
)}
|
|
145
|
-
<Table containerClassName="spk-chart-grid-table">
|
|
146
|
-
<TableHeader>
|
|
147
|
-
<TableRow>
|
|
148
|
-
{columns.map((col) => (
|
|
149
|
-
<TableHead key={col.field} style={col.width ? { width: col.width } : undefined}>
|
|
150
|
-
{col.headerName}
|
|
151
|
-
</TableHead>
|
|
152
|
-
))}
|
|
153
|
-
</TableRow>
|
|
154
|
-
</TableHeader>
|
|
155
|
-
<TableBody>
|
|
156
|
-
{rows.map((row, rowIndex) => (
|
|
157
|
-
<TableRow
|
|
158
|
-
key={row.id}
|
|
159
|
-
data-state={selected.includes(row.id) ? "selected" : undefined}
|
|
160
|
-
onClick={() => toggleRow(row.id)}
|
|
161
|
-
style={{ cursor: "pointer" }}
|
|
162
|
-
>
|
|
163
|
-
{columns.map((col, colIndex) => {
|
|
164
|
-
const value = row[col.field];
|
|
165
|
-
const color = chartColor(rowIndex);
|
|
166
|
-
let content: ReactNode;
|
|
167
|
-
if (col.type === "sparkline") {
|
|
168
|
-
content = renderChartCell(value, { variant: "sparkline", color, height: 32 });
|
|
169
|
-
} else if (col.type === "bar") {
|
|
170
|
-
content = renderChartCell(value, {
|
|
171
|
-
variant: "bar",
|
|
172
|
-
color,
|
|
173
|
-
max: maxByField.get(col.field),
|
|
174
|
-
});
|
|
175
|
-
} else if (col.type === "number" && typeof value === "number") {
|
|
176
|
-
content = formatChartNumber(value);
|
|
177
|
-
} else {
|
|
178
|
-
content = value == null ? "—" : String(value);
|
|
179
|
-
}
|
|
180
|
-
return (
|
|
181
|
-
<TableCell key={col.field} data-field={col.field}>
|
|
182
|
-
{colIndex === 0 ? (
|
|
183
|
-
<span className="spk-chart-grid-name">{content}</span>
|
|
184
|
-
) : (
|
|
185
|
-
content
|
|
186
|
-
)}
|
|
187
|
-
</TableCell>
|
|
188
|
-
);
|
|
189
|
-
})}
|
|
190
|
-
</TableRow>
|
|
191
|
-
))}
|
|
192
|
-
</TableBody>
|
|
193
|
-
</Table>
|
|
194
|
-
</div>
|
|
195
|
-
);
|
|
196
|
-
}
|
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { render } from "@testing-library/react";
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
3
|
-
import { MapChart } from "./MapChart";
|
|
4
|
-
|
|
5
|
-
describe("MapChart", () => {
|
|
6
|
-
it("draws choropleth paths from GeoJSON", () => {
|
|
7
|
-
const { container } = render(
|
|
8
|
-
<MapChart
|
|
9
|
-
width={240}
|
|
10
|
-
height={160}
|
|
11
|
-
geoData={{
|
|
12
|
-
type: "FeatureCollection",
|
|
13
|
-
features: [
|
|
14
|
-
{
|
|
15
|
-
type: "Feature",
|
|
16
|
-
id: "a",
|
|
17
|
-
properties: { name: "A" },
|
|
18
|
-
geometry: {
|
|
19
|
-
type: "Polygon",
|
|
20
|
-
coordinates: [
|
|
21
|
-
[
|
|
22
|
-
[0, 0],
|
|
23
|
-
[4, 0],
|
|
24
|
-
[4, 3],
|
|
25
|
-
[0, 3],
|
|
26
|
-
[0, 0],
|
|
27
|
-
],
|
|
28
|
-
],
|
|
29
|
-
},
|
|
30
|
-
},
|
|
31
|
-
],
|
|
32
|
-
}}
|
|
33
|
-
series={[{ data: [{ id: "a", value: 12, label: "A" }] }]}
|
|
34
|
-
/>,
|
|
35
|
-
);
|
|
36
|
-
expect(container.querySelector('[data-slot="map-chart"]')).toBeTruthy();
|
|
37
|
-
expect(container.querySelector('[data-slot="map-chart"] path')).toBeTruthy();
|
|
38
|
-
});
|
|
39
|
-
});
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
import { chartColor, formatChartNumber, type ChartMargin } from "../lib/charts";
|
|
2
|
-
import {
|
|
3
|
-
featureId,
|
|
4
|
-
fitFeatures,
|
|
5
|
-
geoPath,
|
|
6
|
-
type GeoJsonFeatureCollection,
|
|
7
|
-
type GeoProjectionKind,
|
|
8
|
-
} from "../lib/geo";
|
|
9
|
-
import { ChartFrame, useChartHover } from "./chart-ui";
|
|
10
|
-
|
|
11
|
-
export type MapShapeDatum = {
|
|
12
|
-
id: string | number;
|
|
13
|
-
value: number;
|
|
14
|
-
label?: string;
|
|
15
|
-
color?: string;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
export type MapChartProps = {
|
|
19
|
-
geoData: GeoJsonFeatureCollection;
|
|
20
|
-
series?: Array<{ data: MapShapeDatum[] }>;
|
|
21
|
-
idProperty?: string;
|
|
22
|
-
projection?: GeoProjectionKind;
|
|
23
|
-
height?: number;
|
|
24
|
-
width?: number;
|
|
25
|
-
colors?: string[];
|
|
26
|
-
hideLegend?: boolean;
|
|
27
|
-
margin?: ChartMargin;
|
|
28
|
-
className?: string;
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
export function MapChart({
|
|
32
|
-
geoData,
|
|
33
|
-
series,
|
|
34
|
-
idProperty = "id",
|
|
35
|
-
projection = "mercator",
|
|
36
|
-
height = 280,
|
|
37
|
-
width,
|
|
38
|
-
colors,
|
|
39
|
-
hideLegend,
|
|
40
|
-
margin,
|
|
41
|
-
className,
|
|
42
|
-
}: MapChartProps) {
|
|
43
|
-
const features = geoData.features ?? [];
|
|
44
|
-
const data = series?.[0]?.data ?? [];
|
|
45
|
-
const byId = new Map(data.map((item) => [String(item.id), item]));
|
|
46
|
-
const values = data.map((item) => item.value);
|
|
47
|
-
const min = values.length ? Math.min(...values) : 0;
|
|
48
|
-
const max = values.length ? Math.max(...values) : 1;
|
|
49
|
-
const { hover, setHover, clear } = useChartHover();
|
|
50
|
-
const legend = hideLegend
|
|
51
|
-
? undefined
|
|
52
|
-
: data.slice(0, 8).map((item, i) => ({
|
|
53
|
-
id: String(item.id),
|
|
54
|
-
label: item.label ?? String(item.id),
|
|
55
|
-
color: item.color ?? choroplethFill(item.value, min, max, i, colors),
|
|
56
|
-
}));
|
|
57
|
-
|
|
58
|
-
return (
|
|
59
|
-
<ChartFrame
|
|
60
|
-
slot="map-chart"
|
|
61
|
-
className={className}
|
|
62
|
-
width={width}
|
|
63
|
-
height={height}
|
|
64
|
-
margin={margin ?? { top: 8, right: 8, bottom: 8, left: 8 }}
|
|
65
|
-
legend={legend}
|
|
66
|
-
hover={hover}
|
|
67
|
-
aria-label="Map chart"
|
|
68
|
-
>
|
|
69
|
-
{({ width: w, height: h, m }) => {
|
|
70
|
-
const pad = Math.min(m.left, m.right, m.top, m.bottom);
|
|
71
|
-
const project = fitFeatures(features, w, h, pad, projection);
|
|
72
|
-
return (
|
|
73
|
-
<svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
|
|
74
|
-
{features.map((feature, i) => {
|
|
75
|
-
if (!feature.geometry) return null;
|
|
76
|
-
const id = featureId(feature, idProperty) || String(i);
|
|
77
|
-
const datum = byId.get(id);
|
|
78
|
-
const label =
|
|
79
|
-
datum?.label ??
|
|
80
|
-
(feature.properties?.name != null ? String(feature.properties.name) : id);
|
|
81
|
-
const fill = datum
|
|
82
|
-
? (datum.color ?? choroplethFill(datum.value, min, max, i, colors))
|
|
83
|
-
: "color-mix(in srgb, var(--muted) 55%, transparent)";
|
|
84
|
-
const d = geoPath(feature.geometry, project);
|
|
85
|
-
if (!d) return null;
|
|
86
|
-
return (
|
|
87
|
-
<path
|
|
88
|
-
key={id}
|
|
89
|
-
d={d}
|
|
90
|
-
fill={fill}
|
|
91
|
-
className="spk-chart-map-region spk-chart-mark"
|
|
92
|
-
onMouseEnter={(event) =>
|
|
93
|
-
setHover({
|
|
94
|
-
x: event.nativeEvent.offsetX,
|
|
95
|
-
y: event.nativeEvent.offsetY,
|
|
96
|
-
title: label,
|
|
97
|
-
items: datum
|
|
98
|
-
? [
|
|
99
|
-
{
|
|
100
|
-
color: fill,
|
|
101
|
-
label: label,
|
|
102
|
-
value: formatChartNumber(datum.value),
|
|
103
|
-
},
|
|
104
|
-
]
|
|
105
|
-
: [{ color: fill, label: label, value: "—" }],
|
|
106
|
-
})
|
|
107
|
-
}
|
|
108
|
-
onMouseLeave={clear}
|
|
109
|
-
/>
|
|
110
|
-
);
|
|
111
|
-
})}
|
|
112
|
-
</svg>
|
|
113
|
-
);
|
|
114
|
-
}}
|
|
115
|
-
</ChartFrame>
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function choroplethFill(
|
|
120
|
-
value: number,
|
|
121
|
-
min: number,
|
|
122
|
-
max: number,
|
|
123
|
-
index: number,
|
|
124
|
-
colors?: string[],
|
|
125
|
-
): string {
|
|
126
|
-
const t = max === min ? 0.6 : (value - min) / (max - min);
|
|
127
|
-
const base = chartColor(index, colors);
|
|
128
|
-
const mix = Math.round(28 + t * 72);
|
|
129
|
-
return `color-mix(in srgb, ${base} ${mix}%, var(--muted))`;
|
|
130
|
-
}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { render } from "@testing-library/react";
|
|
2
|
-
import { describe, expect, it, vi } from "vitest";
|
|
3
|
-
import { ScatterWebGLLayer } from "./ScatterWebGL";
|
|
4
|
-
|
|
5
|
-
describe("ScatterWebGLLayer", () => {
|
|
6
|
-
it("renders a canvas layer and reports when WebGL is unavailable", () => {
|
|
7
|
-
const onUnavailable = vi.fn();
|
|
8
|
-
const { container } = render(
|
|
9
|
-
<ScatterWebGLLayer
|
|
10
|
-
width={120}
|
|
11
|
-
height={80}
|
|
12
|
-
series={[{ data: [{ x: 1, y: 2 }] }]}
|
|
13
|
-
meta={[{ id: "a", label: "A", color: "#2563eb" }]}
|
|
14
|
-
hidden={new Set()}
|
|
15
|
-
xAt={(value) => value}
|
|
16
|
-
yAt={(value) => value}
|
|
17
|
-
sizeAt={(z) => z}
|
|
18
|
-
bubble={false}
|
|
19
|
-
onUnavailable={onUnavailable}
|
|
20
|
-
/>,
|
|
21
|
-
);
|
|
22
|
-
expect(container.querySelector('[data-slot="scatter-webgl"]')).toBeTruthy();
|
|
23
|
-
expect(onUnavailable).toHaveBeenCalled();
|
|
24
|
-
});
|
|
25
|
-
});
|