Skip to content

Commit 84254e9

Browse files
committed
feat: adds initial implementation of solid-uplot
1 parent 2a5e97a commit 84254e9

File tree

8 files changed

+517
-4
lines changed

8 files changed

+517
-4
lines changed

bun.lock

Lines changed: 70 additions & 0 deletions
Large diffs are not rendered by default.

jsr.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"license": "MIT",
55
"exports": "./src/index.tsx",
66
"publish": {
7-
"include": ["LICENSE", "README.md", "src/**/*.tsx"],
7+
"include": ["LICENSE", "README.md", "CHANGELOG.md", "src/**/*.tsx"],
88
"exclude": ["**/*.test.tsx"]
99
}
1010
}

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,24 +57,28 @@
5757
"@typescript-eslint/eslint-plugin": "^8.32.0",
5858
"@typescript-eslint/parser": "^8.32.0",
5959
"@vitest/coverage-istanbul": "^3.1.3",
60+
"canvas": "^3.1.0",
6061
"eslint": "^9.26.0",
6162
"eslint-plugin-simple-import-sort": "^12.1.1",
6263
"eslint-plugin-solid": "^0.14.5",
6364
"globals": "^16.1.0",
6465
"jiti": "^2.4.2",
6566
"jsdom": "^26.1.0",
67+
"path2d": "^0.2.2",
6668
"prettier": "^3.5.3",
6769
"solid-js": "^1.9.6",
6870
"tailwindcss": "^4.1.5",
6971
"tsup": "^8.4.0",
7072
"tsup-preset-solid": "^2.2.0",
7173
"typescript": "^5.8.3",
7274
"typescript-eslint": "^8.32.0",
75+
"uplot": "^1.6.32",
7376
"vite": "^6.3.5",
7477
"vite-plugin-solid": "^2.11.6",
7578
"vitest": "^3.1.3"
7679
},
7780
"peerDependencies": {
78-
"solid-js": ">=1.6.0"
81+
"solid-js": ">=1.6.0",
82+
"uplot": ">=1.6.32"
7983
}
8084
}

setupTests.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,21 @@
11
import "@testing-library/jest-dom/vitest";
2+
3+
import { Path2D } from "path2d";
4+
import { vi } from "vitest";
5+
6+
window.matchMedia = (query: string) => ({
7+
matches: false,
8+
media: query,
9+
onchange: null,
10+
addListener: vi.fn(),
11+
removeListener: vi.fn(),
12+
addEventListener: vi.fn(),
13+
removeEventListener: vi.fn(),
14+
dispatchEvent: vi.fn(),
15+
});
16+
17+
Object.defineProperty(window, "Path2D", {
18+
value: Path2D,
19+
writable: false,
20+
configurable: true,
21+
});

src/SolidUplot.tsx

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import "uplot/dist/uPlot.min.css";
2+
3+
import {
4+
createEffect,
5+
type JSX,
6+
mergeProps,
7+
onCleanup,
8+
type ParentProps,
9+
splitProps,
10+
untrack,
11+
} from "solid-js";
12+
import uPlot from "uplot";
13+
14+
type SolidUplotProps = uPlot.Options & {
15+
/** The ref of the uPlot instance */
16+
readonly ref?: (el: HTMLDivElement) => void;
17+
/** The id of the uPlot instance */
18+
readonly id?: string;
19+
/** The class name of the uPlot instance */
20+
readonly class?: string;
21+
/** Callback when uPlot instance is created */
22+
readonly onCreate?: (u: uPlot, container: HTMLDivElement) => void;
23+
/** Apply scale reset on redraw triggered by updated plot data (default: `true`) */
24+
readonly resetScales?: boolean;
25+
/** The style of the uPlot instance container */
26+
readonly style?: JSX.CSSProperties;
27+
};
28+
29+
export const SolidUplot = (props: ParentProps<SolidUplotProps>): JSX.Element => {
30+
let container!: HTMLDivElement;
31+
const [local, options] = splitProps(props, [
32+
"class",
33+
"children",
34+
"id",
35+
"onCreate",
36+
"style",
37+
"ref",
38+
]);
39+
40+
const _options = mergeProps({ data: [] as uPlot.AlignedData, resetScales: true }, options);
41+
const [updateableOptions, newChartOptions] = splitProps(_options, [
42+
"data",
43+
"width",
44+
"height",
45+
"resetScales",
46+
]);
47+
48+
const size = () => ({ width: updateableOptions.width, height: updateableOptions.height });
49+
50+
createEffect(() => {
51+
const initialSize = untrack(size);
52+
const initialData = untrack(() => updateableOptions.data);
53+
const chart = new uPlot({ ...newChartOptions, ...initialSize }, initialData, container);
54+
55+
local.onCreate?.(chart, container);
56+
57+
createEffect(() => {
58+
chart.setSize(size());
59+
});
60+
61+
createEffect(() => {
62+
chart.setData(updateableOptions.data, updateableOptions.resetScales);
63+
});
64+
65+
onCleanup(() => {
66+
chart.destroy();
67+
});
68+
});
69+
70+
return (
71+
<div
72+
ref={(el) => {
73+
container = el;
74+
local.ref?.(el);
75+
}}
76+
id={local.id}
77+
class={local.class}
78+
style={local.style}
79+
>
80+
{local.children}
81+
</div>
82+
);
83+
};

