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
+
+```
+
+
+
+### Bar chart
+
+```svelte
+
+
+
+
+
+
+```
+
+```html
+
+```
+
+
+
+### Area chart (multi-series)
+
+```svelte
+
+
+
+
+
+
+
+
+
+```
+
+```html
+
+```
+
+
+
+### Geo chart
+
+```svelte
+
+
+
+ {#each states.features as feature (feature)}
+
+ {/each}
+
+```
+
+```html
+
+```
+
+
+
+### Scatter chart
+
+```svelte
+
+
+
+
+
+
+```
+
+```html
+
+```
+
+
+
+### 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
+
+```
+
+
+
+### Tree chart
+
+```svelte
+
+
+ {#snippet children({ nodes, links })}
+ {#each links as link}
+
+ {/each}
+ {#each nodes as node}
+
+
+
+
+ {/each}
+ {/snippet}
+
+
+```
+
+```html
+
+```
+
+
+
+### 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
+
+```
+
+
+
+## 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