@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
@@ -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
- sm && typeof sm === "number" && spanClass[sm]?.replace("col-span", "sm:col-span"),
91
- md && typeof md === "number" && spanClass[md]?.replace("col-span", "md:col-span"),
92
- lg && typeof lg === "number" && spanClass[lg]?.replace("col-span", "lg:col-span"),
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
- * MUI-style theme provider for Spatika.
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
- if (syncDocument) applyTheme(theme);
78
- if (target) applyTheme(theme, target);
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
- theme,
86
- setTheme,
87
- themes: THEME_IDS,
88
- }) satisfies SpatikaThemeContextValue,
89
- [theme, setTheme],
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
- });