src/__tests__/SolidUplot.test.tsx

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
import { cleanup, render, waitFor } from "@solidjs/testing-library";
2+
import { createSignal } from "solid-js";
3+
import uPlot from "uplot";
4+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
5+
6+
import { SolidUplot } from "../SolidUplot";
7+
8+
const DEFAULT_DATA: uPlot.AlignedData = [
9+
[1, 2, 3, 4, 5], // x-values
10+
[10, 20, 30, 40, 50], // y-values for the first series
11+
];
12+
13+
const DEFAULT_OPTIONS: uPlot.Options = {
14+
width: 400,
15+
height: 300,
16+
series: [
17+
{},
18+
{
19+
label: "Series 1",
20+
stroke: "red",
21+
},
22+
],
23+
};
24+
25+
describe("COMPONENT: <SolidUplot />", () => {
26+
beforeEach(() => {
27+
vi.clearAllMocks();
28+
});
29+
30+
afterEach(() => {
31+
cleanup();
32+
});
33+
34+
test("renders with default props", async () => {
35+
const onCreateSpy = vi.fn();
36+
37+
const { container } = render(() => (
38+
<SolidUplot {...DEFAULT_OPTIONS} data={DEFAULT_DATA} onCreate={onCreateSpy} />
39+
));
40+
41+
await waitFor(() => expect(onCreateSpy).toHaveBeenCalled());
42+
43+
expect(container.querySelector("div")).not.toBeNull();
44+
});
45+
46+
test("renders children inside the container", () => {
47+
const { getByText } = render(() => (
48+
<SolidUplot id="test-id" {...DEFAULT_OPTIONS} data={DEFAULT_DATA}>
49+
<div data-testid="child-element">Child content</div>
50+
</SolidUplot>
51+
));
52+
53+
expect(getByText("Child content")).toBeInTheDocument();
54+
});
55+
56+
test("passes props to the container", () => {
57+
const { container } = render(() => (
58+
<SolidUplot
59+
{...DEFAULT_OPTIONS}
60+
id="test-id"
61+
class="test-class"
62+
style={{ width: "500px", height: "300px" }}
63+
data={DEFAULT_DATA}
64+
/>
65+
));
66+
67+
const div = container.querySelector("div");
68+
69+
expect(div).toHaveAttribute("id", "test-id");
70+
expect(div).toHaveAttribute("class", "test-class");
71+
expect(div).toHaveStyle({ width: "500px", height: "300px" });
72+
});
73+
74+
test("calls onCreate callback when plot is created", () => {
75+
const onCreateMock = vi.fn();
76+
77+
render(() => <SolidUplot {...DEFAULT_OPTIONS} data={DEFAULT_DATA} onCreate={onCreateMock} />);
78+
79+
expect(onCreateMock).toHaveBeenCalledTimes(1);
80+
expect(onCreateMock).toHaveBeenCalledWith(expect.any(Object), expect.any(HTMLDivElement));
81+
});
82+
83+
test("reactively updates the chart size when width or height changes", async () => {
84+
let chartInstance: uPlot;
85+
const onCreateSpy = vi.fn((chart: uPlot) => {
86+
chartInstance = chart;
87+
});
88+
const [dimensions, setDimensions] = createSignal({ width: 400, height: 300 });
89+
90+
render(() => (
91+
<SolidUplot
92+
{...DEFAULT_OPTIONS}
93+
data={DEFAULT_DATA}
94+
width={dimensions().width}
95+
height={dimensions().height}
96+
onCreate={onCreateSpy}
97+
/>
98+
));
99+
100+
await waitFor(() => expect(chartInstance).toBeDefined());
101+
102+
expect(chartInstance!.width).toBe(400);
103+
expect(chartInstance!.height).toBe(300);
104+
105+
setDimensions({ width: 500, height: 400 }); // Trigger reactive update
106+
107+
expect(chartInstance!.width).toBe(500);
108+
expect(chartInstance!.height).toBe(400);
109+
110+
expect(onCreateSpy).toHaveBeenCalledTimes(1);
111+
});
112+
113+
test("reactively updates the chart data when data changes", async () => {
114+
let chartInstance: uPlot;
115+
116+
const onCreateSpy = vi.fn((chart: uPlot) => {
117+
chartInstance = chart;
118+
});
119+
120+
const [chartData, setChartData] = createSignal<uPlot.AlignedData>(DEFAULT_DATA);
121+
122+
render(() => <SolidUplot {...DEFAULT_OPTIONS} data={chartData()} onCreate={onCreateSpy} />);
123+
124+
await waitFor(() => expect(chartInstance).toBeDefined());
125+
126+
expect(chartInstance!.data).toEqual(DEFAULT_DATA);
127+
128+
const newData: uPlot.AlignedData = [
129+
[1, 2, 3, 4, 5],
130+
[15, 25, 35, 45, 55],
131+
];
132+
133+
setChartData(newData); // Trigger reactive update
134+
135+
expect(chartInstance!.data).toEqual(newData);
136+
});
137+
138+
test("reactively applies resetScales option when updating data", async () => {
139+
let chartInstance: uPlot;
140+
141+
const onCreateSpy = vi.fn((chart: uPlot) => {
142+
chartInstance = chart;
143+
});
144+
145+
const [chartData, setChartData] = createSignal<uPlot.AlignedData>(DEFAULT_DATA);
146+
const [resetScales, setResetScales] = createSignal(false);
147+
148+
render(() => (
149+
<SolidUplot
150+
{...DEFAULT_OPTIONS}
151+
data={chartData()}
152+
resetScales={resetScales()}
153+
onCreate={onCreateSpy}
154+
/>
155+
));
156+
157+
await waitFor(() => expect(chartInstance).toBeDefined());
158+
159+
vi.spyOn(chartInstance!, "setData");
160+
161+
// New data for the update
162+
const newData: uPlot.AlignedData = [
163+
[1, 2, 3, 4, 5],
164+
[15, 25, 35, 45, 55],
165+
];
166+
167+
setChartData(newData);
168+
169+
expect(chartInstance!.setData).toHaveBeenCalledWith(newData, false);
170+
171+
setResetScales(true);
172+
173+
expect(chartInstance!.setData).toHaveBeenCalledWith(newData, true);
174+
175+
expect(onCreateSpy).toHaveBeenCalledTimes(1);
176+
});
177+
178+
test("destroys the chart when component is unmounted", async () => {
179+
let chartInstance: uPlot;
180+
181+
const onCreateSpy = vi.fn((chart: uPlot) => {
182+
chartInstance = chart;
183+
});
184+
185+
const { unmount } = render(() => (
186+
<SolidUplot {...DEFAULT_OPTIONS} data={DEFAULT_DATA} onCreate={onCreateSpy} />
187+
));
188+
189+
await waitFor(() => expect(onCreateSpy).toHaveBeenCalled());
190+
191+
vi.spyOn(chartInstance!, "destroy");
192+
193+
unmount();
194+
195+
expect(chartInstance!.destroy).toHaveBeenCalledTimes(1);
196+
});
197+
198+
test("creates a new chart when a non-updateable prop changes (e.g. series)", async () => {
199+
const onCreateSpy = vi.fn();
200+
201+
const initialSeries = [
202+
{
203+
label: "Series 1",
204+
stroke: "red",
205+
},
206+
] as uPlot.Series[];
207+
208+
const [data, setData] = createSignal<uPlot.AlignedData>(DEFAULT_DATA);
209+
const [series, setSeries] = createSignal<uPlot.Series[]>(initialSeries);
210+
211+
render(() => (
212+
<SolidUplot {...DEFAULT_OPTIONS} data={data()} series={series()} onCreate={onCreateSpy} />
213+
));
214+
215+
await waitFor(() => expect(onCreateSpy).toHaveBeenCalledTimes(1));
216+
217+
setData([...DEFAULT_DATA, [5, 15, 25, 35, 45]]);
218+
219+
setSeries([
220+
...initialSeries,
221+
{
222+
label: "Series 2",
223+
stroke: "blue",
224+
},
225+
]);
226+
227+
await waitFor(() => expect(onCreateSpy).toHaveBeenCalledTimes(2));
228+
});
229+
});

src/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
// Main library export site
2-
// Use playground app (via Vite) to test and document the library
1+
export { tooltipPlugin } from "./plugins/tooltip";
2+
export { SolidUplot } from "./SolidUplot";

0 commit comments

Comments
 (0)