diff --git a/.changeset/axis-grid-stroke-fill-props.md b/.changeset/axis-grid-stroke-fill-props.md new file mode 100644 index 000000000..be3b22a44 --- /dev/null +++ b/.changeset/axis-grid-stroke-fill-props.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +feat: Add `stroke` and `fill` props to `Axis` and `Grid` for explicit color control (useful for SSR where CSS variables are unavailable) diff --git a/.changeset/quiet-rabbits-listen.md b/.changeset/quiet-rabbits-listen.md new file mode 100644 index 000000000..20af749ff --- /dev/null +++ b/.changeset/quiet-rabbits-listen.md @@ -0,0 +1,7 @@ +--- +'layerchart': patch +--- + +fix: improve compatibility with UnoCSS Svelte scoped preprocessing + +- Remove TypeScript-only `as` assertions from exported Svelte markup in core mark components so preprocessors that parse markup expressions as plain JavaScript can consume packaged components without failing diff --git a/.changeset/server-chart-rendering.md b/.changeset/server-chart-rendering.md new file mode 100644 index 000000000..b9cfd90b1 --- /dev/null +++ b/.changeset/server-chart-rendering.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat: Add `renderChart()` to `layerchart/server` for server-side chart-to-image rendering (PNG/JPEG) diff --git a/changes.md b/changes.md deleted file mode 100644 index 3d22602f8..000000000 --- a/changes.md +++ /dev/null @@ -1,197 +0,0 @@ -# Changes - -## Slots -> Snippets - -[Snippets](https://svelte.dev/docs/svelte/snippet) are a new feature in Svelte 5 that replaces slots with a more powerful, composable, and flexible API. Although snippets require a couple extra lines of code, they are more powerful, typeable, and flexible. - -So where before you would write something like this to insert some custom content into the "marks" slot: - -```svelte - - - - - -``` - -Today you write this: - -```svelte - - {#snippet marks({ series, getBarProps })} - - {/snippet} - -``` - -### children Snippet - -There are many components whose entire purpose is to provide context to other pieces of your greater chart component. The most prominent of which is the `` component. These exposed slot props have been replaced with snippet props to the `children` snippet of the component. - -For example, previously in LayerChart, the `` component exposed a _ton_ of individual slot props that you would access like so: - -```svelte - - - -``` - -Now, you can access all of these props via the `context` children snippet prop: - -```svelte - - {#snippet children({ context })} - - {/snippet} - -``` - -The other "contexts" that are setup by the `` are also included as separate props: - -```svelte - - {#snippet children({ context, geoContext, brushContext, transformContext, tooltipContext })} - - - - - - {/snippet} - -``` - -Each of these contexts provide their properties in the form of "getters", so to retain reactivity you should not destructure them from each context. - -For example, if you want to access the `width` of the chart, you should do it like this: - -```svelte -{#snippet children({ context })} -
{context.width}
-{/snippet} -``` - -Each of the components that previously exposed slot props now expose their props via the `children` snippet prop. - -```svelte - - {#snippet children({ gradient })} - - {/snippet} - -``` - -## Event Handlers/Callbacks - -All non-native browser event handlers have been renamed to camelCase to prevent confusion and conflicts with underlying browser events. For example, `onarcclick` is now `onArcClick`. - -## Context Methods - -You can retrieve any specific context within the `` tree by using its respective function: - -```ts -import { - getChartContext, - getTransformContext, - getTooltipContext, - getBrushContext, - getGeoContext, -} from 'layerchart'; - -// these are objects of getters, so destructuring them would lose reactivity -const chartCtx = getChartContext(); // chartCtx.width -const transformCtx = getTransformContext(); // transformCtx.mode -const tooltipCtx = getTooltipContext(); // tooltipCtx.data -const brushCtx = getBrushContext(); // brushCtx.range -const geoCtx = getGeoContext(); // geoCtx.projection -``` - -## Tooltip Payloads for Simplified Charts - -### The Problem - -While the existing tooltip data exposed via `data` from the `` children snippet is great because -it gives you the minimal information you need (just the data for the hovered item), it can be a bit cumbersome when you want -to display series data in the tooltip. - -This is handled out of the box with the various simplified charts, such as ``, ``, etc. However, if you opt-out -of the tooltips provided by the simplified charts, you're now stuck reinventing the wheel. - -### The Solution - -Both the simplified chart implementation as well as users' custom tooltip implementations can reap the same benefits from the simplified chart. - -A new `TooltipPayload` type has been added to the project that provides a more complete payload for tooltips when using the simplified charts. - -This is subject to adjustment as feedback is received, but the current payload looks like this: - -```ts -export type TooltipPayload = { - color?: string; - name?: string; - key: string; - label?: string; - value?: any; - keyAccessor?: Accessor; - valueAccessor?: Accessor; - labelAccessor?: Accessor; - chartType?: SimplifiedChartType; - // the original data point that was hovered over - // exactly the same as the data prop passed to the tooltip - payload: any; - rawSeriesData?: SeriesData; - formatter?: FormatType; -}; -``` - -This payload is passed to the `` `children` snippet via the `payload` prop alongside the existing `data` prop. - -This is accomplished by a context provided by the various simplified charts and then the `` handles building the payload based on that context. - -When not used with a simplified chart, the `payload` prop will be an array with a single object that contains the `data` via the object's `payload` property: - -```ts -console.log(payload); // [{ payload: { ...data }}] -``` - -When used with one of the simplified charts, it will have more information populated. - -## Classnames for all - -All underlying elements rendered by LayerChart _should_ now have an identifiable classname, prefixed with `lc-` to prevent conflicts with other libraries and your own styles (e.g. `lc-bar`, `lc-arc`, `lc-tooltip-context`, etc.) Perhaps we should document these somehow in the API reference, we'd need a source of truth and checks to ensure they do in fact exist though. - -This should make it easier to style the various elements included within the chart components. - -## Chart Context via Simplified Charts - -You can now get a reference to the `` context via the simplified charts by using `bind:context` on the simplified chart. -This is useful for getting the height, width, and other properties of the chart for motion, etc. - -```svelte - - - - - - - - -``` - -## New `motion` prop - -Previously, a lot of components exposed `tweened` or `spring` which gave the perception that you could have both tweened and spring animations. This was not the case, and the props were a bit misleading/confusing. - -The new `motion` prop is a single prop that accepts one of the following: - -```ts -export type SpringMotion = 'spring' | ({ type: 'spring' } & SpringOptions); -export type TweenMotion = 'tween' | ({ type: 'tween' } & TweenOptions); -export type NoneMotion = 'none' | { type: 'none' }; -``` - -This provides type safety for the various options should you need to customize them, while providing a shortcut to accept the defaults via `"spring"`, `"tween"`, or `"none"`. diff --git a/docs/package.json b/docs/package.json index d347cf3e1..9050ebd0e 100644 --- a/docs/package.json +++ b/docs/package.json @@ -38,6 +38,7 @@ "@layerstack/svelte-table": "1.0.1-next.18", "@layerstack/tailwind": "2.0.0-next.21", "@layerstack/utils": "2.0.0-next.18", + "@napi-rs/canvas": "^0.1.97", "@shikijs/transformers": "^4.0.2", "@sveltejs/adapter-cloudflare": "^7.2.8", "@sveltejs/kit": "^2.55.0", diff --git a/docs/src/content/guides/ssr-images.md b/docs/src/content/guides/ssr-images.md new file mode 100644 index 000000000..c803f8fa4 --- /dev/null +++ b/docs/src/content/guides/ssr-images.md @@ -0,0 +1,546 @@ +--- +title: SSR Images +description: Server-side render charts as PNG/JPEG images +category: advanced +--- + +LayerChart can render charts to PNG or JPEG images on the server using the `layerchart/server` module. This is useful for generating chart images for social cards, email embeds, Slack/Discord bots, PDF reports, or any context where an interactive chart isn't available. + +## How it works + +Server-side chart rendering uses three pieces: + +1. **``** — A wrapper around `` + `` that captures the component tree during SSR +2. **`renderChart()`** — Runs Svelte's SSR render, captures chart state, and paints the component tree onto a node canvas +3. **A canvas library** — Such as [`@napi-rs/canvas`](https://github.com/nicolo-ribaudo/napi-rs-canvas) to provide the `Canvas` and `Path2D` APIs on the server + +## Quick start + +### 1. Install dependencies + +```bash +npm install @napi-rs/canvas +``` + +### 2. Create a chart component + +Create a Svelte component using `` instead of ``: + +```svelte + + + + + + + + + +``` + +The key props are: + +- **`capture`** — An object that `ServerChart` populates with the chart state and component tree during SSR +- **`width` / `height`** — The output image dimensions in pixels + +### 3. Create a server endpoint + +```ts +// src/routes/api/chart/+server.ts +import { createCanvas, Path2D } from '@napi-rs/canvas'; +import { renderChart } from 'layerchart/server'; +import type { RequestHandler } from './$types'; +import MyLineChart from '$lib/charts/MyLineChart.svelte'; + +// Register Path2D globally (required once) +if (typeof globalThis.Path2D === 'undefined') { + (globalThis as any).Path2D = Path2D; +} + +const data = Array.from({ length: 50 }, (_, i) => ({ + date: i, + value: 50 + 30 * Math.sin(i / 5) +})); + +export const GET: RequestHandler = async ({ url }) => { + const width = Number(url.searchParams.get('width') ?? 800); + const height = Number(url.searchParams.get('height') ?? 400); + const format = url.searchParams.get('format') === 'jpeg' ? 'jpeg' : 'png'; + + const buffer = renderChart(MyLineChart, { + width, + height, + format, + props: { data }, + createCanvas: (w, h) => createCanvas(w, h) as any + }); + + return new Response(buffer, { + headers: { 'Content-Type': `image/${format}` } + }); +}; +``` + +The chart image is now available at `/api/chart` and supports query params: + +- `/api/chart` — 800x400 PNG (defaults) +- `/api/chart?width=1200&height=600` — custom size +- `/api/chart?format=jpeg` — JPEG output + +## API reference + +### `renderChart(component, options)` + +The simplest way to render a chart to an image buffer. Handles SSR render, capture, and canvas painting in one call. + +```ts +import { renderChart } from 'layerchart/server'; + +const buffer = renderChart(MyChart, { + width: 800, + height: 400, + props: { data: myData }, + createCanvas: (w, h) => createCanvas(w, h), + // Optional: + format: 'png', // 'png' | 'jpeg' + quality: 0.92, // JPEG quality (0-1) + devicePixelRatio: 2, // High-DPI output + background: 'white' // Background color (transparent by default) +}); +``` + +| Option | Type | Default | Description | +| ------------------ | --------------------- | ------- | ---------------------------------------------------------------------- | +| `width` | `number` | — | Image width in pixels | +| `height` | `number` | — | Image height in pixels | +| `props` | `Record` | `{}` | Additional props passed to the chart component | +| `createCanvas` | `(w, h) => Canvas` | — | Canvas factory (e.g. from `@napi-rs/canvas`) | +| `format` | `'png' \| 'jpeg'` | `'png'` | Output format | +| `quality` | `number` | `0.92` | JPEG quality | +| `devicePixelRatio` | `number` | `1` | Pixel ratio for high-DPI | +| `background` | `string` | — | Background fill color. Omit for transparent PNG. Recommended for JPEG. | + +### `renderCapturedChart(capture, options)` + +Lower-level function for advanced use cases where you need control over the SSR render step. See `createCaptureCallback()` for the capture workflow. + +### `` + +A wrapper component around `` + `` designed for server rendering. Accepts all `` props plus: + +| Prop | Type | Description | +| ----------- | ---------------- | -------------------------------------------- | +| `capture` | `CaptureTarget` | Object populated with chart state during SSR | +| `onCapture` | `(data) => void` | Callback alternative to the `capture` prop | + +## Examples + +These examples are rendered live from the API endpoints in this project. + +### Line chart + +```svelte + + + + + + +``` + +```html + +``` + +![Line chart](/api/charts/line) + +### Bar chart + +```svelte + + + + + + +``` + +```html + +``` + +![Bar chart](/api/charts/bar) + +### Area chart (multi-series) + +```svelte + + + + + + + + + +``` + +```html + +``` + +![Area chart](/api/charts/area) + +### Geo chart + +```svelte + + + + {#each states.features as feature (feature)} + + {/each} + +``` + +```html + +``` + +![Geo chart](/api/charts/geo) + +### Scatter chart + +```svelte + + + + + + +``` + +```html + +``` + +![Scatter chart](/api/charts/scatter) + +### Sankey chart + +```svelte + + d.id}> + {#snippet children({ links, nodes })} + {#each links as link} + + {/each} + {#each nodes as node (node.id)} + {@const nodeWidth = (node.x1 ?? 0) - (node.x0 ?? 0)} + {@const nodeHeight = (node.y1 ?? 0) - (node.y0 ?? 0)} + + + + + {/each} + {/snippet} + + +``` + +```html + +``` + +![Sankey chart](/api/charts/sankey) + +### Tree chart + +```svelte + + + {#snippet children({ nodes, links })} + {#each links as link} + + {/each} + {#each nodes as node} + + + + + {/each} + {/snippet} + + +``` + +```html + +``` + +![Tree chart](/api/charts/tree) + +### Treemap chart + +```svelte + + + {#snippet children({ nodes })} + {#each nodes as node} + {@const nodeWidth = node.x1 - node.x0} + {@const nodeHeight = node.y1 - node.y0} + {@const nodeColor = getNodeColor(node)} + + + + + {/each} + {/snippet} + + +``` + +```html + +``` + +![Treemap chart](/api/charts/treemap) + +## Supported components + +Server-side rendering works with components that have **canvas rendering support**. Most primitive and data mark components work: + +| Works | Component | +| ----- | ------------------------------------------------------------------------ | +| Yes | `Spline`, `Area`, `Line`, `Path`, `Rect`, `Circle`, `Ellipse`, `Polygon` | +| Yes | `Bars`, `Points`, `Group`, `Text` | +| Yes | `Axis`, `Grid`, `Rule` | +| Yes | `LinearGradient`, `RadialGradient`, `Pattern`, `ClipPath` | +| Yes | `GeoPath` (via `Path` canvas render) | +| No | `Tooltip`, `Legend`, `Highlight` (interactive/DOM) | + +> **Note:** CSS classes and Tailwind utilities don't apply on the server. Pass explicit `stroke` and `fill` props to `Axis`, `Grid`, and other components for control over colors. + +## Tips + +### Styling Axis and Grid + +Since CSS variables don't resolve on the server, `Axis` and `Grid` accept `stroke` and `fill` props that pass through to their child Lines and Text: + +```svelte + + + +``` + +- **`stroke`** — applied to grid lines, axis rule, tick marks, and tick label stroke +- **`fill`** — applied to tick labels and axis label text fill + +### Transparency and background + +PNG output is transparent by default. To add a solid background, use the `background` option: + +```ts +const buffer = renderChart(MyChart, { + width: 800, + height: 400, + background: 'white' + // ... +}); +``` + +JPEG does not support transparency and will render a black background unless you set `background`. Always set a background when using JPEG format. + +### High-DPI images + +Pass `devicePixelRatio: 2` for retina-quality output (doubles the canvas resolution): + +```ts +const buffer = renderChart(MyChart, { + width: 800, + height: 400, + devicePixelRatio: 2 + // ... +}); +``` + +### Cloudflare and edge runtimes + +Server-side chart rendering requires a **native Node.js canvas library** such as `@napi-rs/canvas`, `node-canvas`, or `skia-canvas`. These are native addons that do not run on edge runtimes like Cloudflare Workers. + +If you deploy to Cloudflare Pages (or similar edge platforms), **prerender your chart endpoints** so the images are generated at build time: + +```ts +// +server.ts +export const prerender = true; + +export const GET: RequestHandler = async ({ url }) => { + return renderChartResponse({ component: MyChart, props: { data }, url }); +}; +``` + +Prerendered endpoints become static files served directly from the CDN — no server-side canvas library needed at runtime. Note that query parameters (like `?width=1200`) are not available for prerendered routes, so bake any defaults into the endpoint code. + +### Inline styles only + +Since there is no DOM or CSS engine on the server, use inline style props (`fill`, `stroke`, `strokeWidth`, etc.) instead of CSS classes or Tailwind utilities. + +```svelte + + + + + +``` diff --git a/docs/src/routes/api/charts/area/+server.ts b/docs/src/routes/api/charts/area/+server.ts new file mode 100644 index 000000000..8d0435771 --- /dev/null +++ b/docs/src/routes/api/charts/area/+server.ts @@ -0,0 +1,19 @@ +import { renderChartResponse } from '../renderChartEndpoint.js'; +import type { RequestHandler } from './$types'; +import AreaChart from './AreaChart.svelte'; + +export const prerender = true; + +const data = Array.from({ length: 50 }, (_, i) => ({ + date: i, + value: 50 + 30 * Math.sin(i / 5) + 10 * Math.cos(i / 3), + value2: 70 + 20 * Math.cos(i / 4) + 15 * Math.sin(i / 7) +})); + +export const GET: RequestHandler = async ({ url }) => { + return renderChartResponse({ + component: AreaChart, + props: { data }, + url + }); +}; diff --git a/docs/src/routes/api/charts/area/AreaChart.svelte b/docs/src/routes/api/charts/area/AreaChart.svelte new file mode 100644 index 000000000..ce11a9d3d --- /dev/null +++ b/docs/src/routes/api/charts/area/AreaChart.svelte @@ -0,0 +1,39 @@ + + + + + + + + + + + diff --git a/docs/src/routes/api/charts/bar/+server.ts b/docs/src/routes/api/charts/bar/+server.ts new file mode 100644 index 000000000..80aa49516 --- /dev/null +++ b/docs/src/routes/api/charts/bar/+server.ts @@ -0,0 +1,24 @@ +import { renderChartResponse } from '../renderChartEndpoint.js'; +import type { RequestHandler } from './$types'; +import BarChart from './BarChart.svelte'; + +export const prerender = true; + +const data = [ + { category: 'A', value: 28 }, + { category: 'B', value: 55 }, + { category: 'C', value: 43 }, + { category: 'D', value: 91 }, + { category: 'E', value: 81 }, + { category: 'F', value: 53 }, + { category: 'G', value: 19 }, + { category: 'H', value: 67 } +]; + +export const GET: RequestHandler = async ({ url }) => { + return renderChartResponse({ + component: BarChart, + props: { data }, + url + }); +}; diff --git a/docs/src/routes/api/charts/bar/BarChart.svelte b/docs/src/routes/api/charts/bar/BarChart.svelte new file mode 100644 index 000000000..0b8d640b0 --- /dev/null +++ b/docs/src/routes/api/charts/bar/BarChart.svelte @@ -0,0 +1,38 @@ + + + + + + + + diff --git a/docs/src/routes/api/charts/geo/+server.ts b/docs/src/routes/api/charts/geo/+server.ts new file mode 100644 index 000000000..cd6b635c9 --- /dev/null +++ b/docs/src/routes/api/charts/geo/+server.ts @@ -0,0 +1,31 @@ +import { geoAlbersUsa } from 'd3-geo'; +import { feature } from 'topojson-client'; +import type { Topology, GeometryCollection } from 'topojson-specification'; +import { renderChartResponse } from '../renderChartEndpoint.js'; +import type { RequestHandler } from './$types'; +import GeoChart from './GeoChart.svelte'; + +export const prerender = true; + +let cachedStates: ReturnType | null = null; + +async function getStates(fetchFn: typeof fetch) { + if (cachedStates) return cachedStates; + const topology = (await fetchFn( + 'https://cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json' + ).then((r) => r.json())) as Topology<{ + states: GeometryCollection<{ name: string }>; + }>; + cachedStates = feature(topology, topology.objects.states); + return cachedStates; +} + +export const GET: RequestHandler = async ({ url, fetch }) => { + const states = await getStates(fetch); + + return renderChartResponse({ + component: GeoChart, + props: { states, projection: geoAlbersUsa }, + url + }); +}; diff --git a/docs/src/routes/api/charts/geo/GeoChart.svelte b/docs/src/routes/api/charts/geo/GeoChart.svelte new file mode 100644 index 000000000..aee82f1a0 --- /dev/null +++ b/docs/src/routes/api/charts/geo/GeoChart.svelte @@ -0,0 +1,43 @@ + + + + {#each states.features as feature (feature)} + + {/each} + diff --git a/docs/src/routes/api/charts/line/+server.ts b/docs/src/routes/api/charts/line/+server.ts new file mode 100644 index 000000000..3b7efc7f1 --- /dev/null +++ b/docs/src/routes/api/charts/line/+server.ts @@ -0,0 +1,18 @@ +import { renderChartResponse } from '../renderChartEndpoint.js'; +import type { RequestHandler } from './$types'; +import LineChart from './LineChart.svelte'; + +export const prerender = true; + +const data = Array.from({ length: 50 }, (_, i) => ({ + date: i, + value: 50 + 30 * Math.sin(i / 5) + 10 * Math.cos(i / 3) +})); + +export const GET: RequestHandler = async ({ url }) => { + return renderChartResponse({ + component: LineChart, + props: { data }, + url + }); +}; diff --git a/docs/src/routes/api/charts/line/LineChart.svelte b/docs/src/routes/api/charts/line/LineChart.svelte new file mode 100644 index 000000000..6280b5bf2 --- /dev/null +++ b/docs/src/routes/api/charts/line/LineChart.svelte @@ -0,0 +1,36 @@ + + + + + + + + diff --git a/docs/src/routes/api/charts/renderChartEndpoint.ts b/docs/src/routes/api/charts/renderChartEndpoint.ts new file mode 100644 index 000000000..bd0868f9e --- /dev/null +++ b/docs/src/routes/api/charts/renderChartEndpoint.ts @@ -0,0 +1,51 @@ +import { building } from '$app/environment'; +import { createCanvas, Path2D } from '@napi-rs/canvas'; +import { renderChart } from 'layerchart/server'; +import type { CanvasFactory } from 'layerchart/server'; +import type { Component } from 'svelte'; + +// Register Path2D globally for canvas rendering +if (typeof globalThis.Path2D === 'undefined') { + (globalThis as any).Path2D = Path2D; +} + +const createNodeCanvas: CanvasFactory = (canvasWidth, canvasHeight) => + createCanvas(canvasWidth, canvasHeight) as unknown as ReturnType; + +function getParam(url: URL, name: string): string | null { + if (building) return null; + return url.searchParams.get(name); +} + +type RenderChartResponseOptions = { + component: Component; + props: Record; + url: URL; +}; + +export function renderChartResponse({ + component, + props, + url +}: RenderChartResponseOptions): Response { + const width = Number(getParam(url, 'width') ?? 800); + const height = Number(getParam(url, 'height') ?? 400); + const format = getParam(url, 'format') === 'jpeg' ? 'jpeg' : 'png'; + const background = getParam(url, 'background') ?? 'white'; + + const buffer = renderChart(component, { + width, + height, + format, + background, + props, + createCanvas: createNodeCanvas + }); + + return new Response(buffer as any, { + headers: { + 'Content-Type': `image/${format}`, + 'Cache-Control': 'public, max-age=3600' + } + }); +} diff --git a/docs/src/routes/api/charts/sankey/+server.ts b/docs/src/routes/api/charts/sankey/+server.ts new file mode 100644 index 000000000..2d98b8c46 --- /dev/null +++ b/docs/src/routes/api/charts/sankey/+server.ts @@ -0,0 +1,45 @@ +import { renderChartResponse } from '../renderChartEndpoint.js'; +import type { RequestHandler } from './$types'; +import SankeyChart from './SankeyChart.svelte'; + +export const prerender = true; + +const data = { + nodes: [ + { id: 'A1' }, + { id: 'A2' }, + { id: 'A3' }, + { id: 'B1' }, + { id: 'B2' }, + { id: 'B3' }, + { id: 'B4' }, + { id: 'C1' }, + { id: 'C2' }, + { id: 'C3' }, + { id: 'D1' }, + { id: 'D2' } + ], + links: [ + { source: 'A1', target: 'B1', value: 27 }, + { source: 'A1', target: 'B2', value: 9 }, + { source: 'A2', target: 'B2', value: 5 }, + { source: 'A2', target: 'B3', value: 11 }, + { source: 'A3', target: 'B2', value: 12 }, + { source: 'A3', target: 'B4', value: 7 }, + { source: 'B1', target: 'C1', value: 13 }, + { source: 'B1', target: 'C2', value: 10 }, + { source: 'B4', target: 'C2', value: 5 }, + { source: 'B4', target: 'C3', value: 2 }, + { source: 'B1', target: 'D1', value: 4 }, + { source: 'C3', target: 'D1', value: 1 }, + { source: 'C3', target: 'D2', value: 1 } + ] +}; + +export const GET: RequestHandler = async ({ url }) => { + return renderChartResponse({ + component: SankeyChart, + props: { data }, + url + }); +}; diff --git a/docs/src/routes/api/charts/sankey/SankeyChart.svelte b/docs/src/routes/api/charts/sankey/SankeyChart.svelte new file mode 100644 index 000000000..8c92195f5 --- /dev/null +++ b/docs/src/routes/api/charts/sankey/SankeyChart.svelte @@ -0,0 +1,50 @@ + + + + d.id}> + {#snippet children({ links, nodes })} + {#each links as link ([link.value, link.source.id, link.target.id].join('-'))} + + {/each} + {#each nodes as node (node.id)} + {@const nodeWidth = (node.x1 ?? 0) - (node.x0 ?? 0)} + {@const nodeHeight = (node.y1 ?? 0) - (node.y0 ?? 0)} + + + + + {/each} + {/snippet} + + diff --git a/docs/src/routes/api/charts/scatter/+server.ts b/docs/src/routes/api/charts/scatter/+server.ts new file mode 100644 index 000000000..a6156aca3 --- /dev/null +++ b/docs/src/routes/api/charts/scatter/+server.ts @@ -0,0 +1,24 @@ +import { renderChartResponse } from '../renderChartEndpoint.js'; +import type { RequestHandler } from './$types'; +import ScatterChart from './ScatterChart.svelte'; + +export const prerender = true; + +// Generate clustered scatter data +const random = (seed: number) => { + const x = Math.sin(seed) * 10000; + return x - Math.floor(x); +}; + +const data = Array.from({ length: 100 }, (_, i) => ({ + x: random(i * 3 + 1) * 100, + y: random(i * 3 + 2) * 100 +})); + +export const GET: RequestHandler = async ({ url }) => { + return renderChartResponse({ + component: ScatterChart, + props: { data }, + url + }); +}; diff --git a/docs/src/routes/api/charts/scatter/ScatterChart.svelte b/docs/src/routes/api/charts/scatter/ScatterChart.svelte new file mode 100644 index 000000000..93cf2cd84 --- /dev/null +++ b/docs/src/routes/api/charts/scatter/ScatterChart.svelte @@ -0,0 +1,35 @@ + + + + + + + + diff --git a/docs/src/routes/api/charts/tree/+server.ts b/docs/src/routes/api/charts/tree/+server.ts new file mode 100644 index 000000000..7cc30208a --- /dev/null +++ b/docs/src/routes/api/charts/tree/+server.ts @@ -0,0 +1,47 @@ +import { renderChartResponse } from '../renderChartEndpoint.js'; +import type { RequestHandler } from './$types'; +import TreeChart from './TreeChart.svelte'; + +export const prerender = true; + +const data = { + name: 'root', + children: [ + { + name: 'A', + children: [ + { name: 'A1' }, + { name: 'A2' }, + { name: 'A3' } + ] + }, + { + name: 'B', + children: [ + { + name: 'B1', + children: [ + { name: 'B1a' }, + { name: 'B1b' } + ] + }, + { name: 'B2' } + ] + }, + { + name: 'C', + children: [ + { name: 'C1' }, + { name: 'C2' } + ] + } + ] +}; + +export const GET: RequestHandler = async ({ url }) => { + return renderChartResponse({ + component: TreeChart, + props: { data }, + url + }); +}; diff --git a/docs/src/routes/api/charts/tree/TreeChart.svelte b/docs/src/routes/api/charts/tree/TreeChart.svelte new file mode 100644 index 000000000..6b6763c87 --- /dev/null +++ b/docs/src/routes/api/charts/tree/TreeChart.svelte @@ -0,0 +1,75 @@ + + + + + {#snippet children({ nodes, links })} + {#each links as link (link.source.data.name + '_' + link.target.data.name)} + + {/each} + + {#each nodes as node (node.data.name + node.depth)} + + + + + {/each} + {/snippet} + + diff --git a/docs/src/routes/api/charts/treemap/+server.ts b/docs/src/routes/api/charts/treemap/+server.ts new file mode 100644 index 000000000..1df98e22d --- /dev/null +++ b/docs/src/routes/api/charts/treemap/+server.ts @@ -0,0 +1,63 @@ +import { renderChartResponse } from '../renderChartEndpoint.js'; +import type { RequestHandler } from './$types'; +import TreemapChart from './TreemapChart.svelte'; + +export const prerender = true; + +const data = { + name: 'World', + children: [ + { + name: 'Europe', + children: [ + { name: 'Western Europe', value: 200 }, + { name: 'Southern Europe', value: 151 }, + { name: 'Eastern Europe', value: 284 }, + { name: 'Northern Europe', value: 109 } + ] + }, + { + name: 'Asia', + children: [ + { name: 'East Asia', value: 1652 }, + { name: 'South Asia', value: 2085 }, + { name: 'Southeast Asia', value: 700 }, + { name: 'Western Asia', value: 314 }, + { name: 'Central Asia', value: 84 } + ] + }, + { + name: 'North America', + children: [ + { name: 'Northern America', value: 388 }, + { name: 'Central America', value: 184 } + ] + }, + { + name: 'South America', + children: [{ name: 'South America', value: 434 }] + }, + { + name: 'Africa', + children: [ + { name: 'Western Africa', value: 467 }, + { name: 'Southern Africa', value: 74 }, + { name: 'Northern Africa', value: 276 }, + { name: 'Eastern Africa', value: 513 }, + { name: 'Middle Africa', value: 220 } + ] + }, + { + name: 'Oceania', + children: [{ name: 'Oceania', value: 47 }] + } + ] +}; + +export const GET: RequestHandler = async ({ url }) => { + return renderChartResponse({ + component: TreemapChart, + props: { data }, + url + }); +}; diff --git a/docs/src/routes/api/charts/treemap/TreemapChart.svelte b/docs/src/routes/api/charts/treemap/TreemapChart.svelte new file mode 100644 index 000000000..9d908151b --- /dev/null +++ b/docs/src/routes/api/charts/treemap/TreemapChart.svelte @@ -0,0 +1,69 @@ + + + + + {#snippet children({ nodes })} + {#each nodes as node} + {@const nodeWidth = node.x1 - node.x0} + {@const nodeHeight = node.y1 - node.y0} + {@const nodeColor = getNodeColor(node)} + + + + + + + {/each} + {/snippet} + + diff --git a/docs/vite.config.ts b/docs/vite.config.ts index e715f145c..9f732a3de 100644 --- a/docs/vite.config.ts +++ b/docs/vite.config.ts @@ -104,6 +104,7 @@ export default defineConfig({ 'shiki', '@shikijs/langs', '@shikijs/themes', + '@napi-rs/canvas', ] } }); diff --git a/examples/daisyui-5/package.json b/examples/daisyui-5/package.json index 947be7366..81dfd130c 100644 --- a/examples/daisyui-5/package.json +++ b/examples/daisyui-5/package.json @@ -19,7 +19,7 @@ "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/vite": "^4.2.2", "daisyui": "^5.5.19", - "layerchart": "next", + "layerchart": "workspace:*", "mode-watcher": "^1.1.0", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.5.1", diff --git a/examples/shadcn-svelte-1/package.json b/examples/shadcn-svelte-1/package.json index 3eaf11749..3a8126d42 100644 --- a/examples/shadcn-svelte-1/package.json +++ b/examples/shadcn-svelte-1/package.json @@ -22,7 +22,7 @@ "@tailwindcss/vite": "^4.2.2", "bits-ui": "^2.16.3", "clsx": "^2.1.1", - "layerchart": "next", + "layerchart": "workspace:*", "mode-watcher": "^1.1.0", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.5.1", diff --git a/examples/skeleton-3/package.json b/examples/skeleton-3/package.json index 3b8a47900..bf44a96e7 100644 --- a/examples/skeleton-3/package.json +++ b/examples/skeleton-3/package.json @@ -20,7 +20,7 @@ "@sveltejs/kit": "^2.55.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/vite": "^4.2.2", - "layerchart": "next", + "layerchart": "workspace:*", "mode-watcher": "^1.1.0", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.5.1", diff --git a/examples/skeleton-4/package.json b/examples/skeleton-4/package.json index d4dcf68dd..8bb8dfd91 100644 --- a/examples/skeleton-4/package.json +++ b/examples/skeleton-4/package.json @@ -20,7 +20,7 @@ "@sveltejs/kit": "^2.55.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/vite": "^4.2.2", - "layerchart": "next", + "layerchart": "workspace:*", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.5.1", "prettier-plugin-tailwindcss": "^0.7.2", diff --git a/examples/standalone/package.json b/examples/standalone/package.json index 0eac32545..e5a8cc740 100644 --- a/examples/standalone/package.json +++ b/examples/standalone/package.json @@ -17,7 +17,7 @@ "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/kit": "^2.55.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", - "layerchart": "next", + "layerchart": "workspace:*", "mode-watcher": "^1.1.0", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.5.1", diff --git a/examples/svelte-ux-2/package.json b/examples/svelte-ux-2/package.json index b119d1fc2..3cb0a59c5 100644 --- a/examples/svelte-ux-2/package.json +++ b/examples/svelte-ux-2/package.json @@ -19,7 +19,7 @@ "@sveltejs/kit": "^2.55.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/vite": "^4.2.2", - "layerchart": "next", + "layerchart": "workspace:*", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.5.1", "prettier-plugin-tailwindcss": "^0.7.2", diff --git a/examples/unocss-1/package.json b/examples/unocss-1/package.json index 8975f8f9c..a7440f93f 100644 --- a/examples/unocss-1/package.json +++ b/examples/unocss-1/package.json @@ -19,7 +19,7 @@ "@sveltejs/vite-plugin-svelte": "^6.0.0", "@unocss/preset-wind4": "^66.6.7", "@unocss/svelte-scoped": "^66.6.7", - "layerchart": "next", + "layerchart": "workspace:*", "mode-watcher": "^1.1.0", "prettier": "^3.8.1", "prettier-plugin-svelte": "^3.5.1", diff --git a/packages/layerchart/package.json b/packages/layerchart/package.json index a7fe54369..e229756e7 100644 --- a/packages/layerchart/package.json +++ b/packages/layerchart/package.json @@ -28,6 +28,7 @@ }, "devDependencies": { "@changesets/cli": "^2.30.0", + "@napi-rs/canvas": "^0.1.97", "@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/kit": "^2.55.0", "@sveltejs/package": "^2.5.7", @@ -109,6 +110,11 @@ "svelte": "./dist/index.js", "default": "./dist/index.js" }, + "./server": { + "types": "./dist/server/index.d.ts", + "svelte": "./dist/server/index.js", + "default": "./dist/server/index.js" + }, "./utils/*": { "types": "./dist/utils/*.d.ts", "svelte": "./dist/utils/*.js", diff --git a/packages/layerchart/src/lib/components/Axis.svelte b/packages/layerchart/src/lib/components/Axis.svelte index bbaf7932a..5e72e4ca1 100644 --- a/packages/layerchart/src/lib/components/Axis.svelte +++ b/packages/layerchart/src/lib/components/Axis.svelte @@ -101,6 +101,18 @@ */ scale?: any; + /** + * Stroke color for axis rule, grid lines, and tick marks. + * Useful for server-side rendering where CSS variables are not available. + */ + stroke?: string; + + /** + * Fill color for tick labels and axis label. + * Useful for server-side rendering where CSS variables are not available. + */ + fill?: string; + /** * Classes for styling various parts of the axis * @default {} @@ -156,6 +168,8 @@ tickMarks = true, format, tickLabelProps, + stroke, + fill, motion, transitionIn, transitionInParams, @@ -168,6 +182,8 @@ const ctx = getChartContext(); + ctx.registerComponent({ name: 'Axis', kind: 'composite-mark' }); + const orientation = $derived( placement === 'angle' ? 'angle' @@ -465,6 +481,8 @@ // complement 10px text (until Text supports custom styles) capHeight: '7px', lineHeight: '11px', + fill, + stroke, ...labelProps, class: cls('lc-axis-label', classes.label, labelProps?.class), }) satisfies ComponentProps; @@ -479,6 +497,7 @@ @@ -506,6 +525,8 @@ // complement 10px text (until Text supports custom styles) capHeight: '7px', lineHeight: '11px', + fill, + stroke, ...tickLabelProps, class: cls('lc-axis-tick-label', classes.tickLabel, tickLabelProps?.class), }} @@ -515,6 +536,7 @@ @@ -528,6 +550,7 @@ y1={tickCoords.y} x2={tickCoords.x} y2={tickCoords.y + (placement === 'top' ? -tickLength : tickLength)} + {stroke} {motion} class={tickClasses} /> @@ -537,6 +560,7 @@ y1={tickCoords.y} x2={tickCoords.x + (placement === 'left' ? -tickLength : tickLength)} y2={tickCoords.y} + {stroke} {motion} class={tickClasses} /> @@ -546,6 +570,7 @@ y1={radialTickCoordsY} x2={radialTickMarkCoordsX} y2={radialTickMarkCoordsY} + {stroke} {motion} class={tickClasses} /> diff --git a/packages/layerchart/src/lib/components/Circle.svelte b/packages/layerchart/src/lib/components/Circle.svelte index f76231656..4b44c5133 100644 --- a/packages/layerchart/src/lib/components/Circle.svelte +++ b/packages/layerchart/src/lib/components/Circle.svelte @@ -95,7 +95,13 @@ import { untrack } from 'svelte'; import { createMotion, createDataMotionMap, type MotionProp } from '$lib/utils/motion.svelte.js'; import { renderCircle, type ComputedStylesOptions } from '$lib/utils/canvas.js'; - import { hasAnyDataProp, resolveDataProp, resolveColorProp, resolveGeoDataPair, resolveStyleProp } from '$lib/utils/dataProp.js'; + import { + hasAnyDataProp, + resolveDataProp, + resolveColorProp, + resolveGeoDataPair, + resolveStyleProp, + } from '$lib/utils/dataProp.js'; import { getGeoContext } from '$lib/contexts/geo.js'; import { chartDataArray } from '$lib/utils/common.js'; import type { SVGAttributes } from 'svelte/elements'; @@ -130,9 +136,7 @@ const geo = getGeoContext(); // Data to iterate over in data mode - const resolvedData: any[] = $derived( - dataMode ? (dataProp ?? chartDataArray(chartCtx.data)) : [] - ); + const resolvedData: any[] = $derived(dataMode ? (dataProp ?? chartDataArray(chartCtx.data)) : []); // Resolve a single data item to pixel coordinates function resolveCircle(d: any) { @@ -201,21 +205,16 @@ const layerCtx = getLayerContext(); - const motionCx = createMotion( - initialCx, - () => (typeof cx === 'number' ? cx : 0), - motion - ); - const motionCy = createMotion( - initialCy, - () => (typeof cy === 'number' ? cy : 0), - motion - ); - const motionR = createMotion( - initialR, - () => (typeof r === 'number' ? r : 1), - motion - ); + const motionCx = createMotion(initialCx, () => (typeof cx === 'number' ? cx : 0), motion); + const motionCy = createMotion(initialCy, () => (typeof cy === 'number' ? cy : 0), motion); + const motionR = createMotion(initialR, () => (typeof r === 'number' ? r : 1), motion); + + const staticFill = $derived(typeof fill === 'string' ? fill : undefined); + const staticFillOpacity = $derived(typeof fillOpacity === 'number' ? fillOpacity : undefined); + const staticStroke = $derived(typeof stroke === 'string' ? stroke : undefined); + const staticStrokeWidth = $derived(typeof strokeWidth === 'number' ? strokeWidth : undefined); + const staticOpacity = $derived(typeof opacity === 'number' ? opacity : undefined); + const staticClassName = $derived(typeof className === 'string' ? className : undefined); // Style options (shared between pixel and data mode) function getStyleOptions( @@ -228,16 +227,29 @@ itemClass?: string | undefined ) { return styleOverrides - ? merge({ styles: { strokeWidth: itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined) } }, styleOverrides) + ? merge( + { + styles: { + strokeWidth: + itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined), + }, + }, + styleOverrides + ) : { styles: { fill: itemFill ?? fill, - fillOpacity: itemFillOpacity ?? (typeof fillOpacity === 'number' ? fillOpacity : undefined), + fillOpacity: + itemFillOpacity ?? (typeof fillOpacity === 'number' ? fillOpacity : undefined), stroke: itemStroke ?? stroke, - strokeWidth: itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined), + strokeWidth: + itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined), opacity: itemOpacity ?? (typeof opacity === 'number' ? opacity : undefined), }, - classes: cls('lc-circle', itemClass ?? (typeof className === 'string' ? className : undefined)), + classes: cls( + 'lc-circle', + itemClass ?? (typeof className === 'string' ? className : undefined) + ), style: restProps.style as string | undefined, }; } @@ -254,7 +266,15 @@ const resolvedStrokeWidth = resolveStyleProp(strokeWidth, item.d); const resolvedOpacity = resolveStyleProp(opacity, item.d); const resolvedClass = resolveStyleProp(className, item.d); - const styleOpts = getStyleOptions(styleOverrides, resolvedFill, resolvedStroke, resolvedFillOpacity, resolvedStrokeWidth, resolvedOpacity, resolvedClass); + const styleOpts = getStyleOptions( + styleOverrides, + resolvedFill, + resolvedStroke, + resolvedFillOpacity, + resolvedStrokeWidth, + resolvedOpacity, + resolvedClass + ); renderCircle(ctx, item, styleOpts); } } else { @@ -284,30 +304,33 @@ color: typeof fill === 'string' ? fill : undefined, }; }, - canvasRender: layerCtx === 'canvas' ? { - render, - events: { - click: restProps.onclick, - pointerdown: restProps.onpointerdown, - pointerenter: restProps.onpointerenter, - pointermove: restProps.onpointermove, - pointerleave: restProps.onpointerleave, - }, - deps: () => [ - dataMode, - dataMode ? resolvedItems : null, - motionCx.current, - motionCy.current, - motionR.current, - fillKey!.current, - fillOpacity, - strokeKey!.current, - strokeWidth, - opacity, - className, - restProps.style, - ], - } : undefined, + canvasRender: + layerCtx === 'canvas' + ? { + render, + events: { + click: restProps.onclick, + pointerdown: restProps.onpointerdown, + pointerenter: restProps.onpointerenter, + pointermove: restProps.onpointermove, + pointerleave: restProps.onpointerleave, + }, + deps: () => [ + dataMode, + dataMode ? resolvedItems : null, + motionCx.current, + motionCy.current, + motionR.current, + fillKey!.current, + fillOpacity, + strokeKey!.current, + strokeWidth, + opacity, + className, + restProps.style, + ], + } + : undefined, }); @@ -339,12 +362,12 @@ cx={motionCx.current} cy={motionCy.current} r={motionR.current} - fill={fill as string} - fill-opacity={fillOpacity as number} - stroke={stroke as string} - stroke-width={strokeWidth as number} - opacity={opacity as number} - class={cls('lc-circle', className as string)} + fill={staticFill} + fill-opacity={staticFillOpacity} + stroke={staticStroke} + stroke-width={staticStrokeWidth} + opacity={staticOpacity} + class={cls('lc-circle', staticClassName)} {...restProps} /> {/if} @@ -382,13 +405,13 @@ style:width="{motionR.current * 2}px" style:height="{motionR.current * 2}px" style:border-radius="50%" - style:background-color={fill as string} - style:opacity={opacity as number} - style:border-width={strokeWidth as number} - style:border-color={stroke as string} + style:background-color={staticFill} + style:opacity={staticOpacity} + style:border-width={staticStrokeWidth} + style:border-color={staticStroke} style:border-style="solid" style:transform="translate(-50%, -50%)" - class={cls('lc-circle', className as string)} + class={cls('lc-circle', staticClassName)} {...restProps} > {@render children?.()} diff --git a/packages/layerchart/src/lib/components/Ellipse.svelte b/packages/layerchart/src/lib/components/Ellipse.svelte index 6f4f2a90e..5e7dd060e 100644 --- a/packages/layerchart/src/lib/components/Ellipse.svelte +++ b/packages/layerchart/src/lib/components/Ellipse.svelte @@ -108,7 +108,13 @@ import { getChartContext } from '$lib/contexts/chart.js'; import { createMotion, createDataMotionMap, type MotionProp } from '$lib/utils/motion.svelte.js'; import { renderEllipse, type ComputedStylesOptions } from '$lib/utils/canvas.js'; - import { hasAnyDataProp, resolveDataProp, resolveColorProp, resolveGeoDataPair, resolveStyleProp } from '$lib/utils/dataProp.js'; + import { + hasAnyDataProp, + resolveDataProp, + resolveColorProp, + resolveGeoDataPair, + resolveStyleProp, + } from '$lib/utils/dataProp.js'; import { getGeoContext } from '$lib/contexts/geo.js'; import { chartDataArray } from '$lib/utils/common.js'; import type { SVGAttributes } from 'svelte/elements'; @@ -144,9 +150,7 @@ const geo = getGeoContext(); // Data to iterate over in data mode - const resolvedData: any[] = $derived( - dataMode ? (dataProp ?? chartDataArray(chartCtx.data)) : [] - ); + const resolvedData: any[] = $derived(dataMode ? (dataProp ?? chartDataArray(chartCtx.data)) : []); // Resolve a single data item to pixel coordinates function resolveEllipse(d: any) { @@ -217,26 +221,10 @@ const layerCtx = getLayerContext(); - const motionCx = createMotion( - initialCx, - () => (typeof cx === 'number' ? cx : 0), - motion - ); - const motionCy = createMotion( - initialCy, - () => (typeof cy === 'number' ? cy : 0), - motion - ); - const motionRx = createMotion( - initialRx, - () => (typeof rx === 'number' ? rx : 1), - motion - ); - const motionRy = createMotion( - initialRy, - () => (typeof ry === 'number' ? ry : 1), - motion - ); + const motionCx = createMotion(initialCx, () => (typeof cx === 'number' ? cx : 0), motion); + const motionCy = createMotion(initialCy, () => (typeof cy === 'number' ? cy : 0), motion); + const motionRx = createMotion(initialRx, () => (typeof rx === 'number' ? rx : 1), motion); + const motionRy = createMotion(initialRy, () => (typeof ry === 'number' ? ry : 1), motion); function getStyleOptions( styleOverrides: ComputedStylesOptions | undefined, @@ -248,16 +236,29 @@ itemClass?: string | undefined ) { return styleOverrides - ? merge({ styles: { strokeWidth: itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined) } }, styleOverrides) + ? merge( + { + styles: { + strokeWidth: + itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined), + }, + }, + styleOverrides + ) : { styles: { fill: itemFill ?? fill, - fillOpacity: itemFillOpacity ?? (typeof fillOpacity === 'number' ? fillOpacity : undefined), + fillOpacity: + itemFillOpacity ?? (typeof fillOpacity === 'number' ? fillOpacity : undefined), stroke: itemStroke ?? stroke, - strokeWidth: itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined), + strokeWidth: + itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined), opacity: itemOpacity ?? (typeof opacity === 'number' ? opacity : undefined), }, - classes: cls('lc-ellipse', itemClass ?? (typeof className === 'string' ? className : undefined)), + classes: cls( + 'lc-ellipse', + itemClass ?? (typeof className === 'string' ? className : undefined) + ), }; } @@ -273,7 +274,15 @@ const resolvedStrokeWidth = resolveStyleProp(strokeWidth, item.d); const resolvedOpacity = resolveStyleProp(opacity, item.d); const resolvedClass = resolveStyleProp(className, item.d); - const styleOpts = getStyleOptions(styleOverrides, resolvedFill, resolvedStroke, resolvedFillOpacity, resolvedStrokeWidth, resolvedOpacity, resolvedClass); + const styleOpts = getStyleOptions( + styleOverrides, + resolvedFill, + resolvedStroke, + resolvedFillOpacity, + resolvedStrokeWidth, + resolvedOpacity, + resolvedClass + ); renderEllipse(ctx, item, styleOpts); } } else { @@ -295,6 +304,13 @@ const fillKey = layerCtx === 'canvas' ? createKey(() => fill) : undefined; const strokeKey = layerCtx === 'canvas' ? createKey(() => stroke) : undefined; + const staticFill = $derived(typeof fill === 'string' ? fill : undefined); + const staticFillOpacity = $derived(typeof fillOpacity === 'number' ? fillOpacity : undefined); + const staticStroke = $derived(typeof stroke === 'string' ? stroke : undefined); + const staticStrokeWidth = $derived(typeof strokeWidth === 'number' ? strokeWidth : undefined); + const staticOpacity = $derived(typeof opacity === 'number' ? opacity : undefined); + const staticClassName = $derived(typeof className === 'string' ? className : undefined); + chartCtx.registerComponent({ name: 'Ellipse', kind: 'mark', @@ -307,30 +323,33 @@ color: typeof fill === 'string' ? fill : typeof stroke === 'string' ? stroke : undefined, }; }, - canvasRender: layerCtx === 'canvas' ? { - render, - events: { - click: restProps.onclick, - pointerdown: restProps.onpointerdown, - pointerenter: restProps.onpointerenter, - pointermove: restProps.onpointermove, - pointerleave: restProps.onpointerleave, - }, - deps: () => [ - dataMode, - dataMode ? resolvedItems : null, - motionCx.current, - motionCy.current, - motionRx.current, - motionRy.current, - fillKey!.current, - fillOpacity, - strokeKey!.current, - strokeWidth, - opacity, - className, - ], - } : undefined, + canvasRender: + layerCtx === 'canvas' + ? { + render, + events: { + click: restProps.onclick, + pointerdown: restProps.onpointerdown, + pointerenter: restProps.onpointerenter, + pointermove: restProps.onpointermove, + pointerleave: restProps.onpointerleave, + }, + deps: () => [ + dataMode, + dataMode ? resolvedItems : null, + motionCx.current, + motionCy.current, + motionRx.current, + motionRy.current, + fillKey!.current, + fillOpacity, + strokeKey!.current, + strokeWidth, + opacity, + className, + ], + } + : undefined, }); @@ -364,12 +383,12 @@ cy={motionCy.current} rx={motionRx.current} ry={motionRy.current} - fill={fill as string} - fill-opacity={fillOpacity as number} - stroke={stroke as string} - stroke-width={strokeWidth as number} - opacity={opacity as number} - class={cls('lc-ellipse', className as string)} + fill={staticFill} + fill-opacity={staticFillOpacity} + stroke={staticStroke} + stroke-width={staticStrokeWidth} + opacity={staticOpacity} + class={cls('lc-ellipse', staticClassName)} {...restProps} /> {/if} @@ -407,13 +426,13 @@ style:width="{motionRx.current * 2}px" style:height="{motionRy.current * 2}px" style:border-radius="50%" - style:background-color={fill as string} - style:opacity={opacity as number} - style:border-width={strokeWidth as number} - style:border-color={stroke as string} + style:background-color={staticFill} + style:opacity={staticOpacity} + style:border-width={staticStrokeWidth} + style:border-color={staticStroke} style:border-style="solid" style:transform="translate(-50%, -50%)" - class={cls('lc-ellipse', className as string)} + class={cls('lc-ellipse', staticClassName)} {...restProps} > {/if} diff --git a/packages/layerchart/src/lib/components/Grid.svelte b/packages/layerchart/src/lib/components/Grid.svelte index 003eea7ab..1ef047534 100644 --- a/packages/layerchart/src/lib/components/Grid.svelte +++ b/packages/layerchart/src/lib/components/Grid.svelte @@ -44,6 +44,12 @@ */ radialY?: 'circle' | 'linear'; + /** + * Stroke color for grid lines. + * Useful for server-side rendering where CSS variables are not available. + */ + stroke?: string; + /** * Classes to apply to the rendered elements. * @@ -113,6 +119,7 @@ yTicks: yTicksProp, bandAlign = 'center', radialY = 'circle', + stroke, motion, transitionIn: transitionInProp, transitionInParams = { easing: cubicIn }, @@ -168,6 +175,7 @@ {y1} {x2} {y2} + {stroke} motion={tweenConfig} {...splineProps} class={cls('lc-grid-x-radial-line', classes.line, splineProps?.class)} @@ -176,6 +184,7 @@ ({ x, y }))} x="x" y="y" + {stroke} motion={tweenConfig} curve={curveLinearClosed} {...splineProps} @@ -226,6 +238,7 @@ y1={ctx.yScale(y) + yBandOffset} x2={ctx.xRange[1]} y2={ctx.yScale(y) + yBandOffset} + {stroke} {motion} {...splineProps} class={cls('lc-grid-y-rule', classes.line, splineProps?.class)} @@ -238,6 +251,7 @@ {#if ctx.radial} {/each} {:else} @@ -532,7 +532,7 @@ style:object-fit="cover" crossorigin={crossOrigin} class={cls('lc-image', className)} - {...restProps} + {...restProps as any} /> {/if} {/if} diff --git a/packages/layerchart/src/lib/components/Line.svelte b/packages/layerchart/src/lib/components/Line.svelte index 3b216c922..b86e6fbea 100644 --- a/packages/layerchart/src/lib/components/Line.svelte +++ b/packages/layerchart/src/lib/components/Line.svelte @@ -125,7 +125,13 @@ import { getLayerContext } from '$lib/contexts/layer.js'; import { getChartContext } from '$lib/contexts/chart.js'; import { createDataMotionMap } from '$lib/utils/motion.svelte.js'; - import { hasAnyDataProp, resolveDataProp, resolveColorProp, resolveGeoDataPair, resolveStyleProp } from '$lib/utils/dataProp.js'; + import { + hasAnyDataProp, + resolveDataProp, + resolveColorProp, + resolveGeoDataPair, + resolveStyleProp, + } from '$lib/utils/dataProp.js'; import { getGeoContext } from '$lib/contexts/geo.js'; import { chartDataArray } from '$lib/utils/common.js'; @@ -167,9 +173,7 @@ const geo = getGeoContext(); // Data to iterate over in data mode - const resolvedData: any[] = $derived( - dataMode ? (dataProp ?? chartDataArray(chartCtx.data)) : [] - ); + const resolvedData: any[] = $derived(dataMode ? (dataProp ?? chartDataArray(chartCtx.data)) : []); // Resolve a single data item to pixel coordinates function resolveLine(d: any) { @@ -233,29 +237,21 @@ const _initialX2 = initialX2 ?? (typeof x2 === 'number' ? x2 : 0); const _initialY2 = initialY2 ?? (typeof y2 === 'number' ? y2 : 0); - const motionX1 = createMotion( - _initialX1, - () => (typeof x1 === 'number' ? x1 : 0), - motion - ); - const motionY1 = createMotion( - _initialY1, - () => (typeof y1 === 'number' ? y1 : 0), - motion - ); - const motionX2 = createMotion( - _initialX2, - () => (typeof x2 === 'number' ? x2 : 0), - motion - ); - const motionY2 = createMotion( - _initialY2, - () => (typeof y2 === 'number' ? y2 : 0), - motion - ); + const motionX1 = createMotion(_initialX1, () => (typeof x1 === 'number' ? x1 : 0), motion); + const motionY1 = createMotion(_initialY1, () => (typeof y1 === 'number' ? y1 : 0), motion); + const motionX2 = createMotion(_initialX2, () => (typeof x2 === 'number' ? x2 : 0), motion); + const motionY2 = createMotion(_initialY2, () => (typeof y2 === 'number' ? y2 : 0), motion); const layerCtx = getLayerContext(); + const staticFill = $derived(typeof fill === 'string' ? fill : undefined); + const staticStroke = $derived(typeof stroke === 'string' ? stroke : undefined); + const staticFillOpacity = $derived(typeof fillOpacity === 'number' ? fillOpacity : undefined); + const staticStrokeWidth = $derived(typeof strokeWidth === 'number' ? strokeWidth : undefined); + const staticOpacity = $derived(typeof opacity === 'number' ? opacity : undefined); + const staticClassName = $derived(typeof className === 'string' ? className : undefined); + const staticHeight = $derived(typeof strokeWidth === 'number' ? `${strokeWidth}px` : '1px'); + function getStyleOptions( styleOverrides: ComputedStylesOptions | undefined, itemFill?: string | undefined, @@ -266,16 +262,29 @@ itemClass?: string | undefined ) { return styleOverrides - ? merge({ styles: { strokeWidth: itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined) } }, styleOverrides) + ? merge( + { + styles: { + strokeWidth: + itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined), + }, + }, + styleOverrides + ) : { styles: { fill: itemFill ?? fill, - fillOpacity: itemFillOpacity ?? (typeof fillOpacity === 'number' ? fillOpacity : undefined), + fillOpacity: + itemFillOpacity ?? (typeof fillOpacity === 'number' ? fillOpacity : undefined), stroke: itemStroke ?? stroke, - strokeWidth: itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined), + strokeWidth: + itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined), opacity: itemOpacity ?? (typeof opacity === 'number' ? opacity : undefined), }, - classes: cls('lc-line', itemClass ?? (typeof className === 'string' ? className : undefined)), + classes: cls( + 'lc-line', + itemClass ?? (typeof className === 'string' ? className : undefined) + ), style: restProps.style as string | undefined, }; } @@ -292,7 +301,15 @@ const resolvedStrokeWidth = resolveStyleProp(strokeWidth, item.d); const resolvedOpacity = resolveStyleProp(opacity, item.d); const resolvedClass = resolveStyleProp(className, item.d); - const styleOpts = getStyleOptions(styleOverrides, resolvedFill, resolvedStroke, resolvedFillOpacity, resolvedStrokeWidth, resolvedOpacity, resolvedClass); + const styleOpts = getStyleOptions( + styleOverrides, + resolvedFill, + resolvedStroke, + resolvedFillOpacity, + resolvedStrokeWidth, + resolvedOpacity, + resolvedClass + ); const pathData = `M ${item.x1},${item.y1} L ${item.x2},${item.y2}`; renderPathData(ctx, pathData, styleOpts); } @@ -318,29 +335,32 @@ color: typeof stroke === 'string' ? stroke : typeof fill === 'string' ? fill : undefined, }; }, - canvasRender: layerCtx === 'canvas' ? { - render, - events: { - click: restProps.onclick, - pointerenter: restProps.onpointerenter, - pointermove: restProps.onpointermove, - pointerleave: restProps.onpointerleave, - }, - deps: () => [ - dataMode, - dataMode ? resolvedItems : null, - motionX1.current, - motionY1.current, - motionX2.current, - motionY2.current, - fillKey!.current, - strokeKey!.current, - strokeWidth, - opacity, - className, - restProps.style, - ], - } : undefined, + canvasRender: + layerCtx === 'canvas' + ? { + render, + events: { + click: restProps.onclick, + pointerenter: restProps.onpointerenter, + pointermove: restProps.onpointermove, + pointerleave: restProps.onpointerleave, + }, + deps: () => [ + dataMode, + dataMode ? resolvedItems : null, + motionX1.current, + motionY1.current, + motionX2.current, + motionY2.current, + fillKey!.current, + strokeKey!.current, + strokeWidth, + opacity, + className, + restProps.style, + ], + } + : undefined, }); @@ -380,15 +400,15 @@ y1={motionY1.current} x2={motionX2.current} y2={motionY2.current} - fill={fill as string} - stroke={stroke as string} - fill-opacity={fillOpacity as number} - stroke-width={strokeWidth as number} - opacity={opacity as number} + fill={staticFill} + stroke={staticStroke} + fill-opacity={staticFillOpacity} + stroke-width={staticStrokeWidth} + opacity={staticOpacity} marker-start={markerStartId ? `url(#${markerStartId})` : undefined} marker-mid={markerMidId ? `url(#${markerMidId})` : undefined} marker-end={markerEndId ? `url(#${markerEndId})` : undefined} - class={cls('lc-line', className as string)} + class={cls('lc-line', staticClassName)} {...restProps} /> @@ -431,12 +451,12 @@ style:left="{motionX1.current}px" style:top="{motionY1.current}px" style:width="{length}px" - style:height="{(strokeWidth as number) ?? 1}px" + style:height={staticHeight} style:transform="translateY(-50%) rotate({angle}deg)" style:transform-origin="0 50%" - style:opacity={opacity as number} - style:background-color={stroke as string} - class={cls('lc-line', className as string)} + style:opacity={staticOpacity} + style:background-color={staticStroke} + class={cls('lc-line', staticClassName)} style={restProps.style} > {/if} diff --git a/packages/layerchart/src/lib/components/Line.svelte.test.ts b/packages/layerchart/src/lib/components/Line.svelte.test.ts index 9fe6fa6cc..3b13a26f3 100644 --- a/packages/layerchart/src/lib/components/Line.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Line.svelte.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; +import type { Component } from 'svelte'; import TestHarness, { componentTestId } from './tests/TestHarness.svelte'; import Line from './Line.svelte'; @@ -9,7 +10,7 @@ describe('Line', () => { describe('pixel mode', () => { it('should render a line with pixel values', async () => { render(TestHarness, { - component: Line, + component: Line as unknown as Component, componentProps: { x1: 0, y1: 0, @@ -32,7 +33,7 @@ describe('Line', () => { it('should render one line per data item with string accessors', async () => { render(TestHarness, { - component: Line, + component: Line as unknown as Component, chartProps: { data, x: 'date', @@ -59,7 +60,7 @@ describe('Line', () => { ]; render(TestHarness, { - component: Line, + component: Line as unknown as Component, chartProps: { data: colorData, x: 'date', @@ -87,7 +88,7 @@ describe('Line', () => { it('should pass literal CSS colors through unchanged in data mode', async () => { render(TestHarness, { - component: Line, + component: Line as unknown as Component, chartProps: { data, x: 'date', @@ -114,7 +115,7 @@ describe('Line', () => { const explicitData = [{ date: new Date('2024-01-01'), value: 20 }]; render(TestHarness, { - component: Line, + component: Line as unknown as Component, chartProps: { data, x: 'date', diff --git a/packages/layerchart/src/lib/components/Points.svelte b/packages/layerchart/src/lib/components/Points.svelte index f0c3e7182..8c9da6e74 100644 --- a/packages/layerchart/src/lib/components/Points.svelte +++ b/packages/layerchart/src/lib/components/Points.svelte @@ -137,8 +137,8 @@ const scaledX: number = ctx.xScale(xVal); const scaledY: number = ctx.yScale(yVal); - const x = scaledX + getOffset(scaledX, offsetX, ctx.xScale, ctx.x1Scale); - const y = scaledY + getOffset(scaledY, offsetY, ctx.yScale, ctx.y1Scale); + const x = scaledX + getOffset(scaledX, offsetX, ctx.xScale, ctx.x1Scale ?? undefined); + const y = scaledY + getOffset(scaledY, offsetY, ctx.yScale, ctx.y1Scale ?? undefined); const radialPoint = pointRadial(x, y); diff --git a/packages/layerchart/src/lib/components/Polygon.svelte b/packages/layerchart/src/lib/components/Polygon.svelte index 1b31f8fc7..d25238195 100644 --- a/packages/layerchart/src/lib/components/Polygon.svelte +++ b/packages/layerchart/src/lib/components/Polygon.svelte @@ -168,7 +168,13 @@ type ResolvedMotion, } from '$lib/utils/motion.svelte.js'; import { renderPathData, type ComputedStylesOptions } from '$lib/utils/canvas.js'; - import { hasAnyDataProp, resolveDataProp, resolveColorProp, resolveGeoDataPair, resolveStyleProp } from '$lib/utils/dataProp.js'; + import { + hasAnyDataProp, + resolveDataProp, + resolveColorProp, + resolveGeoDataPair, + resolveStyleProp, + } from '$lib/utils/dataProp.js'; import { getGeoContext } from '$lib/contexts/geo.js'; import { chartDataArray } from '$lib/utils/common.js'; import { createKey } from '$lib/utils/key.svelte.js'; @@ -219,9 +225,7 @@ const dataMode = $derived(hasAnyDataProp(cx, cy, r)); // Data to iterate over in data mode - const resolvedData: any[] = $derived( - dataMode ? (dataProp ?? chartDataArray(chartCtx.data)) : [] - ); + const resolvedData: any[] = $derived(dataMode ? (dataProp ?? chartDataArray(chartCtx.data)) : []); // Resolve a single data item to a polygon path string function resolvePolygon(d: any) { @@ -234,22 +238,23 @@ } const resolvedR = resolveDataProp(r, d, chartCtx.rScale, typeof r === 'number' ? r : 1); - const pts = typeof points === 'number' - ? polygon({ - cx: resolvedCx, - cy: resolvedCy, - count: points, - radius: resolvedR, - rotate, - inset, - scaleX, - scaleY, - skewX, - skewY, - tiltX, - tiltY, - }) - : points; + const pts = + typeof points === 'number' + ? polygon({ + cx: resolvedCx, + cy: resolvedCy, + count: points, + radius: resolvedR, + rotate, + inset, + scaleX, + scaleY, + skewX, + skewY, + tiltX, + tiltY, + }) + : points; return roundedPolygonPath(pts, cornerRadius); } @@ -350,6 +355,13 @@ const layerCtx = getLayerContext(); + const staticFill = $derived(typeof fill === 'string' ? fill : undefined); + const staticFillOpacity = $derived(typeof fillOpacity === 'number' ? fillOpacity : undefined); + const staticStroke = $derived(typeof stroke === 'string' ? stroke : undefined); + const staticStrokeWidth = $derived(typeof strokeWidth === 'number' ? strokeWidth : undefined); + const staticOpacity = $derived(typeof opacity === 'number' ? opacity : undefined); + const staticClassName = $derived(typeof className === 'string' ? className : undefined); + function getStyleOptions( styleOverrides: ComputedStylesOptions | undefined, itemFill?: string | undefined, @@ -360,16 +372,29 @@ itemClass?: string | undefined ) { return styleOverrides - ? merge({ styles: { strokeWidth: itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined) } }, styleOverrides) + ? merge( + { + styles: { + strokeWidth: + itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined), + }, + }, + styleOverrides + ) : { styles: { fill: itemFill ?? fill, - fillOpacity: itemFillOpacity ?? (typeof fillOpacity === 'number' ? fillOpacity : undefined), + fillOpacity: + itemFillOpacity ?? (typeof fillOpacity === 'number' ? fillOpacity : undefined), stroke: itemStroke ?? stroke, - strokeWidth: itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined), + strokeWidth: + itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined), opacity: itemOpacity ?? (typeof opacity === 'number' ? opacity : undefined), }, - classes: cls('lc-polygon', itemClass ?? (typeof className === 'string' ? className : undefined)), + classes: cls( + 'lc-polygon', + itemClass ?? (typeof className === 'string' ? className : undefined) + ), style: restProps.style as string | undefined, }; } @@ -387,7 +412,15 @@ const resolvedStrokeWidth = resolveStyleProp(strokeWidth, d); const resolvedOpacity = resolveStyleProp(opacity, d); const resolvedClass = resolveStyleProp(className, d); - const styleOpts = getStyleOptions(styleOverrides, resolvedFill, resolvedStroke, resolvedFillOpacity, resolvedStrokeWidth, resolvedOpacity, resolvedClass); + const styleOpts = getStyleOptions( + styleOverrides, + resolvedFill, + resolvedStroke, + resolvedFillOpacity, + resolvedStrokeWidth, + resolvedOpacity, + resolvedClass + ); renderPathData(ctx, pathData, styleOpts); } } else { @@ -412,31 +445,34 @@ color: typeof fill === 'string' ? fill : typeof stroke === 'string' ? stroke : undefined, }; }, - canvasRender: layerCtx === 'canvas' ? { - render, - events: { - click: restProps.onclick, - pointerenter: restProps.onpointerenter, - pointermove: restProps.onpointermove, - pointerleave: restProps.onpointerleave, - pointerdown: restProps.onpointerdown, - pointerover: restProps.onpointerover, - pointerout: restProps.onpointerout, - touchmove: restProps.ontouchmove, - }, - deps: () => [ - dataMode, - dataMode ? resolvedItems : null, - fillKey!.current, - fillOpacity, - strokeKey!.current, - strokeWidth, - opacity, - className, - tweenedState.current, - restProps.style, - ], - } : undefined, + canvasRender: + layerCtx === 'canvas' + ? { + render, + events: { + click: restProps.onclick, + pointerenter: restProps.onpointerenter, + pointermove: restProps.onpointermove, + pointerleave: restProps.onpointerleave, + pointerdown: restProps.onpointerdown, + pointerover: restProps.onpointerover, + pointerout: restProps.onpointerout, + touchmove: restProps.ontouchmove, + }, + deps: () => [ + dataMode, + dataMode ? resolvedItems : null, + fillKey!.current, + fillOpacity, + strokeKey!.current, + strokeWidth, + opacity, + className, + tweenedState.current, + restProps.style, + ], + } + : undefined, }); @@ -464,12 +500,12 @@ {:else} diff --git a/packages/layerchart/src/lib/components/Rect.svelte b/packages/layerchart/src/lib/components/Rect.svelte index fc338e9ab..b80e0fc5b 100644 --- a/packages/layerchart/src/lib/components/Rect.svelte +++ b/packages/layerchart/src/lib/components/Rect.svelte @@ -131,13 +131,20 @@ @@ -472,15 +521,15 @@ y={motionY.current} width={motionWidth.current} height={motionHeight.current} - fill={fill as string} - fill-opacity={fillOpacity as number} - stroke={stroke as string} - stroke-opacity={strokeOpacity as number} - stroke-width={strokeWidth as number} - opacity={opacity as number} + fill={staticFill} + fill-opacity={staticFillOpacity} + stroke={staticStroke} + stroke-opacity={staticStrokeOpacity} + stroke-width={staticStrokeWidth} + opacity={staticOpacity} {rx} {ry} - class={cls('lc-rect', className as string)} + class={cls('lc-rect', staticClassName)} {...restProps} {onclick} {ondblclick} @@ -516,7 +565,7 @@ style:border-color={resolvedStroke} style:border-radius="{rx}px" class={cls('lc-rect', resolvedClass)} - {...restProps as any} + {...htmlRestProps} {onclick} {ondblclick} {onpointerenter} @@ -535,14 +584,14 @@ style:top="{motionY.current}px" style:width="{motionWidth.current}px" style:height="{motionHeight.current}px" - style:background={fill as string} - style:opacity={opacity as number} - style:border-width="{strokeWidth as number}px" + style:background={staticFill} + style:opacity={staticOpacity} + style:border-width={staticBorderWidth} style:border-style="solid" - style:border-color={stroke as string} + style:border-color={staticStroke} style:border-radius="{rx}px" - class={cls('lc-rect', className as string)} - {...restProps as any} + class={cls('lc-rect', staticClassName)} + {...htmlRestProps} {onclick} {ondblclick} {onpointerenter} diff --git a/packages/layerchart/src/lib/components/Rule.svelte b/packages/layerchart/src/lib/components/Rule.svelte index f82010d22..f7d1a2353 100644 --- a/packages/layerchart/src/lib/components/Rule.svelte +++ b/packages/layerchart/src/lib/components/Rule.svelte @@ -77,6 +77,8 @@ const ctx = getChartContext(); + ctx.registerComponent({ name: 'Rule', kind: 'composite-mark' }); + const data = $derived(chartDataArray(dataProp ?? ctx.data)); const singleX = $derived( diff --git a/packages/layerchart/src/lib/components/Sankey.svelte b/packages/layerchart/src/lib/components/Sankey.svelte index 3e721857f..351aedbee 100644 --- a/packages/layerchart/src/lib/components/Sankey.svelte +++ b/packages/layerchart/src/lib/components/Sankey.svelte @@ -107,8 +107,6 @@ const ctx = getChartContext(); const sankeyData = $derived.by(() => { - if (typeof document === 'undefined') return { nodes: [], links: [] }; - return ( d3Sankey() .size([ctx.width, ctx.height]) diff --git a/packages/layerchart/src/lib/components/Text.svelte b/packages/layerchart/src/lib/components/Text.svelte index 3e4986dd5..53096e130 100644 --- a/packages/layerchart/src/lib/components/Text.svelte +++ b/packages/layerchart/src/lib/components/Text.svelte @@ -238,7 +238,12 @@ import { createDataMotionMap } from '$lib/utils/motion.svelte.js'; import { getStringWidth, truncateText, type TruncateTextOptions } from '$lib/utils/string.js'; import { getComputedStyles, renderText, type ComputedStylesOptions } from '../utils/canvas.js'; - import { resolveDataProp, resolveColorProp, resolveGeoDataPair, resolveStyleProp } from '$lib/utils/dataProp.js'; + import { + resolveDataProp, + resolveColorProp, + resolveGeoDataPair, + resolveStyleProp, + } from '$lib/utils/dataProp.js'; import { getGeoContext } from '$lib/contexts/geo.js'; import { get } from '@layerstack/utils'; import { chartDataArray } from '$lib/utils/common.js'; @@ -294,9 +299,7 @@ const geo = getGeoContext(); // Data to iterate over in data mode - const resolvedData: any[] = $derived( - dataMode ? (dataProp ?? chartDataArray(chartCtx.data)) : [] - ); + const resolvedData: any[] = $derived(dataMode ? (dataProp ?? chartDataArray(chartCtx.data)) : []); // Resolve position for a data item function resolveTextPosition(d: any) { @@ -401,7 +404,11 @@ const motionValue = createMotion( typeof value === 'number' ? value : 0, () => (typeof value === 'number' ? value : 0), - typeof value === 'number' && motion ? (typeof motion === 'object' && 'type' in motion ? motion : undefined) : undefined + typeof value === 'number' && motion + ? typeof motion === 'object' && 'type' in motion + ? motion + : undefined + : undefined ); // Handle null and convert `\n` strings back to newline characters @@ -549,6 +556,13 @@ motion ); + const staticFill = $derived(typeof fill === 'string' ? fill : undefined); + const staticFillOpacity = $derived(typeof fillOpacity === 'number' ? fillOpacity : undefined); + const staticStroke = $derived(typeof stroke === 'string' ? stroke : undefined); + const staticStrokeWidth = $derived(typeof strokeWidth === 'number' ? strokeWidth : undefined); + const staticOpacity = $derived(typeof opacity === 'number' ? opacity : undefined); + const staticClassName = $derived(typeof className === 'string' ? className : undefined); + function render( ctx: CanvasRenderingContext2D, styleOverrides: ComputedStylesOptions | undefined @@ -562,20 +576,33 @@ itemClass?: string | undefined ) { return styleOverrides - ? merge({ styles: { strokeWidth: itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined) } }, styleOverrides) + ? merge( + { + styles: { + strokeWidth: + itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined), + }, + }, + styleOverrides + ) : { styles: { fill: itemFill ?? fill, - fillOpacity: itemFillOpacity ?? (typeof fillOpacity === 'number' ? fillOpacity : undefined), + fillOpacity: + itemFillOpacity ?? (typeof fillOpacity === 'number' ? fillOpacity : undefined), stroke: itemStroke ?? stroke, - strokeWidth: itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined), + strokeWidth: + itemStrokeWidth ?? (typeof strokeWidth === 'number' ? strokeWidth : undefined), opacity: itemOpacity ?? (typeof opacity === 'number' ? opacity : undefined), paintOrder: 'stroke', // Only include textAnchor in constantStyles when explicitly non-default, // so that CSS class-based text-anchor (e.g. [text-anchor:middle]) can take effect ...(textAnchor !== 'start' ? { textAnchor } : {}), }, - classes: cls('lc-text', itemClass ?? (typeof className === 'string' ? className : undefined)), + classes: cls( + 'lc-text', + itemClass ?? (typeof className === 'string' ? className : undefined) + ), style: restProps.style as string | undefined, }; } @@ -584,8 +611,7 @@ const baseStyles = getTextStyles(); const computedStyles = getComputedStyles(ctx.canvas, baseStyles); ctx.font = `${computedStyles.fontSize} ${computedStyles.fontFamily}`; - const textAlign = - textAnchor === 'middle' ? 'center' : textAnchor === 'end' ? 'end' : 'start'; + const textAlign = textAnchor === 'middle' ? 'center' : textAnchor === 'end' ? 'end' : 'start'; ctx.textAlign = textAlign; for (const item of resolvedItems) { @@ -596,7 +622,14 @@ const resolvedStrokeWidth = resolveStyleProp(strokeWidth, item.d); const resolvedOpacity = resolveStyleProp(opacity, item.d); const resolvedClass = resolveStyleProp(className, item.d); - const itemStyles = getTextStyles(resolvedFill, resolvedStroke, resolvedFillOpacity, resolvedStrokeWidth, resolvedOpacity, resolvedClass); + const itemStyles = getTextStyles( + resolvedFill, + resolvedStroke, + resolvedFillOpacity, + resolvedStrokeWidth, + resolvedOpacity, + resolvedClass + ); ctx.save(); if (rotate !== undefined) { const radians = degreesToRadians(rotate); @@ -634,8 +667,7 @@ ctx.font = `${computedStyles.fontSize} ${computedStyles.fontFamily}`; - const textAlign = - textAnchor === 'middle' ? 'center' : textAnchor === 'end' ? 'end' : 'start'; + const textAlign = textAnchor === 'middle' ? 'center' : textAnchor === 'end' ? 'end' : 'start'; ctx.textAlign = textAlign; for (let index = 0; index < wordsByLines.length; index++) { @@ -668,26 +700,29 @@ color: typeof fill === 'string' ? fill : undefined, }; }, - canvasRender: layerCtx === 'canvas' ? { - render, - deps: () => [ - dataMode, - dataMode ? resolvedItems : null, - value, - motionX.current, - motionY.current, - fillKey!.current, - strokeKey!.current, - strokeWidth, - opacity, - className, - truncateConfig, - rotate, - lineHeight, - textAnchor, - verticalAnchor, - ], - } : undefined, + canvasRender: + layerCtx === 'canvas' + ? { + render, + deps: () => [ + dataMode, + dataMode ? resolvedItems : null, + value, + motionX.current, + motionY.current, + fillKey!.current, + strokeKey!.current, + strokeWidth, + opacity, + className, + truncateConfig, + rotate, + lineHeight, + textAnchor, + verticalAnchor, + ], + } + : undefined, }); @@ -717,11 +752,7 @@ opacity={resolvedOpacity} class={['lc-text', resolvedClass]} > - + {text} @@ -741,13 +772,13 @@ bind:this={ref} {dy} {...restProps} - fill={fill as string} - fill-opacity={fillOpacity as number} - stroke={stroke as string} - stroke-width={strokeWidth as number} - opacity={opacity as number} + fill={staticFill} + fill-opacity={staticFillOpacity} + stroke={staticStroke} + stroke-width={staticStrokeWidth} + opacity={staticOpacity} transform={transformProp} - class={['lc-text', className as string]} + class={['lc-text', staticClassName]} > {#each wordsByLines as line, index} {textValue} diff --git a/packages/layerchart/src/lib/components/charts/PieChart.svelte b/packages/layerchart/src/lib/components/charts/PieChart.svelte index 1c06e2174..f869d7876 100644 --- a/packages/layerchart/src/lib/components/charts/PieChart.svelte +++ b/packages/layerchart/src/lib/components/charts/PieChart.svelte @@ -231,7 +231,7 @@ // Reading context.series.allSeriesData here would create a derived_references_self cycle: // SeriesState.#series → ChartState.props → data={visibleData} → chartData → context.series.allSeriesData → #series const chartData = $derived.by(() => { - const seriesData = series.flatMap((s) => s.data ?? []); + const seriesData = series.flatMap((s) => ('data' in s ? s.data : undefined) ?? []); return (seriesData.length > 0 ? seriesData : chartDataArray(data)) as Array; }); @@ -245,7 +245,7 @@ // Compute series colors locally to avoid derived_references_self cycle through context.series.allSeriesColors const allSeriesColors = $derived( - series.map((s) => s.color).filter((c) => c != null) as string[] + series.map((s) => ('color' in s ? s.color : undefined)).filter((c) => c != null) as string[] ); // Custom tickFormat for PieChart legends - uses data labels instead of series labels diff --git a/packages/layerchart/src/lib/components/layers/Canvas.svelte b/packages/layerchart/src/lib/components/layers/Canvas.svelte index 9312e8d82..a976bcc04 100644 --- a/packages/layerchart/src/lib/components/layers/Canvas.svelte +++ b/packages/layerchart/src/lib/components/layers/Canvas.svelte @@ -1,5 +1,11 @@ diff --git a/packages/layerchart/src/lib/server/ServerChart.svelte b/packages/layerchart/src/lib/server/ServerChart.svelte new file mode 100644 index 000000000..77bd34564 --- /dev/null +++ b/packages/layerchart/src/lib/server/ServerChart.svelte @@ -0,0 +1,26 @@ + + + + + {#if children} + {@render children()} + {/if} + + diff --git a/packages/layerchart/src/lib/server/TestBarChart.svelte b/packages/layerchart/src/lib/server/TestBarChart.svelte new file mode 100644 index 000000000..a2b17cefe --- /dev/null +++ b/packages/layerchart/src/lib/server/TestBarChart.svelte @@ -0,0 +1,35 @@ + + + + + diff --git a/packages/layerchart/src/lib/server/TestLineChart.svelte b/packages/layerchart/src/lib/server/TestLineChart.svelte new file mode 100644 index 000000000..e195044a9 --- /dev/null +++ b/packages/layerchart/src/lib/server/TestLineChart.svelte @@ -0,0 +1,35 @@ + + + + + + diff --git a/packages/layerchart/src/lib/server/captureStore.ts b/packages/layerchart/src/lib/server/captureStore.ts new file mode 100644 index 000000000..293f60b29 --- /dev/null +++ b/packages/layerchart/src/lib/server/captureStore.ts @@ -0,0 +1,35 @@ +import type { ChartState, ComponentNode } from '$lib/states/chart.svelte.js'; + +export type CaptureTarget = { + chartState?: ChartState; + rootNode?: ComponentNode; +}; + +export type SSRCapture = CaptureTarget | null; + +const SSR_CAPTURE_KEY = Symbol.for('layerchart.ssr-capture'); + +type GlobalWithSSRCapture = typeof globalThis & { + [SSR_CAPTURE_KEY]?: SSRCapture; +}; + +let _capture: SSRCapture = null; + +function getGlobalCaptureStore(): GlobalWithSSRCapture { + return globalThis as GlobalWithSSRCapture; +} + +export function setSSRCapture(target: SSRCapture) { + _capture = target; + + const globalStore = getGlobalCaptureStore(); + if (target == null) { + delete globalStore[SSR_CAPTURE_KEY]; + } else { + globalStore[SSR_CAPTURE_KEY] = target; + } +} + +export function getSSRCapture() { + return getGlobalCaptureStore()[SSR_CAPTURE_KEY] ?? _capture; +} diff --git a/packages/layerchart/src/lib/server/index.ts b/packages/layerchart/src/lib/server/index.ts new file mode 100644 index 000000000..889f93fdc --- /dev/null +++ b/packages/layerchart/src/lib/server/index.ts @@ -0,0 +1,230 @@ +import { render } from 'svelte/server'; +import type { Component } from 'svelte'; +import type { ChartState } from '$lib/states/chart.svelte.js'; +import type { ComponentNode } from '$lib/states/chart.svelte.js'; +import type { CaptureTarget } from './captureStore.js'; +import { renderTree } from './renderTree.js'; +export { renderTree } from './renderTree.js'; +export { default as ServerChart } from './ServerChart.svelte'; +export { + getSSRCapture, + setSSRCapture, + type CaptureTarget, + type SSRCapture, +} from './captureStore.js'; + +export type CapturedChart = { + chartState: ChartState; + rootNode: ComponentNode; +}; + +export type CanvasRenderContext = Omit & { + canvas?: unknown; +}; + +export type CanvasFactory = ( + width: number, + height: number +) => { + getContext(type: '2d'): unknown; + toBuffer(mimeType: string, ...args: any[]): Buffer | Uint8Array; +}; + +export type RenderOptions = { + /** Pixel ratio for high-DPI output. @default 1 */ + devicePixelRatio?: number; + /** Output format. @default 'png' */ + format?: 'png' | 'jpeg'; + /** JPEG quality (0-1). Only used when format is 'jpeg'. @default 0.92 */ + quality?: number; + /** + * Background color to fill before rendering the chart. + * When omitted, PNG output is transparent. + * Set to `'white'` (or any CSS color) for an opaque background — + * recommended for JPEG which does not support transparency. + */ + background?: string; + /** + * Canvas factory function. + * + * Example with \@napi-rs/canvas: + * ```ts + * import { createCanvas } from '\@napi-rs/canvas'; + * createCanvas: (w, h) => createCanvas(w, h) + * ``` + */ + createCanvas: CanvasFactory; +}; + +export type RenderChartOptions = RenderOptions & { + /** Width of the output image in pixels. */ + width: number; + /** Height of the output image in pixels. */ + height: number; + /** Additional props to pass to the chart component. */ + props?: Record; +}; + +/** + * Create a capture callback for use with `render()` from `svelte/server`. + * Pass the returned `onCapture` as the `onCapture` prop to your chart component. + * After `render()` completes, call `getCapture()` to retrieve the chart state and + * component tree. + * + * @example + * ```ts + * import { render } from 'svelte/server'; + * import { createCaptureCallback, renderCapturedChart } from 'layerchart/server'; + * import MyChart from './MyChart.svelte'; + * + * const { onCapture, getCapture } = createCaptureCallback(); + * const rendered = render(MyChart, { props: { data, width: 800, height: 400, onCapture } }); + * rendered.body; // Force the SSR render to fully flush before reading capture state + * const capture = getCapture(); + * ``` + */ +export function createCaptureCallback() { + let captured: CaptureTarget | null = null; + return { + onCapture: (data: CaptureTarget) => { + captured = data; + }, + getCapture: () => captured, + }; +} + +/** + * Render a chart component to an image buffer in a single call. + * + * This is a convenience function that handles SSR rendering, capture, and + * canvas rendering in one step. The component should use `` + * internally and accept `width`, `height`, and `capture` props. + * + * @example + * ```ts + * import { createCanvas, Path2D } from '\@napi-rs/canvas'; + * import { renderChart } from 'layerchart/server'; + * import MyChart from './MyChart.svelte'; + * + * // Register Path2D globally for canvas rendering + * if (typeof globalThis.Path2D === 'undefined') (globalThis as any).Path2D = Path2D; + * + * const buffer = renderChart(MyChart, { + * width: 800, + * height: 400, + * props: { data: myData }, + * createCanvas: (w, h) => createCanvas(w, h), + * }); + * + * // Use as a Response in a SvelteKit endpoint + * return new Response(buffer, { + * headers: { 'Content-Type': 'image/png' } + * }); + * ``` + */ +export function renderChart( + component: Component, + options: RenderChartOptions +): Buffer | Uint8Array { + const { width, height, props = {}, ...renderOptions } = options; + const captureTarget: CaptureTarget = {}; + + // SSR render to build the component tree and capture chart state + const rendered = render(component, { + props: { ...props, width, height, capture: captureTarget } + }); + // Force the SSR render to fully flush + void rendered.body; + + if (!captureTarget.chartState || !captureTarget.rootNode) { + throw new Error( + 'Failed to capture chart state. Ensure the component uses with a `capture` prop.' + ); + } + + return renderCapturedChart(captureTarget as CapturedChart, { + width, + height, + ...renderOptions, + }); +} + +/** + * Render a captured chart component tree to an image buffer. + * Call this after `render()` from `svelte/server` has been used to build + * the component tree with a capture callback. + * + * For most use cases, prefer {@link renderChart} which handles the full pipeline. + * + * @example + * ```ts + * import { render } from 'svelte/server'; + * import { createCanvas, Path2D } from '\@napi-rs/canvas'; + * import { createCaptureCallback, renderCapturedChart } from 'layerchart/server'; + * import MyChart from './MyChart.svelte'; + * + * // Register canvas globals + * if (typeof globalThis.Path2D === 'undefined') (globalThis as any).Path2D = Path2D; + * + * // Build component tree via SSR render + * const { onCapture, getCapture } = createCaptureCallback(); + * const rendered = render(MyChart, { props: { data, width: 800, height: 400, onCapture } }); + * rendered.body; // Force the SSR render to fully flush before reading capture state + * + * // Render to image + * const buffer = renderCapturedChart(getCapture()!, { + * width: 800, + * height: 400, + * createCanvas: (w, h) => createCanvas(w, h), + * }); + * ``` + */ +export function renderCapturedChart( + capture: CapturedChart, + options: RenderOptions & { width: number; height: number } +): Buffer | Uint8Array { + const { + width, + height, + devicePixelRatio = 1, + format = 'png', + quality = 0.92, + background, + createCanvas, + } = options; + + // Create canvas + const canvasWidth = Math.round(width * devicePixelRatio); + const canvasHeight = Math.round(height * devicePixelRatio); + const canvas = createCanvas(canvasWidth, canvasHeight); + const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; + + // Fill background (canvas is transparent by default) + if (background) { + ctx.fillStyle = background; + ctx.fillRect(0, 0, canvasWidth, canvasHeight); + } + + // Apply DPI scaling + if (devicePixelRatio !== 1) { + ctx.scale(devicePixelRatio, devicePixelRatio); + } + + // Apply padding translation (mirrors what Canvas.svelte's update() does) + if (capture.chartState) { + const padding = capture.chartState.padding; + if (padding) { + ctx.translate(padding.left ?? 0, padding.top ?? 0); + } + } + + // Render the component tree onto the canvas + renderTree(ctx as CanvasRenderingContext2D, capture.rootNode); + + // Export to buffer + const mimeType = format === 'jpeg' ? 'image/jpeg' : 'image/png'; + if (format === 'jpeg') { + return canvas.toBuffer(mimeType, quality); + } + return canvas.toBuffer(mimeType); +} diff --git a/packages/layerchart/src/lib/server/renderChart.ssr.test.ts b/packages/layerchart/src/lib/server/renderChart.ssr.test.ts new file mode 100644 index 000000000..ed42dd0dc --- /dev/null +++ b/packages/layerchart/src/lib/server/renderChart.ssr.test.ts @@ -0,0 +1,257 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { render } from 'svelte/server'; +import { createCanvas, Path2D } from '@napi-rs/canvas'; +import { + renderChart, + renderCapturedChart, + createCaptureCallback, + type CaptureTarget, + type CapturedChart, + type CanvasFactory, +} from './index.js'; + +import TestLineChart from './TestLineChart.svelte'; +import TestBarChart from './TestBarChart.svelte'; + +// Register Path2D globally for canvas rendering +beforeAll(() => { + if (typeof globalThis.Path2D === 'undefined') { + (globalThis as any).Path2D = Path2D; + } +}); + +const createNodeCanvas: CanvasFactory = (w, h) => + createCanvas(w, h) as unknown as ReturnType; + +const lineData = Array.from({ length: 20 }, (_, i) => ({ + date: i, + value: 50 + 30 * Math.sin(i / 5), +})); + +const barData = [ + { category: 'A', value: 28 }, + { category: 'B', value: 55 }, + { category: 'C', value: 43 }, + { category: 'D', value: 91 }, +]; + +describe('renderChart', () => { + it('renders a line chart to PNG buffer', () => { + const buffer = renderChart(TestLineChart, { + width: 400, + height: 200, + props: { data: lineData }, + createCanvas: createNodeCanvas, + }); + + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.length).toBeGreaterThan(0); + // PNG magic bytes + expect(buffer[0]).toBe(0x89); + expect(buffer[1]).toBe(0x50); // P + expect(buffer[2]).toBe(0x4e); // N + expect(buffer[3]).toBe(0x47); // G + }); + + it('renders a bar chart to PNG buffer', () => { + const buffer = renderChart(TestBarChart, { + width: 400, + height: 200, + props: { data: barData }, + createCanvas: createNodeCanvas, + }); + + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer.length).toBeGreaterThan(0); + // PNG magic bytes + expect(buffer[0]).toBe(0x89); + }); + + it('renders to JPEG format', () => { + const buffer = renderChart(TestLineChart, { + width: 400, + height: 200, + format: 'jpeg', + props: { data: lineData }, + createCanvas: createNodeCanvas, + }); + + expect(buffer).toBeInstanceOf(Buffer); + // JPEG magic bytes (SOI marker) + expect(buffer[0]).toBe(0xff); + expect(buffer[1]).toBe(0xd8); + }); + + it('respects custom dimensions', () => { + const buffer1 = renderChart(TestLineChart, { + width: 200, + height: 100, + props: { data: lineData }, + createCanvas: createNodeCanvas, + }); + + const buffer2 = renderChart(TestLineChart, { + width: 800, + height: 600, + props: { data: lineData }, + createCanvas: createNodeCanvas, + }); + + // Larger image should produce a larger buffer + expect(buffer2.length).toBeGreaterThan(buffer1.length); + }); + + it('supports devicePixelRatio', () => { + const buffer1x = renderChart(TestLineChart, { + width: 400, + height: 200, + devicePixelRatio: 1, + props: { data: lineData }, + createCanvas: createNodeCanvas, + }); + + const buffer2x = renderChart(TestLineChart, { + width: 400, + height: 200, + devicePixelRatio: 2, + props: { data: lineData }, + createCanvas: createNodeCanvas, + }); + + // 2x DPI should produce a larger buffer (more pixels) + expect(buffer2x.length).toBeGreaterThan(buffer1x.length); + }); + + it('supports background color', () => { + const transparentBuffer = renderChart(TestLineChart, { + width: 400, + height: 200, + props: { data: lineData }, + createCanvas: createNodeCanvas, + }); + + const whiteBuffer = renderChart(TestLineChart, { + width: 400, + height: 200, + background: 'white', + props: { data: lineData }, + createCanvas: createNodeCanvas, + }); + + // Both should be valid PNGs but different content + expect(transparentBuffer[0]).toBe(0x89); + expect(whiteBuffer[0]).toBe(0x89); + expect(Buffer.compare(transparentBuffer, whiteBuffer)).not.toBe(0); + }); + + it('throws on missing ServerChart', () => { + // A bare component that doesn't use ServerChart should fail + expect(() => + renderChart( + // Use a dummy component-like object + (() => {}) as any, + { + width: 400, + height: 200, + createCanvas: createNodeCanvas, + } + ) + ).toThrow('Failed to capture chart state'); + }); +}); + +describe('renderCapturedChart', () => { + it('renders a captured chart tree to a buffer', () => { + const captureTarget: CaptureTarget = {}; + + const rendered = render(TestLineChart, { + props: { data: lineData, width: 400, height: 200, capture: captureTarget }, + }); + void rendered.body; + + expect(captureTarget.chartState).toBeDefined(); + expect(captureTarget.rootNode).toBeDefined(); + + const buffer = renderCapturedChart(captureTarget as CapturedChart, { + width: 400, + height: 200, + createCanvas: createNodeCanvas, + }); + + expect(buffer).toBeInstanceOf(Buffer); + expect(buffer[0]).toBe(0x89); // PNG + }); +}); + +describe('createCaptureCallback', () => { + it('captures chart state via callback', () => { + const { onCapture, getCapture } = createCaptureCallback(); + + const rendered = render(TestLineChart, { + props: { data: lineData, width: 400, height: 200, onCapture }, + }); + void rendered.body; + + const capture = getCapture(); + expect(capture).not.toBeNull(); + expect(capture?.chartState).toBeDefined(); + expect(capture?.rootNode).toBeDefined(); + }); + + it('returns null before render', () => { + const { getCapture } = createCaptureCallback(); + expect(getCapture()).toBeNull(); + }); +}); + +describe('ServerChart capture prop', () => { + it('populates capture target via prop', () => { + const captureTarget: CaptureTarget = {}; + + const rendered = render(TestLineChart, { + props: { data: lineData, width: 400, height: 200, capture: captureTarget }, + }); + void rendered.body; + + expect(captureTarget.chartState).toBeDefined(); + expect(captureTarget.rootNode).toBeDefined(); + expect(captureTarget.rootNode!.children.length).toBeGreaterThan(0); + }); + + it('captures chart state with correct padding', () => { + const captureTarget: CaptureTarget = {}; + + const rendered = render(TestLineChart, { + props: { data: lineData, width: 800, height: 400, capture: captureTarget }, + }); + void rendered.body; + + const state = captureTarget.chartState!; + expect(state.padding).toEqual({ top: 20, right: 20, bottom: 20, left: 20 }); + }); + + it('captures component tree with children', () => { + const captureTarget: CaptureTarget = {}; + + const rendered = render(TestLineChart, { + props: { data: lineData, width: 400, height: 200, capture: captureTarget }, + }); + void rendered.body; + + // Root node (Canvas) should have children + const root = captureTarget.rootNode!; + expect(root.kind).toBe('group'); + expect(root.children.length).toBeGreaterThan(0); + + // Count all marks in the tree (may be nested in composite-marks) + function countMarks(node: typeof root): number { + let count = node.kind === 'mark' ? 1 : 0; + for (const child of node.children) { + count += countMarks(child); + } + return count; + } + // Should have at least 2 marks (Area and Spline) + expect(countMarks(root)).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/packages/layerchart/src/lib/server/renderTree.ts b/packages/layerchart/src/lib/server/renderTree.ts new file mode 100644 index 000000000..3910d877b --- /dev/null +++ b/packages/layerchart/src/lib/server/renderTree.ts @@ -0,0 +1,29 @@ +import type { ComponentNode } from '$lib/states/chart.svelte.js'; + +/** + * Recursively render the component tree onto a canvas context. + * Group nodes: save → render → recurse children → restore + * Leaf nodes: save → render → restore + * Non-rendering nodes: just recurse children + */ +export function renderTree(ctx: CanvasRenderingContext2D, node: ComponentNode): void { + if (node.kind === 'group' && node.canvasRender) { + // Group: save state, apply transform, render children, restore + ctx.save(); + node.canvasRender.render(ctx); + for (const child of node.children) { + renderTree(ctx, child); + } + ctx.restore(); + } else if (node.canvasRender) { + // Leaf mark: save, render, restore + ctx.save(); + node.canvasRender.render(ctx); + ctx.restore(); + } else { + // Non-rendering node (e.g. root, composite-mark): just recurse children + for (const child of node.children) { + renderTree(ctx, child); + } + } +} diff --git a/packages/layerchart/src/lib/states/chart.svelte.ts b/packages/layerchart/src/lib/states/chart.svelte.ts index 19462da71..4d838dcdf 100644 --- a/packages/layerchart/src/lib/states/chart.svelte.ts +++ b/packages/layerchart/src/lib/states/chart.svelte.ts @@ -256,8 +256,12 @@ export class ChartState< constructor(propsGetter: () => ChartPropsWithoutHTML) { this._propsGetter = propsGetter; - // Create GeoState instance - this.geoState = new GeoState(() => this.props.geo ?? {}); + // Create GeoState instance — pass a dimensions getter so projection + // is available during SSR (where $effect doesn't run) + this.geoState = new GeoState( + () => this.props.geo ?? {}, + () => ({ width: this.width, height: this.height }) + ); // Create SeriesState internally from series/seriesLayout props. // When no explicit series are provided, derive implicit series from mark registrations. @@ -1242,8 +1246,8 @@ export class ChartState< if (typeof baseDomainX[0] === 'string') { // Categorical: compute scale/translate from domain indices const totalCount = baseDomainX.length; - const startIdx = baseDomainX.indexOf(brushX[0] as string); - const endIdx = baseDomainX.indexOf(brushX[1] as string) + 1; + const startIdx = (baseDomainX as unknown as string[]).indexOf(brushX[0] as string); + const endIdx = (baseDomainX as unknown as string[]).indexOf(brushX[1] as string) + 1; const selectedCount = endIdx - startIdx; if (selectedCount > 0 && totalCount > 0) { @@ -1255,8 +1259,8 @@ export class ChartState< const baseDomainY = this._baseYDomain; if (typeof baseDomainY[0] === 'string') { const yTotal = baseDomainY.length; - const yStart = baseDomainY.indexOf(brushY[0] as string); - const yEnd = baseDomainY.indexOf(brushY[1] as string) + 1; + const yStart = (baseDomainY as unknown as string[]).indexOf(brushY[0] as string); + const yEnd = (baseDomainY as unknown as string[]).indexOf(brushY[1] as string) + 1; const ySelected = yEnd - yStart; if (ySelected > 0) { newTranslateY = -(yStart / yTotal) * this.height * newScale; diff --git a/packages/layerchart/src/lib/states/geo.svelte.ts b/packages/layerchart/src/lib/states/geo.svelte.ts index 98f036672..f20836703 100644 --- a/packages/layerchart/src/lib/states/geo.svelte.ts +++ b/packages/layerchart/src/lib/states/geo.svelte.ts @@ -33,109 +33,115 @@ export type GeoStateProps = { export class GeoState { // Props getter function - set in constructor private _propsGetter!: () => GeoStateProps; + private _dimensionsGetter?: () => { width: number; height: number }; // Props - accessed via getter function for fine-grained reactivity props = $derived(this._propsGetter()); - // Context references + // Context references — used by GeoProjection.svelte (client-side only) chartWidth = $state(100); chartHeight = $state(100); transformState = $state(null); transformApply = $state({ rotation: false, scale: true, translate: true }); - // The actual projection instance - projection = $state(undefined); + // The actual projection instance — derived so it works during SSR + projection: GeoProjection | undefined = $derived.by(() => { + if (!this.props.projection) return undefined; - constructor(propsGetter: () => GeoStateProps) { - this._propsGetter = propsGetter; - - // Main effect to build and configure the projection - $effect.pre(() => { - if (!this.props.projection) return; - - const _projection = this.props.projection(); - - // Apply fitSize if fitGeojson is provided - if (this.props.fitGeojson && 'fitSize' in _projection) { - _projection.fitSize(this.fitSizeRange, this.props.fitGeojson); - } - - // Apply scale - if ('scale' in _projection) { - if (this.props.scale) { - _projection.scale(this.props.scale); - } + const _projection = this.props.projection(); - if (this.transformState?.mode === 'projection' && this.transformApply.scale) { - _projection.scale(this.transformState.scale); - } - } - - // Apply rotate - if ('rotate' in _projection) { - if (this.props.rotate) { - _projection.rotate([ - this.props.rotate.yaw, - this.props.rotate.pitch, - this.props.rotate.roll, - ]); - } - - if (this.transformState?.mode === 'projection' && this.transformApply.rotation) { - _projection.rotate([ - this.transformState.translate.x, // yaw - this.transformState.translate.y, // pitch - ]); - } - } + // Apply fitSize if fitGeojson is provided + if (this.props.fitGeojson && 'fitSize' in _projection) { + _projection.fitSize(this.fitSizeRange, this.props.fitGeojson); + } - // Apply translate - if ('translate' in _projection) { - if (this.props.translate) { - _projection.translate(this.props.translate); - } - - if (this.transformState?.mode === 'projection' && this.transformApply.translate) { - _projection.translate([ - this.transformState.translate.x, - this.transformState.translate.y, - ]); - } + // Apply scale + if ('scale' in _projection) { + if (this.props.scale) { + _projection.scale(this.props.scale); } - // Apply center - if (this.props.center && 'center' in _projection) { - _projection.center(this.props.center); + if (this.transformState?.mode === 'projection' && this.transformApply.scale) { + _projection.scale(this.transformState.scale); } - - // Apply reflectX - if (this.props.reflectX) { - _projection.reflectX(this.props.reflectX); + } + + // Apply rotate + if ('rotate' in _projection) { + if (this.props.rotate) { + _projection.rotate([ + this.props.rotate.yaw, + this.props.rotate.pitch, + this.props.rotate.roll, + ]); } - // Apply reflectY - if (this.props.reflectY) { - _projection.reflectY(this.props.reflectY); + if (this.transformState?.mode === 'projection' && this.transformApply.rotation) { + _projection.rotate([ + this.transformState.translate.x, // yaw + this.transformState.translate.y, // pitch + ]); } + } - // Apply clipAngle - if (this.props.clipAngle && 'clipAngle' in _projection) { - _projection.clipAngle(this.props.clipAngle); + // Apply translate + if ('translate' in _projection) { + if (this.props.translate) { + _projection.translate(this.props.translate); } - // Apply clipExtent - if (this.props.clipExtent && 'clipExtent' in _projection) { - _projection.clipExtent(this.props.clipExtent); + if (this.transformState?.mode === 'projection' && this.transformApply.translate) { + _projection.translate([ + this.transformState.translate.x, + this.transformState.translate.y, + ]); } - - this.projection = _projection; - }); + } + + // Apply center + if (this.props.center && 'center' in _projection) { + _projection.center(this.props.center); + } + + // Apply reflectX + if (this.props.reflectX) { + _projection.reflectX(this.props.reflectX); + } + + // Apply reflectY + if (this.props.reflectY) { + _projection.reflectY(this.props.reflectY); + } + + // Apply clipAngle + if (this.props.clipAngle && 'clipAngle' in _projection) { + _projection.clipAngle(this.props.clipAngle); + } + + // Apply clipExtent + if (this.props.clipExtent && 'clipExtent' in _projection) { + _projection.clipExtent(this.props.clipExtent); + } + + return _projection; + }); + + constructor( + propsGetter: () => GeoStateProps, + dimensionsGetter?: () => { width: number; height: number } + ) { + this._propsGetter = propsGetter; + this._dimensionsGetter = dimensionsGetter; } - // Derived properties + // Derived properties — use dimensions getter (from ChartState) when available, + // falling back to $state values (set by GeoProjection.svelte via $effect) fitSizeRange = $derived( this.props.fixedAspectRatio ? [100, 100 / this.props.fixedAspectRatio] - : [this.chartWidth, this.chartHeight] + : [ + this._dimensionsGetter?.().width ?? this.chartWidth, + this._dimensionsGetter?.().height ?? this.chartHeight, + ] ) as [number, number]; } diff --git a/packages/layerchart/src/lib/states/series.svelte.test.ts b/packages/layerchart/src/lib/states/series.svelte.test.ts index 1afdb215d..40629667c 100644 --- a/packages/layerchart/src/lib/states/series.svelte.test.ts +++ b/packages/layerchart/src/lib/states/series.svelte.test.ts @@ -14,7 +14,7 @@ const series = [ { key: 'apples', color: 'red' }, { key: 'bananas', color: 'yellow' }, { key: 'oranges', color: 'orange' }, -] as const; +]; function createSeriesState( seriesData = series as any[], diff --git a/packages/layerchart/src/lib/utils/canvas.svelte.test.ts b/packages/layerchart/src/lib/utils/canvas.svelte.test.ts index 072ea2059..b9a8fb04d 100644 --- a/packages/layerchart/src/lib/utils/canvas.svelte.test.ts +++ b/packages/layerchart/src/lib/utils/canvas.svelte.test.ts @@ -367,9 +367,9 @@ describe('renderPathData', () => { it('applies strokeOpacity less than 1', () => { const globalAlphaValues: number[] = []; const originalStroke = ctx.stroke.bind(ctx); - vi.spyOn(ctx, 'stroke').mockImplementation((...args: any[]) => { + vi.spyOn(ctx, 'stroke').mockImplementation(function (this: CanvasRenderingContext2D) { globalAlphaValues.push(ctx.globalAlpha); - originalStroke(...args); + originalStroke(); }); renderPathData(ctx, 'M0,0 L100,0', { diff --git a/packages/layerchart/src/lib/utils/canvas.ts b/packages/layerchart/src/lib/utils/canvas.ts index 91f41808b..7365f4b59 100644 --- a/packages/layerchart/src/lib/utils/canvas.ts +++ b/packages/layerchart/src/lib/utils/canvas.ts @@ -18,6 +18,7 @@ function isTransparentFill(fill: string): boolean { const CANVAS_STYLES_ELEMENT_ID = '__layerchart_canvas_styles_id'; + /** * Parse an inline CSS style string into a StyleOptions object. * Converts kebab-case properties to camelCase (e.g., 'stroke-dasharray' -> 'strokeDasharray') @@ -87,6 +88,14 @@ export function _getComputedStyles( canvas: HTMLCanvasElement, { styles, classes }: ComputedStylesOptions = {} ) { + // Server-side: no DOM available, return styles with sensible defaults + if (typeof document === 'undefined') { + const merged = { ...styles } as CSSStyleDeclaration; + if (!merged.fontSize) merged.fontSize = '10px'; + if (!merged.fontFamily) merged.fontFamily = 'sans-serif'; + return merged; + } + // console.count(`getComputedStyles: ${getComputedStylesKey(canvas, { styles, classes })}`); try { // Get or create `` below `` @@ -171,11 +180,19 @@ function render( // TODO: Consider memoizing? How about reactiving to CSS variable changes (light/dark mode toggle) let resolvedStyles: StyleOptions; if ( - styleOptions.classes == null && - !Object.values(mergedStyles).some((v) => typeof v === 'string' && v.includes('var(')) + typeof document === 'undefined' || + (styleOptions.classes == null && + !Object.values(mergedStyles).some((v) => typeof v === 'string' && v.includes('var('))) ) { - // Skip resolving styles if no classes are provided and no styles are using CSS variables + // Skip resolving styles if running on server (no DOM), or no classes are provided and no styles are using CSS variables resolvedStyles = mergedStyles; + + // On server, provide sensible defaults for styles that would normally come from CSS + if (typeof document === 'undefined') { + if (!resolvedStyles.stroke && !resolvedStyles.fill) { + resolvedStyles = { ...resolvedStyles, stroke: 'black' }; + } + } } else { // Remove constant non-css variable properties (ex. `strokeWidth: 0.5`, `fill: #123456`) as not needed and improves memoization cache hit const { constantStyles, variableStyles } = Object.entries(mergedStyles).reduce<{ @@ -210,8 +227,11 @@ function render( // font/text properties can be expensive to set (not sure why), so only apply if needed (renderText()) if (applyText) { - // Text properties - ctx.font = `${resolvedStyles.fontWeight} ${resolvedStyles.fontSize} ${resolvedStyles.fontFamily}`; // build string instead of using `computedStyles.font` to fix/workaround `tabular-nums` returning `null` + // Text properties — use defaults for server-side rendering where computed styles aren't available + const fontSize = resolvedStyles.fontSize || '10px'; + const fontFamily = resolvedStyles.fontFamily || 'sans-serif'; + const fontWeight = resolvedStyles.fontWeight || ''; + ctx.font = `${fontWeight} ${fontSize} ${fontFamily}`.trim(); // build string instead of using `computedStyles.font` to fix/workaround `tabular-nums` returning `null` if (resolvedStyles.textAnchor === 'middle') { ctx.textAlign = 'center'; @@ -248,8 +268,8 @@ function render( if (attr === 'fill') { const fill = styleOptions.styles?.fill && - ((styleOptions.styles?.fill as any) instanceof CanvasGradient || - (styleOptions.styles?.fill as any) instanceof CanvasPattern || + ((typeof CanvasGradient !== 'undefined' && (styleOptions.styles?.fill as any) instanceof CanvasGradient) || + (typeof CanvasPattern !== 'undefined' && (styleOptions.styles?.fill as any) instanceof CanvasPattern) || !styleOptions.styles?.fill?.includes('var')) ? styleOptions.styles.fill : resolvedStyles?.fill; @@ -270,7 +290,7 @@ function render( } else if (attr === 'stroke') { const stroke = styleOptions.styles?.stroke && - ((styleOptions.styles?.stroke as any) instanceof CanvasGradient || + ((typeof CanvasGradient !== 'undefined' && (styleOptions.styles?.stroke as any) instanceof CanvasGradient) || !styleOptions.styles?.stroke?.includes('var')) ? styleOptions.styles?.stroke : resolvedStyles?.stroke; @@ -469,7 +489,7 @@ export function clearCanvasContext( @see: https://web.dev/articles/canvas-hidipi */ export function scaleCanvas(ctx: CanvasRenderingContext2D, width: number, height: number) { - const devicePixelRatio = window.devicePixelRatio || 1; + const devicePixelRatio = typeof window !== 'undefined' ? (window.devicePixelRatio || 1) : 1; ctx.canvas.width = width * devicePixelRatio; ctx.canvas.height = height * devicePixelRatio; @@ -483,7 +503,7 @@ export function scaleCanvas(ctx: CanvasRenderingContext2D, width: number, height /** Get pixel color (r,g,b,a) at canvas coordinates */ export function getPixelColor(ctx: CanvasRenderingContext2D, x: number, y: number) { - const dpr = window.devicePixelRatio ?? 1; + const dpr = (typeof window !== 'undefined' ? window.devicePixelRatio : null) ?? 1; const imageData = ctx.getImageData(x * dpr, y * dpr, 1, 1); const [r, g, b, a] = imageData.data; return { r, g, b, a }; diff --git a/packages/layerchart/src/lib/utils/dataProp.test.ts b/packages/layerchart/src/lib/utils/dataProp.test.ts index 09164ec59..8dd5b60d0 100644 --- a/packages/layerchart/src/lib/utils/dataProp.test.ts +++ b/packages/layerchart/src/lib/utils/dataProp.test.ts @@ -47,7 +47,7 @@ describe('hasAnyDataProp', () => { describe('resolveDataProp', () => { const data = { date: '2024-01-01', value: 42, nested: { x: 10 } }; - const mockScale = vi.fn((v: any) => v * 2); + const mockScale = vi.fn((v: any) => v * 2) as any; beforeEach(() => { mockScale.mockClear(); diff --git a/packages/layerchart/src/lib/utils/motion.svelte.ts b/packages/layerchart/src/lib/utils/motion.svelte.ts index d9aab4984..2a3736a88 100644 --- a/packages/layerchart/src/lib/utils/motion.svelte.ts +++ b/packages/layerchart/src/lib/utils/motion.svelte.ts @@ -161,6 +161,20 @@ function setupTracking( ) { if (options.controlled) return; + // On the server (SSR), $effect won't run, so eagerly set the target value + // to ensure render closures capture the actual computed state (not the initial/baseline value). + if (typeof window === 'undefined') { + try { + const value = getValue(); + if (value != null) { + motion.set(value, { instant: true }); + } + } catch { + // getValue() may fail if reactive dependencies aren't ready yet; ignore + } + return; + } + $effect(() => { const value = getValue(); if (value == null) return; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f2d6fdde..97abb2f4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: '@layerstack/utils': specifier: 2.0.0-next.18 version: 2.0.0-next.18 + '@napi-rs/canvas': + specifier: ^0.1.97 + version: 0.1.97 '@shikijs/transformers': specifier: ^4.0.2 version: 4.0.2 @@ -376,8 +379,8 @@ importers: specifier: ^5.5.19 version: 5.5.19 layerchart: - specifier: next - version: 2.0.0-next.46(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(zod@4.3.6) + specifier: workspace:* + version: link:../../packages/layerchart mode-watcher: specifier: ^1.1.0 version: 1.1.0(svelte@5.54.1) @@ -436,8 +439,8 @@ importers: specifier: ^2.1.1 version: 2.1.1 layerchart: - specifier: next - version: 2.0.0-next.46(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(zod@4.3.6) + specifier: workspace:* + version: link:../../packages/layerchart mode-watcher: specifier: ^1.1.0 version: 1.1.0(svelte@5.54.1) @@ -499,8 +502,8 @@ importers: specifier: ^4.2.2 version: 4.2.2(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) layerchart: - specifier: next - version: 2.0.0-next.46(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(zod@4.3.6) + specifier: workspace:* + version: link:../../packages/layerchart mode-watcher: specifier: ^1.1.0 version: 1.1.0(svelte@5.54.1) @@ -553,8 +556,8 @@ importers: specifier: ^4.2.2 version: 4.2.2(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) layerchart: - specifier: next - version: 2.0.0-next.46(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(zod@4.3.6) + specifier: workspace:* + version: link:../../packages/layerchart prettier: specifier: ^3.8.1 version: 3.8.1 @@ -592,8 +595,8 @@ importers: specifier: ^7.0.0 version: 7.0.0(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) layerchart: - specifier: next - version: 2.0.0-next.46(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(zod@4.3.6) + specifier: workspace:* + version: link:../../packages/layerchart mode-watcher: specifier: ^1.1.0 version: 1.1.0(svelte@5.54.1) @@ -637,8 +640,8 @@ importers: specifier: ^4.2.2 version: 4.2.2(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) layerchart: - specifier: next - version: 2.0.0-next.46(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(zod@4.3.6) + specifier: workspace:* + version: link:../../packages/layerchart prettier: specifier: ^3.8.1 version: 3.8.1 @@ -688,8 +691,8 @@ importers: specifier: ^66.6.7 version: 66.6.7 layerchart: - specifier: next - version: 2.0.0-next.46(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(zod@4.3.6) + specifier: workspace:* + version: link:../../packages/layerchart mode-watcher: specifier: ^1.1.0 version: 1.1.0(svelte@5.54.1) @@ -811,6 +814,9 @@ importers: '@changesets/cli': specifier: ^2.30.0 version: 2.30.0(@types/node@25.5.0) + '@napi-rs/canvas': + specifier: ^0.1.97 + version: 0.1.97 '@sveltejs/adapter-auto': specifier: ^7.0.1 version: 7.0.1(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))) @@ -1955,6 +1961,76 @@ packages: '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} + '@napi-rs/canvas-android-arm64@0.1.97': + resolution: {integrity: sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.97': + resolution: {integrity: sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.97': + resolution: {integrity: sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.97': + resolution: {integrity: sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.97': + resolution: {integrity: sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.97': + resolution: {integrity: sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.97': + resolution: {integrity: sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.97': + resolution: {integrity: sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.97': + resolution: {integrity: sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.97': + resolution: {integrity: sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.97': + resolution: {integrity: sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.97': + resolution: {integrity: sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==} + engines: {node: '>= 10'} + '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} @@ -4268,11 +4344,6 @@ packages: known-css-properties@0.37.0: resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} - layerchart@2.0.0-next.46: - resolution: {integrity: sha512-fpdnvexCG/chonAIunDdLbqrUrD0IZ2v6MG/kbI5v7/yi0bIC47er1Kz31sACQNuoK+sxMIvWeebMHqiQP5XSQ==} - peerDependencies: - svelte: ^5.0.0 - levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -6775,6 +6846,53 @@ snapshots: '@marijn/find-cluster-break@1.0.2': {} + '@napi-rs/canvas-android-arm64@0.1.97': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.97': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.97': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.97': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.97': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.97': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.97': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.97': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.97': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.97': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.97': + optional: true + + '@napi-rs/canvas@0.1.97': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.97 + '@napi-rs/canvas-darwin-arm64': 0.1.97 + '@napi-rs/canvas-darwin-x64': 0.1.97 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.97 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.97 + '@napi-rs/canvas-linux-arm64-musl': 0.1.97 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.97 + '@napi-rs/canvas-linux-x64-gnu': 0.1.97 + '@napi-rs/canvas-linux-x64-musl': 0.1.97 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.97 + '@napi-rs/canvas-win32-x64-msvc': 0.1.97 + '@napi-rs/wasm-runtime@1.1.1': dependencies: '@emnapi/core': 1.9.1 @@ -9449,72 +9567,6 @@ snapshots: known-css-properties@0.37.0: {} - layerchart@2.0.0-next.46(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(zod@4.3.6): - dependencies: - '@dagrejs/dagre': 2.0.4 - '@layerstack/svelte-actions': 1.0.1-next.18 - '@layerstack/svelte-state': 0.1.0-next.23 - '@layerstack/tailwind': 2.0.0-next.21 - '@layerstack/utils': 2.0.0-next.18 - d3-array: 3.2.4 - d3-color: 3.1.0 - d3-delaunay: 6.0.4 - d3-dsv: 3.0.1 - d3-force: 3.0.0 - d3-geo: 3.1.1 - d3-geo-voronoi: 2.1.0 - d3-hierarchy: 3.1.2 - d3-interpolate: 3.0.1 - d3-interpolate-path: 2.3.0 - d3-path: 3.1.0 - d3-quadtree: 3.0.1 - d3-random: 3.0.1 - d3-sankey: 0.12.3 - d3-scale: 4.0.2 - d3-scale-chromatic: 3.1.0 - d3-shape: 3.2.0 - d3-tile: 1.0.0 - d3-time: 3.1.0 - memoize: 10.2.0 - runed: 0.37.1(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(zod@4.3.6) - svelte: 5.54.1 - transitivePeerDependencies: - - '@sveltejs/kit' - - zod - - layerchart@2.0.0-next.46(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(zod@4.3.6): - dependencies: - '@dagrejs/dagre': 2.0.4 - '@layerstack/svelte-actions': 1.0.1-next.18 - '@layerstack/svelte-state': 0.1.0-next.23 - '@layerstack/tailwind': 2.0.0-next.21 - '@layerstack/utils': 2.0.0-next.18 - d3-array: 3.2.4 - d3-color: 3.1.0 - d3-delaunay: 6.0.4 - d3-dsv: 3.0.1 - d3-force: 3.0.0 - d3-geo: 3.1.1 - d3-geo-voronoi: 2.1.0 - d3-hierarchy: 3.1.2 - d3-interpolate: 3.0.1 - d3-interpolate-path: 2.3.0 - d3-path: 3.1.0 - d3-quadtree: 3.0.1 - d3-random: 3.0.1 - d3-sankey: 0.12.3 - d3-scale: 4.0.2 - d3-scale-chromatic: 3.1.0 - d3-shape: 3.2.0 - d3-tile: 1.0.0 - d3-time: 3.1.0 - memoize: 10.2.0 - runed: 0.37.1(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(zod@4.3.6) - svelte: 5.54.1 - transitivePeerDependencies: - - '@sveltejs/kit' - - zod - levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -10544,16 +10596,6 @@ snapshots: optionalDependencies: '@sveltejs/kit': 2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) - runed@0.37.1(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(zod@4.3.6): - dependencies: - dequal: 2.0.3 - esm-env: 1.2.2 - lz-string: 1.5.0 - svelte: 5.54.1 - optionalDependencies: - '@sveltejs/kit': 2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.54.1)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3)) - zod: 4.3.6 - runed@0.37.1(@sveltejs/kit@2.55.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.0.0(svelte@5.54.1)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(typescript@5.9.3)(vite@8.0.1(@types/node@25.5.0)(esbuild@0.27.3)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)))(svelte@5.54.1)(zod@4.3.6): dependencies: dequal: 2.0.3