diff --git a/README.md b/README.md index a8948b8..18011ee 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,71 @@ -# Template: SolidJS Library +# μPlot Solid -Template for [SolidJS](https://www.solidjs.com/) library package. Bundling of the library is managed by [tsup](https://tsup.egoist.dev/). +Solid wrapper around [μPlot](https://github.com/leeoniya/uPlot?tab=readme-ov-file#-%CE%BCplot) which is a small, fast and performant 2D canvas based chart for time series, lines, areas, ohlc & bars. It exposes the μPlot API in a fully typed, declarative JSX format and does the work to make the μPlot experience as reactive as possible. -Other things configured include: +Once a μPlot instance is made, the component will **not** recreate a new instance even if the `data` or `size` of the chart updates reactively. However, it will create a new chart instance if any other options are reactively updated when using it (e.g. `series`, `hooks`, `plugins`, etc.). -- Bun (for dependency management and running scripts) -- TypeScript -- ESLint / Prettier -- Solid Testing Library + Vitest (for testing) -- GitHub Actions (for all CI/CD) +## Installing + +```bash +npm install uplot uplot-solid +pnpm add uplot uplot-solid +yarn add uplot uplot-solid +bun add uplot uplot-solid +``` + +## Overview + +If you are new to μPlot, here is a quick breakdown of some things using a simple example plot: + +```tsx + +``` + +The above is the minimum of what you need to get a chart on screen given you have filled in the data. The expected format of `data` is a 2D array where the first array is the dataset for the x-series of the plot. The `series` list follows the same ordering being a list of config objects where the first object applies to the x-series and the rest to the y-series. μPlot supports a single x-series and all the y-series share the same x-series data. There are many more fields within the series struct that you can configure to tailor the display of the series data to your liking. + +Here are some other key options within the API to familiarize yourself with: + +- `hooks`: An object structure exposing key events that occur within μPlot. Every event hook accepts an **array of callbacks** allowing you to have discrete units of work that occur within a single event. +- `plugins`: A **plugin** is used to extend or modify the behavior of the chart by injecting custom functionality. You can hook into the various lifecycle events (the very same ones exposed from the `hooks` property) and add custom behavior, such as drawing on the canvas, adding custom controls, tooltips, interactions, or integrating with external libraries. + +### Why Use Plugins Instead of Hooks? + +In a nutshell, separation of concerns. Here are some points: + +- **Customization**: Add custom drawings (like annotations, lines, or custom tooltips). +- **Modularity**: Keep the core logic of the chart clean and separate from specific custom behavior. +- **Reusability**: Create reusable plugins that can be applied across multiple uPlot instances with consistent functionality. +- **Extensibility**: Augment the chart with additional features (e.g., zoom controls, data overlays). + +### Demos + +Be sure to check out the [μPlot demos](https://leeoniya.github.io/uPlot/demos/index.html) to see the many kinds of different charts you can create. The code for the demos can be found [here](https://github.com/leeoniya/uPlot/tree/master/demos). + +Over time, I will try to add example demos using this library to showcase some simple and more complex examples (e.g. creating custom tooltips either via `plugins` or using Solid directly) ## Getting Started diff --git a/bun.lockb b/bun.lockb index c25dc32..4b02299 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 75fa323..3e2485d 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,24 @@ { - "name": "template-solidjs-library", - "version": "0.0.0", - "description": "Template for SolidJS library using tsup for bundling. Configured with Bun, NVM, TypeScript, ESLint, Prettier, Vitest, and GHA", + "name": "uplot-solid", + "version": "1.0.0", + "description": "Solid wrapper for μPlot", "type": "module", + "author": "Daniel Sanchez ", + "license": "MIT", + "homepage": "https://github.com/thedanchez/uplot-solid#readme", + "bugs": { + "url": "https://github.com/thedanchez/uplot-solid/issues" + }, + "files": [ + "dist" + ], + "keywords": [ + "uPlot", + "Solid", + "SolidJS", + "charts", + "plot" + ], "scripts": { "build": "tsup", "build:watch": "tsup --watch", @@ -17,7 +33,6 @@ "test:cov": "vitest run --coverage", "typecheck": "tsc --noEmit" }, - "license": "MIT", "devDependencies": { "@solidjs/testing-library": "^0.8.10", "@testing-library/jest-dom": "^6.5.0", @@ -25,19 +40,35 @@ "@typescript-eslint/eslint-plugin": "^8.7.0", "@typescript-eslint/parser": "^8.7.0", "@vitest/coverage-istanbul": "^2.1.1", + "esbuild-css-modules-plugin": "^3.1.2", "eslint": "^8.57.0", "eslint-plugin-simple-import-sort": "^12.1.1", "eslint-plugin-solid": "^0.14.3", "jsdom": "^25.0.1", "prettier": "^3.3.3", + "solid-js": "^1.9.1", "tsup": "^8.3.0", "tsup-preset-solid": "^2.2.0", "typescript": "^5.6.2", + "uplot": "^1.6.31", "vite": "^5.4.8", "vite-plugin-solid": "^2.10.2", "vitest": "^2.1.1" }, "peerDependencies": { - "solid-js": ">=1.8.0" - } + "solid-js": ">=1.8.0", + "uplot": ">=1.6.31" + }, + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "browser": {}, + "exports": { + "solid": "./dist/index.jsx", + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "typesVersions": {} } diff --git a/playground/App.tsx b/playground/App.tsx index 0bbca41..4b866cf 100644 --- a/playground/App.tsx +++ b/playground/App.tsx @@ -1,61 +1,99 @@ -import { createSignal, For } from "solid-js"; -import { createStore } from "solid-js/store"; +import { createSignal } from "solid-js"; -type Todo = { id: number; text: string; completed: boolean }; +import UplotSolid from "../dist"; +import fakeData from "./resource/uplot_fake_data.json"; + +const isNil = (value: unknown): value is null | undefined => value === null || value === undefined; export const App = () => { - let input!: HTMLInputElement; + const [size, setSize] = createSignal({ width: 1000, height: 300 }); + const [position, setPosition] = createSignal({ left: 0, top: 0 }); + const [content, setContent] = createSignal(""); - const [count, setCount] = createSignal(0); - const [todos, setTodos] = createStore([]); + const alignedData = [fakeData.time, fakeData.y_series_1, fakeData.y_series_2] as uPlot.AlignedData; - const addTodo = (text: string) => { - setTodos(todos.length, { id: todos.length, text, completed: false }); - }; + return ( +
+ + { + const { idx } = u.cursor; - const toggleTodo = (id: number) => { - setTodos(id, "completed", (c) => !c); - }; + const left = u.cursor.left as number; + const top = u.cursor.top as number; - return ( - <> -
-
Count: {count()}
- - - -
- - {(todo) => { - const { id, text } = todo; - return ( -
- - - {text} - -
- ); + if (!isNil(idx)) { + const xValue = u.data[0][idx] as number; + const yValue = u.data[1]?.[idx] as number; + const yValue2 = u.data[2]?.[idx] as number; + + setPosition({ left: left - 90, top: top + 40 }); + // setContent(`x: ${xValue}, y: ${yValue}`); + setContent( + `x: ${new Date(xValue).toLocaleTimeString()}, y1: ${yValue}, y2: ${yValue2}. Pluse randome number: ${Math.random()}`, + ); + } else { + setContent(""); // Hide tooltip + } + }, + ], }} -
- + /> + +
); }; + +type TooltipProps = { + readonly position: { left: number; top: number }; + readonly content: string; +}; + +function Tooltip(props: TooltipProps) { + return ( +
+ {props.content} +
+ ); +} diff --git a/playground/resource/uplot_fake_data.json b/playground/resource/uplot_fake_data.json new file mode 100644 index 0000000..3ecc76b --- /dev/null +++ b/playground/resource/uplot_fake_data.json @@ -0,0 +1,70 @@ +{ + "time": [ + 1727395200000, 1727395500000, 1727395800000, 1727396100000, 1727396400000, 1727396700000, 1727397000000, + 1727397300000, 1727397600000, 1727397900000, 1727398200000, 1727398500000, 1727398800000, 1727399100000, + 1727399400000, 1727399700000, 1727400000000, 1727400300000, 1727400600000, 1727400900000, 1727401200000, + 1727401500000, 1727401800000, 1727402100000, 1727402400000, 1727402700000, 1727403000000, 1727403300000, + 1727403600000, 1727403900000, 1727404200000, 1727404500000, 1727404800000, 1727405100000, 1727405400000, + 1727405700000, 1727406000000, 1727406300000, 1727406600000, 1727406900000, 1727407200000, 1727407500000, + 1727407800000, 1727408100000, 1727408400000, 1727408700000, 1727409000000, 1727409300000, 1727409600000, + 1727409900000, 1727410200000, 1727410500000, 1727410800000, 1727411100000, 1727411400000, 1727411700000, + 1727412000000, 1727412300000, 1727412600000, 1727412900000, 1727413200000, 1727413500000, 1727413800000, + 1727414100000, 1727414400000, 1727414700000, 1727415000000, 1727415300000, 1727415600000, 1727415900000, + 1727416200000, 1727416500000, 1727416800000, 1727417100000, 1727417400000, 1727417700000, 1727418000000, + 1727418300000, 1727418600000, 1727418900000, 1727419200000, 1727419500000, 1727419800000, 1727420100000, + 1727420400000, 1727420700000, 1727421000000, 1727421300000, 1727421600000, 1727421900000, 1727422200000, + 1727422500000, 1727422800000, 1727423100000, 1727423400000, 1727423700000, 1727424000000, 1727424300000, + 1727424600000, 1727424900000, 1727425200000, 1727425500000, 1727425800000, 1727426100000, 1727426400000, + 1727426700000, 1727427000000, 1727427300000, 1727427600000, 1727427900000, 1727428200000, 1727428500000, + 1727428800000, 1727429100000, 1727429400000, 1727429700000, 1727430000000, 1727430300000, 1727430600000, + 1727430900000, 1727431200000, 1727431500000, 1727431800000, 1727432100000, 1727432400000, 1727432700000, + 1727433000000, 1727433300000, 1727433600000, 1727433900000, 1727434200000, 1727434500000, 1727434800000, + 1727435100000, 1727435400000, 1727435700000, 1727436000000, 1727436300000, 1727436600000, 1727436900000, + 1727437200000, 1727437500000, 1727437800000, 1727438100000, 1727438400000, 1727438700000, 1727439000000, + 1727439300000, 1727439600000, 1727439900000, 1727440200000, 1727440500000, 1727440800000, 1727441100000, + 1727441400000, 1727441700000, 1727442000000, 1727442300000, 1727442600000, 1727442900000, 1727443200000, + 1727443500000, 1727443800000, 1727444100000, 1727444400000, 1727444700000, 1727445000000, 1727445300000, + 1727445600000, 1727445900000, 1727446200000, 1727446500000, 1727446800000, 1727447100000, 1727447400000, + 1727447700000, 1727448000000, 1727448300000, 1727448600000, 1727448900000, 1727449200000, 1727449500000, + 1727449800000, 1727450100000, 1727450400000, 1727450700000, 1727451000000, 1727451300000, 1727451600000, + 1727451900000, 1727452200000, 1727452500000, 1727452800000, 1727453100000, 1727453400000, 1727453700000, + 1727454000000, 1727454300000, 1727454600000, 1727454900000, 1727455200000, 1727455500000, 1727455800000, + 1727456100000, 1727456400000, 1727456700000, 1727457000000, 1727457300000, 1727457600000, 1727457900000, + 1727458200000, 1727458500000, 1727458800000, 1727459100000, 1727459400000, 1727459700000, 1727460000000, + 1727460300000, 1727460600000, 1727460900000, 1727461200000, 1727461500000, 1727461800000, 1727462100000, + 1727462400000, 1727462700000, 1727463000000, 1727463300000, 1727463600000, 1727463900000, 1727464200000, + 1727464500000, 1727464800000, 1727465100000, 1727465400000, 1727465700000, 1727466000000, 1727466300000, + 1727466600000, 1727466900000, 1727467200000, 1727467500000, 1727467800000, 1727468100000, 1727468400000, + 1727468700000, 1727469000000, 1727469300000, 1727469600000, 1727469900000, 1727470200000, 1727470500000, + 1727470800000, 1727471100000, 1727471400000, 1727471700000, 1727472000000, 1727472300000, 1727472600000, + 1727472900000, 1727473200000, 1727473500000, 1727473800000, 1727474100000, 1727474400000, 1727474700000, + 1727475000000, 1727475300000, 1727475600000, 1727475900000, 1727476200000, 1727476500000, 1727476800000, + 1727477100000, 1727477400000, 1727477700000, 1727478000000, 1727478300000, 1727478600000, 1727478900000, + 1727479200000, 1727479500000, 1727479800000, 1727480100000, 1727480400000, 1727480700000, 1727481000000, + 1727481300000 + ], + "y_series_1": [ + 73, 75, 74, 94, 90, 78, 64, 94, 50, 74, 56, 58, 73, 50, 93, 57, 73, 60, 66, 57, 84, 84, 82, 54, 91, 88, 90, 77, 56, + 58, 57, 61, 83, 82, 97, 72, 73, 86, 84, 93, 89, 71, 76, 84, 50, 84, 86, 96, 63, 52, 50, 54, 75, 63, 88, 76, 58, 64, + 64, 75, 91, 62, 81, 88, 98, 81, 53, 79, 86, 72, 88, 94, 64, 92, 78, 85, 62, 81, 56, 71, 77, 51, 91, 94, 55, 77, 77, + 93, 93, 69, 79, 60, 77, 74, 88, 82, 50, 76, 62, 90, 52, 88, 55, 57, 76, 58, 86, 82, 91, 93, 73, 64, 81, 81, 73, 90, + 98, 98, 61, 88, 51, 52, 98, 86, 98, 66, 98, 51, 51, 77, 72, 86, 81, 82, 50, 68, 51, 93, 75, 81, 55, 81, 53, 60, 66, + 87, 73, 54, 83, 55, 71, 60, 97, 65, 82, 58, 55, 65, 78, 52, 69, 85, 68, 75, 52, 68, 69, 81, 56, 90, 82, 89, 88, 67, + 89, 50, 60, 77, 74, 99, 72, 80, 79, 91, 84, 56, 65, 75, 97, 98, 51, 50, 97, 61, 54, 86, 81, 58, 90, 84, 68, 97, 65, + 52, 69, 73, 82, 73, 60, 98, 57, 85, 87, 89, 69, 84, 97, 74, 84, 74, 78, 67, 95, 67, 51, 84, 65, 90, 85, 82, 53, 82, + 63, 70, 97, 69, 57, 56, 52, 66, 82, 97, 61, 71, 71, 95, 79, 87, 87, 94, 57, 76, 76, 83, 70, 79, 82, 77, 96, 82, 54, + 97, 68, 53, 84, 98, 66, 93, 77, 79, 78, 95, 55, 84, 90, 86, 73, 78, 98, 95, 80, 84, 82, 70, 81, 72, 82, 52 + ], + "y_series_2": [ + 37, 44, 61, 50, 73, 77, 22, 59, 65, 43, 69, 51, 66, 41, 42, 21, 46, 61, 21, 45, 36, 59, 52, 28, 62, 73, 67, 58, 48, + 61, 74, 45, 54, 69, 44, 43, 32, 79, 77, 26, 76, 55, 64, 39, 20, 27, 65, 35, 33, 31, 70, 42, 34, 47, 53, 21, 51, 42, + 41, 70, 44, 77, 41, 77, 77, 41, 68, 71, 61, 25, 34, 73, 62, 79, 56, 52, 27, 72, 79, 63, 63, 24, 58, 23, 25, 64, 51, + 71, 49, 66, 54, 74, 59, 71, 35, 32, 69, 79, 61, 49, 38, 36, 75, 38, 47, 77, 74, 45, 56, 45, 72, 42, 28, 31, 72, 20, + 77, 77, 20, 66, 53, 51, 73, 67, 44, 59, 64, 72, 20, 35, 58, 24, 41, 48, 74, 22, 31, 45, 35, 70, 56, 41, 76, 48, 33, + 47, 24, 66, 68, 49, 65, 71, 24, 31, 35, 45, 45, 67, 40, 58, 55, 52, 49, 56, 42, 77, 29, 73, 24, 55, 53, 71, 50, 29, + 77, 38, 77, 51, 20, 75, 24, 64, 23, 35, 43, 35, 74, 21, 68, 47, 51, 46, 39, 43, 31, 69, 54, 79, 52, 52, 70, 62, 56, + 31, 22, 20, 52, 59, 29, 62, 63, 48, 32, 31, 50, 65, 21, 70, 69, 54, 42, 36, 45, 27, 48, 45, 29, 45, 53, 70, 60, 26, + 23, 77, 72, 69, 64, 30, 48, 75, 55, 44, 40, 76, 55, 29, 56, 28, 43, 54, 68, 54, 67, 55, 37, 68, 58, 51, 43, 42, 51, + 56, 31, 68, 74, 32, 42, 44, 54, 60, 49, 36, 68, 39, 67, 44, 41, 32, 78, 38, 68, 55, 31, 60, 38, 31, 28, 26 + ] +} diff --git a/src/UplotSolid.tsx b/src/UplotSolid.tsx new file mode 100644 index 0000000..b859e10 --- /dev/null +++ b/src/UplotSolid.tsx @@ -0,0 +1,70 @@ +import "uplot/dist/uPlot.min.css"; + +import { createEffect, type JSX, mergeProps, onCleanup, type ParentProps, splitProps, untrack } from "solid-js"; +import uPlot from "uplot"; + +type Props = uPlot.Options & { + readonly class?: string; + /** Callback when uPlot instance is created */ + readonly onCreate?: (u: uPlot, container: HTMLDivElement) => void; + /** Apply scale reset on redraw triggered by updated plot data (default: `true`) */ + readonly resetScales?: boolean; + readonly style?: JSX.CSSProperties | string; +}; + +const DEFAULT_PROPS = { + data: [] as uPlot.AlignedData, + resetScales: true, +}; + +const UplotSolid = (props: ParentProps) => { + let chartContainerRef!: HTMLDivElement; + + const mergedProps = mergeProps(DEFAULT_PROPS, props); + const [_props, options] = splitProps(mergedProps, [ + "class", + "children", + "data", + "height", + "width", + "onCreate", + "resetScales", + "style", + ]); + + const size = () => ({ width: _props.width, height: _props.height }); + + // Untrack size to avoid re-creating the chart on size changes + const uplotOptions = () => ({ ...options, ...untrack(size) }); + + createEffect(() => { + const chart = new uPlot( + uplotOptions(), + // Untrack data to avoid re-creating the chart on data changes + untrack(() => _props.data), + chartContainerRef, + ); + + _props.onCreate?.(chart, chartContainerRef); + + createEffect(() => { + chart.setSize(size()); + }); + + createEffect(() => { + chart.setData(_props.data, _props.resetScales); + }); + + onCleanup(() => { + chart.destroy(); + }); + }); + + return ( +
+ {_props.children} +
+ ); +}; + +export default UplotSolid; diff --git a/src/index.tsx b/src/index.tsx index 148fe81..960d42e 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,2 +1 @@ -// Main library export site -// Use playground app (via Vite) to test and document the library +export { default } from "./UplotSolid"; diff --git a/tsup.config.ts b/tsup.config.ts index 55ecfb1..0588e1d 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,3 +1,4 @@ +import inlineCssModules from "esbuild-css-modules-plugin"; import { defineConfig } from "tsup"; import * as preset from "tsup-preset-solid"; @@ -6,12 +7,11 @@ const generateSolidPresetOptions = (watching: boolean): preset.PresetOptions => { // entries with '.tsx' extension will have `solid` export condition generated entry: "src/index.tsx", - dev_entry: false, - server_entry: true, }, ], drop_console: !watching, // remove all `console.*` calls and `debugger` statements in prod builds cjs: false, + esbuild_plugins: [inlineCssModules()], }); export default defineConfig((config) => { @@ -28,7 +28,7 @@ export default defineConfig((config) => { const tsupOptions = preset .generateTsupOptions(parsedOptions) - .map((tsupOption) => ({ name: "solid-js", ...tsupOption })); + .map((tsupOption) => ({ name: "uplot-solid", ...tsupOption })); return tsupOptions; });