From e057b4a10e1bad421367c5613deb5d6edc3dd816 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Sat, 4 Apr 2026 15:29:23 -0400 Subject: [PATCH 01/16] Initial progress --- docs/package.json | 1 + docs/src/routes/api/chart/+server.ts | 57 +++++++ docs/src/routes/api/chart/DemoChart.svelte | 34 ++++ docs/src/routes/api/chart/test/+server.ts | 41 +++++ docs/vite.config.ts | 1 + packages/layerchart/package.json | 5 + .../src/lib/components/layers/Canvas.svelte | 113 +++++++------ .../layerchart/src/lib/contexts/canvas.ts | 3 + .../src/lib/server/ContextCapture.svelte | 30 ++++ .../src/lib/server/ServerChart.svelte | 27 ++++ .../layerchart/src/lib/server/captureStore.ts | 35 ++++ packages/layerchart/src/lib/server/index.ts | 151 ++++++++++++++++++ .../layerchart/src/lib/server/renderTree.ts | 29 ++++ packages/layerchart/src/lib/utils/canvas.ts | 17 +- .../layerchart/src/lib/utils/motion.svelte.ts | 14 ++ pnpm-lock.yaml | 148 +++++++++++++++-- 16 files changed, 639 insertions(+), 67 deletions(-) create mode 100644 docs/src/routes/api/chart/+server.ts create mode 100644 docs/src/routes/api/chart/DemoChart.svelte create mode 100644 docs/src/routes/api/chart/test/+server.ts create mode 100644 packages/layerchart/src/lib/server/ContextCapture.svelte create mode 100644 packages/layerchart/src/lib/server/ServerChart.svelte create mode 100644 packages/layerchart/src/lib/server/captureStore.ts create mode 100644 packages/layerchart/src/lib/server/index.ts create mode 100644 packages/layerchart/src/lib/server/renderTree.ts 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/routes/api/chart/+server.ts b/docs/src/routes/api/chart/+server.ts new file mode 100644 index 000000000..886f14d88 --- /dev/null +++ b/docs/src/routes/api/chart/+server.ts @@ -0,0 +1,57 @@ +import { createCanvas, Path2D } from '@napi-rs/canvas'; +import { render } from 'svelte/server'; +import { renderCapturedChart } from 'layerchart/server'; +import type { CanvasFactory, CaptureTarget, CapturedChart } from 'layerchart/server'; +import type { RequestHandler } from './$types'; + +import DemoChart from './DemoChart.svelte'; + +// Register Path2D globally for canvas rendering +if (typeof globalThis.Path2D === 'undefined') { + (globalThis as any).Path2D = Path2D; +} + +// Sample data: a simple sine-wave-like dataset +const data = Array.from({ length: 50 }, (_, i) => ({ + date: i, + value: 50 + 30 * Math.sin(i / 5) + 10 * Math.cos(i / 3) +})); + +function isCapturedChart(capture: CaptureTarget | null): capture is CapturedChart { + return Boolean(capture?.chartState && capture?.rootNode); +} + +const createNodeCanvas: CanvasFactory = (canvasWidth, canvasHeight) => + createCanvas(canvasWidth, canvasHeight) as unknown as ReturnType; + +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 captureTarget: CaptureTarget = {}; + + const rendered = render(DemoChart, { + props: { data, width, height, capture: captureTarget } + }); + void rendered.body; + + const capture = captureTarget; + + if (!isCapturedChart(capture)) { + return new Response('Failed to render chart', { status: 500 }); + } + + const buffer = renderCapturedChart(capture, { + width, + height, + format, + 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/chart/DemoChart.svelte b/docs/src/routes/api/chart/DemoChart.svelte new file mode 100644 index 000000000..0d7dc2c7a --- /dev/null +++ b/docs/src/routes/api/chart/DemoChart.svelte @@ -0,0 +1,34 @@ + + + + + + diff --git a/docs/src/routes/api/chart/test/+server.ts b/docs/src/routes/api/chart/test/+server.ts new file mode 100644 index 000000000..04e83f9c8 --- /dev/null +++ b/docs/src/routes/api/chart/test/+server.ts @@ -0,0 +1,41 @@ +import { render } from 'svelte/server'; +import { createCaptureCallback } from 'layerchart/server'; +import type { CaptureTarget } from 'layerchart/server'; +import type { RequestHandler } from './$types'; +import DemoChart from '../DemoChart.svelte'; + +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 () => { + const { onCapture, getCapture } = createCaptureCallback(); + const captureTarget: CaptureTarget = {}; + + const rendered = render(DemoChart, { + props: { data, width: 800, height: 400, capture: captureTarget, _onCapture: onCapture } + }); + + const capture = getCapture(); + const typedCapture = capture as CaptureTarget | null; + + return new Response( + JSON.stringify({ + htmlLength: rendered.body.length, + canvasCount: (rendered.body.match(/ import type { ComponentNode } from '$lib/contexts/chart.js'; + import type { ChartState } from '$lib/states/chart.svelte.js'; + + type SSRCaptureTarget = { + chartState?: ChartState; + rootNode?: ComponentNode; + }; export type CanvasPropsWithoutHTML = { /** @@ -80,6 +86,12 @@ */ debug?: boolean; + /** @internal Server-side capture target used by layerchart/server. */ + ssrCapture?: SSRCaptureTarget; + + /** @internal Server-side capture callback used by layerchart/server. */ + ssrCaptureCallback?: (data: SSRCaptureTarget) => void; + children?: Snippet< [{ ref: HTMLCanvasElement; canvasContext: CanvasRenderingContext2D | undefined }] >; @@ -87,7 +99,6 @@ export type CanvasProps = CanvasPropsWithoutHTML & Without; - diff --git a/packages/layerchart/src/lib/server/ServerChart.svelte b/packages/layerchart/src/lib/server/ServerChart.svelte new file mode 100644 index 000000000..70baab6e2 --- /dev/null +++ b/packages/layerchart/src/lib/server/ServerChart.svelte @@ -0,0 +1,27 @@ + + + + + {#if children} + {@render children()} + {/if} + + 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..ea915c4fd --- /dev/null +++ b/packages/layerchart/src/lib/server/index.ts @@ -0,0 +1,151 @@ +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 = { + /** Width of the output image in pixels. */ + width: number; + /** Height of the output image in pixels. */ + height: number; + /** 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; + /** + * Canvas factory function. + * + * Example with \@napi-rs/canvas: + * ```ts + * import { createCanvas } from '\@napi-rs/canvas'; + * createCanvas: (w, h) => createCanvas(w, h) + * ``` + */ + createCanvas: CanvasFactory; +}; + +/** + * 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: 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 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. + * + * @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: 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 +): Buffer | Uint8Array { + const { + width, + height, + devicePixelRatio = 1, + format = 'png', + quality = 0.92, + 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; + + // 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/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/utils/canvas.ts b/packages/layerchart/src/lib/utils/canvas.ts index 91f41808b..d59300a59 100644 --- a/packages/layerchart/src/lib/utils/canvas.ts +++ b/packages/layerchart/src/lib/utils/canvas.ts @@ -171,10 +171,11 @@ 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; } else { // Remove constant non-css variable properties (ex. `strokeWidth: 0.5`, `fill: #123456`) as not needed and improves memoization cache hit @@ -248,8 +249,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 +271,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 +470,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 +484,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/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..de7587b08 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 @@ -377,7 +380,7 @@ importers: 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) + version: 2.0.0-next.50(@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) mode-watcher: specifier: ^1.1.0 version: 1.1.0(svelte@5.54.1) @@ -437,7 +440,7 @@ importers: 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) + version: 2.0.0-next.50(@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) mode-watcher: specifier: ^1.1.0 version: 1.1.0(svelte@5.54.1) @@ -500,7 +503,7 @@ importers: 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) + version: 2.0.0-next.50(@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) mode-watcher: specifier: ^1.1.0 version: 1.1.0(svelte@5.54.1) @@ -554,7 +557,7 @@ importers: 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) + version: 2.0.0-next.50(@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) prettier: specifier: ^3.8.1 version: 3.8.1 @@ -593,7 +596,7 @@ importers: 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) + version: 2.0.0-next.50(@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) mode-watcher: specifier: ^1.1.0 version: 1.1.0(svelte@5.54.1) @@ -638,7 +641,7 @@ importers: 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) + version: 2.0.0-next.50(@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) prettier: specifier: ^3.8.1 version: 3.8.1 @@ -689,7 +692,7 @@ importers: 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) + version: 2.0.0-next.50(@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) mode-watcher: specifier: ^1.1.0 version: 1.1.0(svelte@5.54.1) @@ -1955,6 +1958,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,8 +4341,8 @@ 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==} + layerchart@2.0.0-next.50: + resolution: {integrity: sha512-1I2q0oOZO0qQG8tugZcRHsKAygFOZNmzbQMKGvcq9w1k5dvVPEab2Lsgd6qw0u0j1plYk+XvtGHo8OcCPidCFw==} peerDependencies: svelte: ^5.0.0 @@ -6775,6 +6848,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,15 +9569,18 @@ 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): + layerchart@2.0.0-next.50(@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 + '@types/d3-contour': 3.0.6 d3-array: 3.2.4 + d3-chord: 3.0.1 d3-color: 3.1.0 + d3-contour: 4.0.2 d3-delaunay: 6.0.4 d3-dsv: 3.0.1 d3-force: 3.0.0 @@ -9482,15 +9605,18 @@ snapshots: - '@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): + layerchart@2.0.0-next.50(@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 + '@types/d3-contour': 3.0.6 d3-array: 3.2.4 + d3-chord: 3.0.1 d3-color: 3.1.0 + d3-contour: 4.0.2 d3-delaunay: 6.0.4 d3-dsv: 3.0.1 d3-force: 3.0.0 From b2d9d1b3c41a6e30f51a473837bd6b6b3c650362 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Sat, 4 Apr 2026 16:15:41 -0400 Subject: [PATCH 02/16] Add more examples and docs --- docs/src/content/guides/ssr-images.md | 221 ++++++++++++++++++ docs/src/routes/api/chart/+server.ts | 57 ----- docs/src/routes/api/chart/test/+server.ts | 41 ---- docs/src/routes/api/charts/CanvasGrid.svelte | 75 ++++++ docs/src/routes/api/charts/area/+server.ts | 17 ++ .../routes/api/charts/area/AreaChart.svelte | 38 +++ docs/src/routes/api/charts/bar/+server.ts | 22 ++ .../src/routes/api/charts/bar/BarChart.svelte | 37 +++ docs/src/routes/api/charts/geo/+server.ts | 29 +++ .../src/routes/api/charts/geo/GeoChart.svelte | 43 ++++ docs/src/routes/api/charts/line/+server.ts | 16 ++ .../line/LineChart.svelte} | 12 +- .../routes/api/charts/renderChartEndpoint.ts | 39 ++++ docs/src/routes/api/charts/scatter/+server.ts | 22 ++ .../api/charts/scatter/ScatterChart.svelte | 34 +++ .../src/lib/server/ServerChart.svelte | 7 +- packages/layerchart/src/lib/server/index.ts | 81 ++++++- 17 files changed, 676 insertions(+), 115 deletions(-) create mode 100644 docs/src/content/guides/ssr-images.md delete mode 100644 docs/src/routes/api/chart/+server.ts delete mode 100644 docs/src/routes/api/chart/test/+server.ts create mode 100644 docs/src/routes/api/charts/CanvasGrid.svelte create mode 100644 docs/src/routes/api/charts/area/+server.ts create mode 100644 docs/src/routes/api/charts/area/AreaChart.svelte create mode 100644 docs/src/routes/api/charts/bar/+server.ts create mode 100644 docs/src/routes/api/charts/bar/BarChart.svelte create mode 100644 docs/src/routes/api/charts/geo/+server.ts create mode 100644 docs/src/routes/api/charts/geo/GeoChart.svelte create mode 100644 docs/src/routes/api/charts/line/+server.ts rename docs/src/routes/api/{chart/DemoChart.svelte => charts/line/LineChart.svelte} (65%) create mode 100644 docs/src/routes/api/charts/renderChartEndpoint.ts create mode 100644 docs/src/routes/api/charts/scatter/+server.ts create mode 100644 docs/src/routes/api/charts/scatter/ScatterChart.svelte diff --git a/docs/src/content/guides/ssr-images.md b/docs/src/content/guides/ssr-images.md new file mode 100644 index 000000000..a723a89f2 --- /dev/null +++ b/docs/src/content/guides/ssr-images.md @@ -0,0 +1,221 @@ +--- +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 +}); +``` + +| 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 | + +### `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 | + +## 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 | `LinearGradient`, `RadialGradient`, `Pattern`, `ClipPath` | +| Yes | `GeoPath` (via `Path` canvas render) | +| No | `Axis`, `Grid`, `Rule` (SVG-only) | +| No | `Tooltip`, `Legend`, `Highlight` (interactive/DOM) | + +\* `Text` requires DOM for font resolution via `getComputedStyles`. When rendering server-side, text will use fallback font metrics. For reliable text, set explicit font styles. + +## Tips + +### Grid lines without `Grid` + +Since `Grid` and `Axis` are SVG-only, you can draw grid lines using `Line` which has canvas support: + +```svelte + + +{#each ticks as tick} + +{/each} +``` + +### 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, + // ... +}); +``` + +### 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/chart/+server.ts b/docs/src/routes/api/chart/+server.ts deleted file mode 100644 index 886f14d88..000000000 --- a/docs/src/routes/api/chart/+server.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { createCanvas, Path2D } from '@napi-rs/canvas'; -import { render } from 'svelte/server'; -import { renderCapturedChart } from 'layerchart/server'; -import type { CanvasFactory, CaptureTarget, CapturedChart } from 'layerchart/server'; -import type { RequestHandler } from './$types'; - -import DemoChart from './DemoChart.svelte'; - -// Register Path2D globally for canvas rendering -if (typeof globalThis.Path2D === 'undefined') { - (globalThis as any).Path2D = Path2D; -} - -// Sample data: a simple sine-wave-like dataset -const data = Array.from({ length: 50 }, (_, i) => ({ - date: i, - value: 50 + 30 * Math.sin(i / 5) + 10 * Math.cos(i / 3) -})); - -function isCapturedChart(capture: CaptureTarget | null): capture is CapturedChart { - return Boolean(capture?.chartState && capture?.rootNode); -} - -const createNodeCanvas: CanvasFactory = (canvasWidth, canvasHeight) => - createCanvas(canvasWidth, canvasHeight) as unknown as ReturnType; - -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 captureTarget: CaptureTarget = {}; - - const rendered = render(DemoChart, { - props: { data, width, height, capture: captureTarget } - }); - void rendered.body; - - const capture = captureTarget; - - if (!isCapturedChart(capture)) { - return new Response('Failed to render chart', { status: 500 }); - } - - const buffer = renderCapturedChart(capture, { - width, - height, - format, - 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/chart/test/+server.ts b/docs/src/routes/api/chart/test/+server.ts deleted file mode 100644 index 04e83f9c8..000000000 --- a/docs/src/routes/api/chart/test/+server.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { render } from 'svelte/server'; -import { createCaptureCallback } from 'layerchart/server'; -import type { CaptureTarget } from 'layerchart/server'; -import type { RequestHandler } from './$types'; -import DemoChart from '../DemoChart.svelte'; - -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 () => { - const { onCapture, getCapture } = createCaptureCallback(); - const captureTarget: CaptureTarget = {}; - - const rendered = render(DemoChart, { - props: { data, width: 800, height: 400, capture: captureTarget, _onCapture: onCapture } - }); - - const capture = getCapture(); - const typedCapture = capture as CaptureTarget | null; - - return new Response( - JSON.stringify({ - htmlLength: rendered.body.length, - canvasCount: (rendered.body.match(/ + import { getChartContext, isScaleBand, Line } from 'layerchart'; + + let { + xTicks: xTicksProp, + yTicks: yTicksProp, + gridColor = 'rgba(0,0,0,0.08)', + tickColor = 'rgba(0,0,0,0.4)', + showXAxis = true, + showYAxis = true, + showGrid = true + }: { + xTicks?: number; + yTicks?: number; + gridColor?: string; + tickColor?: string; + showXAxis?: boolean; + showYAxis?: boolean; + showGrid?: boolean; + } = $props(); + + const ctx = getChartContext(); + + const yTickValues = $derived.by(() => { + if (isScaleBand(ctx.yScale)) { + return ctx.yScale.domain(); + } + return ctx.yScale.ticks?.(yTicksProp ?? 5) ?? []; + }); + + const xTickValues = $derived.by(() => { + if (isScaleBand(ctx.xScale)) { + return ctx.xScale.domain(); + } + return ctx.xScale.ticks?.(xTicksProp ?? 5) ?? []; + }); + + function yPos(tick: any) { + const val = ctx.yScale(tick); + return isScaleBand(ctx.yScale) ? val + ctx.yScale.bandwidth() / 2 : val; + } + + + +{#if showGrid} + {#each yTickValues as tick} + + {/each} +{/if} + + +{#if showXAxis} + +{/if} + + +{#if showYAxis} + +{/if} + + +{#if showYAxis} + {#each yTickValues as tick} + + {/each} +{/if} + + +{#if showXAxis} + {#each xTickValues as tick} + {@const val = ctx.xScale(tick)} + {@const x = isScaleBand(ctx.xScale) ? val + ctx.xScale.bandwidth() / 2 : val} + + {/each} +{/if} 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..6b9f60284 --- /dev/null +++ b/docs/src/routes/api/charts/area/+server.ts @@ -0,0 +1,17 @@ +import { renderChartResponse } from '../renderChartEndpoint.js'; +import type { RequestHandler } from './$types'; +import AreaChart from './AreaChart.svelte'; + +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..fe0364b39 --- /dev/null +++ b/docs/src/routes/api/charts/area/AreaChart.svelte @@ -0,0 +1,38 @@ + + + + + + + + + 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..b5917a7c3 --- /dev/null +++ b/docs/src/routes/api/charts/bar/+server.ts @@ -0,0 +1,22 @@ +import { renderChartResponse } from '../renderChartEndpoint.js'; +import type { RequestHandler } from './$types'; +import BarChart from './BarChart.svelte'; + +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..0bca9ab50 --- /dev/null +++ b/docs/src/routes/api/charts/bar/BarChart.svelte @@ -0,0 +1,37 @@ + + + + + + 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..fe6f5818d --- /dev/null +++ b/docs/src/routes/api/charts/geo/+server.ts @@ -0,0 +1,29 @@ +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'; + +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..0fb825d92 --- /dev/null +++ b/docs/src/routes/api/charts/line/+server.ts @@ -0,0 +1,16 @@ +import { renderChartResponse } from '../renderChartEndpoint.js'; +import type { RequestHandler } from './$types'; +import LineChart from './LineChart.svelte'; + +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/chart/DemoChart.svelte b/docs/src/routes/api/charts/line/LineChart.svelte similarity index 65% rename from docs/src/routes/api/chart/DemoChart.svelte rename to docs/src/routes/api/charts/line/LineChart.svelte index 0d7dc2c7a..76b1da81d 100644 --- a/docs/src/routes/api/chart/DemoChart.svelte +++ b/docs/src/routes/api/charts/line/LineChart.svelte @@ -2,33 +2,35 @@ import { ServerChart } from 'layerchart/server'; import type { CaptureTarget } from 'layerchart/server'; import { Area, Spline } from 'layerchart'; + import CanvasGrid from '../CanvasGrid.svelte'; let { data, width, height, capture, - _onCapture + onCapture }: { data: { date: number; value: number }[]; width: number; height: number; capture?: CaptureTarget; - _onCapture?: (data: CaptureTarget) => void; + onCapture?: (data: CaptureTarget) => void; } = $props(); - + + diff --git a/docs/src/routes/api/charts/renderChartEndpoint.ts b/docs/src/routes/api/charts/renderChartEndpoint.ts new file mode 100644 index 000000000..a5de7629f --- /dev/null +++ b/docs/src/routes/api/charts/renderChartEndpoint.ts @@ -0,0 +1,39 @@ +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; + +type RenderChartResponseOptions = { + component: Component; + props: Record; + url: URL; +}; + +export function renderChartResponse({ component, props, url }: RenderChartResponseOptions): Response { + 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(component, { + width, + height, + format, + 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/scatter/+server.ts b/docs/src/routes/api/charts/scatter/+server.ts new file mode 100644 index 000000000..35f77a355 --- /dev/null +++ b/docs/src/routes/api/charts/scatter/+server.ts @@ -0,0 +1,22 @@ +import { renderChartResponse } from '../renderChartEndpoint.js'; +import type { RequestHandler } from './$types'; +import ScatterChart from './ScatterChart.svelte'; + +// 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..6a1c65bec --- /dev/null +++ b/docs/src/routes/api/charts/scatter/ScatterChart.svelte @@ -0,0 +1,34 @@ + + + + + + diff --git a/packages/layerchart/src/lib/server/ServerChart.svelte b/packages/layerchart/src/lib/server/ServerChart.svelte index 70baab6e2..77bd34564 100644 --- a/packages/layerchart/src/lib/server/ServerChart.svelte +++ b/packages/layerchart/src/lib/server/ServerChart.svelte @@ -8,18 +8,17 @@ let { children, capture, - _onCapture, + onCapture, ...chartProps }: { children?: Snippet; capture?: CaptureTarget; - /** @internal Callback used by server-side render helpers to capture chart state during SSR. */ - _onCapture?: (data: CaptureTarget) => void; + onCapture?: (data: CaptureTarget) => void; } & Omit, 'children'> = $props(); - + {#if children} {@render children()} {/if} diff --git a/packages/layerchart/src/lib/server/index.ts b/packages/layerchart/src/lib/server/index.ts index ea915c4fd..6577dde0a 100644 --- a/packages/layerchart/src/lib/server/index.ts +++ b/packages/layerchart/src/lib/server/index.ts @@ -1,3 +1,5 @@ +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'; @@ -29,10 +31,6 @@ export type CanvasFactory = ( }; export type RenderOptions = { - /** Width of the output image in pixels. */ - width: number; - /** Height of the output image in pixels. */ - height: number; /** Pixel ratio for high-DPI output. @default 1 */ devicePixelRatio?: number; /** Output format. @default 'png' */ @@ -51,9 +49,18 @@ export type RenderOptions = { 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. + * 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. * @@ -64,7 +71,7 @@ export type RenderOptions = { * import MyChart from './MyChart.svelte'; * * const { onCapture, getCapture } = createCaptureCallback(); - * const rendered = render(MyChart, { props: { data, width: 800, height: 400, _onCapture: onCapture } }); + * 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(); * ``` @@ -79,11 +86,69 @@ export function createCaptureCallback() { }; } +/** + * 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'; @@ -96,7 +161,7 @@ export function createCaptureCallback() { * * // Build component tree via SSR render * const { onCapture, getCapture } = createCaptureCallback(); - * const rendered = render(MyChart, { props: { data, width: 800, height: 400, _onCapture: onCapture } }); + * 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 @@ -109,7 +174,7 @@ export function createCaptureCallback() { */ export function renderCapturedChart( capture: CapturedChart, - options: RenderOptions + options: RenderOptions & { width: number; height: number } ): Buffer | Uint8Array { const { width, From ba277f96b158a1739c39fe8f4d25e028a3f997dc Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Sat, 4 Apr 2026 16:19:05 -0400 Subject: [PATCH 03/16] Support passing background color as query param --- docs/src/content/guides/ssr-images.md | 17 +++++++++++++++++ .../routes/api/charts/renderChartEndpoint.ts | 2 ++ packages/layerchart/src/lib/server/index.ts | 14 ++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/docs/src/content/guides/ssr-images.md b/docs/src/content/guides/ssr-images.md index a723a89f2..05c8aec5e 100644 --- a/docs/src/content/guides/ssr-images.md +++ b/docs/src/content/guides/ssr-images.md @@ -129,6 +129,7 @@ const buffer = renderChart(MyChart, { format: 'png', // 'png' | 'jpeg' quality: 0.92, // JPEG quality (0-1) devicePixelRatio: 2, // High-DPI output + background: 'white', // Background color (transparent by default) }); ``` @@ -141,6 +142,7 @@ const buffer = renderChart(MyChart, { | `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)` @@ -195,6 +197,21 @@ Since `Grid` and `Axis` are SVG-only, you can draw grid lines using `Line` which {/each} ``` +### 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): diff --git a/docs/src/routes/api/charts/renderChartEndpoint.ts b/docs/src/routes/api/charts/renderChartEndpoint.ts index a5de7629f..21c3f1069 100644 --- a/docs/src/routes/api/charts/renderChartEndpoint.ts +++ b/docs/src/routes/api/charts/renderChartEndpoint.ts @@ -21,11 +21,13 @@ export function renderChartResponse({ component, props, url }: RenderChartRespon 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 background = url.searchParams.get('background') ?? undefined; const buffer = renderChart(component, { width, height, format, + background, props, createCanvas: createNodeCanvas }); diff --git a/packages/layerchart/src/lib/server/index.ts b/packages/layerchart/src/lib/server/index.ts index 6577dde0a..889f93fdc 100644 --- a/packages/layerchart/src/lib/server/index.ts +++ b/packages/layerchart/src/lib/server/index.ts @@ -37,6 +37,13 @@ export type RenderOptions = { 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. * @@ -182,6 +189,7 @@ export function renderCapturedChart( devicePixelRatio = 1, format = 'png', quality = 0.92, + background, createCanvas, } = options; @@ -191,6 +199,12 @@ export function renderCapturedChart( 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); From 2958bf5a178729c8d6366b3591864fa3d5d0ff7d Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Sat, 4 Apr 2026 21:59:03 -0400 Subject: [PATCH 04/16] Fix SSR image support Grid, Axis, and Rule. Add tests. General improvements --- .changeset/axis-grid-canvas-support.md | 5 + .changeset/axis-grid-stroke-fill-props.md | 5 + .changeset/server-chart-rendering.md | 5 + docs/src/content/guides/ssr-images.md | 317 ++++++++++++------ docs/src/routes/api/charts/CanvasGrid.svelte | 75 ----- .../routes/api/charts/area/AreaChart.svelte | 11 +- .../src/routes/api/charts/bar/BarChart.svelte | 7 +- .../routes/api/charts/line/LineChart.svelte | 10 +- .../routes/api/charts/renderChartEndpoint.ts | 6 +- .../api/charts/scatter/ScatterChart.svelte | 9 +- packages/layerchart/package.json | 1 + .../layerchart/src/lib/components/Axis.svelte | 25 ++ .../layerchart/src/lib/components/Grid.svelte | 15 + .../layerchart/src/lib/components/Rule.svelte | 2 + .../src/lib/server/TestBarChart.svelte | 35 ++ .../src/lib/server/TestLineChart.svelte | 35 ++ .../src/lib/server/renderChart.ssr.test.ts | 257 ++++++++++++++ packages/layerchart/src/lib/utils/canvas.ts | 23 +- pnpm-lock.yaml | 3 + 19 files changed, 647 insertions(+), 199 deletions(-) create mode 100644 .changeset/axis-grid-canvas-support.md create mode 100644 .changeset/axis-grid-stroke-fill-props.md create mode 100644 .changeset/server-chart-rendering.md delete mode 100644 docs/src/routes/api/charts/CanvasGrid.svelte create mode 100644 packages/layerchart/src/lib/server/TestBarChart.svelte create mode 100644 packages/layerchart/src/lib/server/TestLineChart.svelte create mode 100644 packages/layerchart/src/lib/server/renderChart.ssr.test.ts diff --git a/.changeset/axis-grid-canvas-support.md b/.changeset/axis-grid-canvas-support.md new file mode 100644 index 000000000..15ace0e80 --- /dev/null +++ b/.changeset/axis-grid-canvas-support.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat: Add canvas rendering support for `Axis`, `Grid`, and `Rule` components, enabling server-side image rendering with axes and grid lines diff --git a/.changeset/axis-grid-stroke-fill-props.md b/.changeset/axis-grid-stroke-fill-props.md new file mode 100644 index 000000000..1d902022a --- /dev/null +++ b/.changeset/axis-grid-stroke-fill-props.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +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/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/docs/src/content/guides/ssr-images.md b/docs/src/content/guides/ssr-images.md index 05c8aec5e..790809d2e 100644 --- a/docs/src/content/guides/ssr-images.md +++ b/docs/src/content/guides/ssr-images.md @@ -29,42 +29,45 @@ 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 @@ -79,34 +82,35 @@ import MyLineChart from '$lib/charts/MyLineChart.svelte'; // Register Path2D globally (required once) if (typeof globalThis.Path2D === 'undefined') { - (globalThis as any).Path2D = Path2D; + (globalThis as any).Path2D = Path2D; } const data = Array.from({ length: 50 }, (_, i) => ({ - date: i, - value: 50 + 30 * Math.sin(i / 5) + 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}` } - }); + 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 @@ -121,28 +125,28 @@ The simplest way to render a chart to an image buffer. Handles SSR render, captu 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) + 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. | +| 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)` @@ -152,61 +156,166 @@ Lower-level function for advanced use cases where you need control over the SSR 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 | +| Prop | Type | Description | +| ----------- | ---------------- | -------------------------------------------- | +| `capture` | `CaptureTarget` | Object populated with chart state during SSR | +| `onCapture` | `(data) => void` | Callback alternative to the `capture` prop | + +## Examples + +These examples are rendered live from the API endpoints in this project. + +### Line chart + +```svelte + + + + + + +``` + +```html + +``` + +![Line chart](/api/charts/line?background=white) + +### Bar chart + +```svelte + + + + + + +``` + +```html + +``` + +![Bar chart](/api/charts/bar?background=white) + +### Area chart (multi-series) + +```svelte + + + + + + + + + +``` + +```html + +``` + +![Area chart](/api/charts/area?background=white) + +### Scatter chart + +```svelte + + + + + + +``` + +```html + +``` + +![Scatter chart](/api/charts/scatter?background=white) ## 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 | `LinearGradient`, `RadialGradient`, `Pattern`, `ClipPath` | -| Yes | `GeoPath` (via `Path` canvas render) | -| No | `Axis`, `Grid`, `Rule` (SVG-only) | -| No | `Tooltip`, `Legend`, `Highlight` (interactive/DOM) | +| 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) | -\* `Text` requires DOM for font resolution via `getComputedStyles`. When rendering server-side, text will use fallback font metrics. For reliable text, set explicit font styles. +> **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 -### Grid lines without `Grid` +### Styling Axis and Grid -Since `Grid` and `Axis` are SVG-only, you can draw grid lines using `Line` which has canvas support: +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 - - -{#each ticks as tick} - -{/each} + + + ``` +- **`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', - // ... + width: 800, + height: 400, + background: 'white' + // ... }); ``` @@ -218,10 +327,10 @@ Pass `devicePixelRatio: 2` for retina-quality output (doubles the canvas resolut ```ts const buffer = renderChart(MyChart, { - width: 800, - height: 400, - devicePixelRatio: 2, - // ... + width: 800, + height: 400, + devicePixelRatio: 2 + // ... }); ``` diff --git a/docs/src/routes/api/charts/CanvasGrid.svelte b/docs/src/routes/api/charts/CanvasGrid.svelte deleted file mode 100644 index 1d91aaaab..000000000 --- a/docs/src/routes/api/charts/CanvasGrid.svelte +++ /dev/null @@ -1,75 +0,0 @@ - - - -{#if showGrid} - {#each yTickValues as tick} - - {/each} -{/if} - - -{#if showXAxis} - -{/if} - - -{#if showYAxis} - -{/if} - - -{#if showYAxis} - {#each yTickValues as tick} - - {/each} -{/if} - - -{#if showXAxis} - {#each xTickValues as tick} - {@const val = ctx.xScale(tick)} - {@const x = isScaleBand(ctx.xScale) ? val + ctx.xScale.bandwidth() / 2 : val} - - {/each} -{/if} diff --git a/docs/src/routes/api/charts/area/AreaChart.svelte b/docs/src/routes/api/charts/area/AreaChart.svelte index fe0364b39..ce11a9d3d 100644 --- a/docs/src/routes/api/charts/area/AreaChart.svelte +++ b/docs/src/routes/api/charts/area/AreaChart.svelte @@ -1,8 +1,7 @@ + + + + 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/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/utils/canvas.ts b/packages/layerchart/src/lib/utils/canvas.ts index d59300a59..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 `` @@ -177,6 +186,13 @@ function render( ) { // 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<{ @@ -211,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'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index de7587b08..01e815b6f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -814,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))) From 927aadffe8103598202117fb1a423b49e6f5e658 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Sat, 4 Apr 2026 22:09:04 -0400 Subject: [PATCH 05/16] fix `pnpm check` --- packages/layerchart/src/lib/components/Image.svelte | 4 ++-- .../layerchart/src/lib/components/Line.svelte.test.ts | 11 ++++++----- packages/layerchart/src/lib/components/Points.svelte | 4 ++-- .../src/lib/components/charts/PieChart.svelte | 4 ++-- packages/layerchart/src/lib/states/chart.svelte.ts | 8 ++++---- .../layerchart/src/lib/states/series.svelte.test.ts | 2 +- .../layerchart/src/lib/utils/canvas.svelte.test.ts | 4 ++-- packages/layerchart/src/lib/utils/dataProp.test.ts | 2 +- 8 files changed, 20 insertions(+), 19 deletions(-) diff --git a/packages/layerchart/src/lib/components/Image.svelte b/packages/layerchart/src/lib/components/Image.svelte index 2cd983ff1..41242608d 100644 --- a/packages/layerchart/src/lib/components/Image.svelte +++ b/packages/layerchart/src/lib/components/Image.svelte @@ -514,7 +514,7 @@ style:object-fit="cover" crossorigin={crossOrigin} class={cls('lc-image', className)} - {...restProps} + {...restProps as any} /> {/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.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/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/states/chart.svelte.ts b/packages/layerchart/src/lib/states/chart.svelte.ts index 47f2b1da7..b1adacf9b 100644 --- a/packages/layerchart/src/lib/states/chart.svelte.ts +++ b/packages/layerchart/src/lib/states/chart.svelte.ts @@ -1228,8 +1228,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) { @@ -1241,8 +1241,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/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/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(); From cc5a23fa2774b101f9d341e08bd2ac25cba5ff9b Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Sat, 4 Apr 2026 23:14:03 -0400 Subject: [PATCH 06/16] Prerender chart API endpoints for Cloudflare compatibility and document edge runtime limitations --- docs/src/content/guides/ssr-images.md | 33 ++++++++++++++----- docs/src/routes/api/charts/area/+server.ts | 2 ++ docs/src/routes/api/charts/bar/+server.ts | 2 ++ docs/src/routes/api/charts/geo/+server.ts | 2 ++ docs/src/routes/api/charts/line/+server.ts | 2 ++ .../routes/api/charts/renderChartEndpoint.ts | 2 +- docs/src/routes/api/charts/scatter/+server.ts | 2 ++ 7 files changed, 36 insertions(+), 9 deletions(-) diff --git a/docs/src/content/guides/ssr-images.md b/docs/src/content/guides/ssr-images.md index 790809d2e..365d712f0 100644 --- a/docs/src/content/guides/ssr-images.md +++ b/docs/src/content/guides/ssr-images.md @@ -187,10 +187,10 @@ These examples are rendered live from the API endpoints in this project. ``` ```html - + ``` -![Line chart](/api/charts/line?background=white) +![Line chart](/api/charts/line) ### Bar chart @@ -215,10 +215,10 @@ These examples are rendered live from the API endpoints in this project. ``` ```html - + ``` -![Bar chart](/api/charts/bar?background=white) +![Bar chart](/api/charts/bar) ### Area chart (multi-series) @@ -245,10 +245,10 @@ These examples are rendered live from the API endpoints in this project. ``` ```html - + ``` -![Area chart](/api/charts/area?background=white) +![Area chart](/api/charts/area) ### Scatter chart @@ -271,10 +271,10 @@ These examples are rendered live from the API endpoints in this project. ``` ```html - + ``` -![Scatter chart](/api/charts/scatter?background=white) +![Scatter chart](/api/charts/scatter) ## Supported components @@ -334,6 +334,23 @@ const buffer = renderChart(MyChart, { }); ``` +### 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. diff --git a/docs/src/routes/api/charts/area/+server.ts b/docs/src/routes/api/charts/area/+server.ts index 6b9f60284..8d0435771 100644 --- a/docs/src/routes/api/charts/area/+server.ts +++ b/docs/src/routes/api/charts/area/+server.ts @@ -2,6 +2,8 @@ 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), diff --git a/docs/src/routes/api/charts/bar/+server.ts b/docs/src/routes/api/charts/bar/+server.ts index b5917a7c3..80aa49516 100644 --- a/docs/src/routes/api/charts/bar/+server.ts +++ b/docs/src/routes/api/charts/bar/+server.ts @@ -2,6 +2,8 @@ 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 }, diff --git a/docs/src/routes/api/charts/geo/+server.ts b/docs/src/routes/api/charts/geo/+server.ts index fe6f5818d..cd6b635c9 100644 --- a/docs/src/routes/api/charts/geo/+server.ts +++ b/docs/src/routes/api/charts/geo/+server.ts @@ -5,6 +5,8 @@ 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) { diff --git a/docs/src/routes/api/charts/line/+server.ts b/docs/src/routes/api/charts/line/+server.ts index 0fb825d92..3b7efc7f1 100644 --- a/docs/src/routes/api/charts/line/+server.ts +++ b/docs/src/routes/api/charts/line/+server.ts @@ -2,6 +2,8 @@ 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) diff --git a/docs/src/routes/api/charts/renderChartEndpoint.ts b/docs/src/routes/api/charts/renderChartEndpoint.ts index f6d5735eb..7c530f2e9 100644 --- a/docs/src/routes/api/charts/renderChartEndpoint.ts +++ b/docs/src/routes/api/charts/renderChartEndpoint.ts @@ -25,7 +25,7 @@ export function renderChartResponse({ 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 background = url.searchParams.get('background') ?? undefined; + const background = url.searchParams.get('background') ?? 'white'; const buffer = renderChart(component, { width, diff --git a/docs/src/routes/api/charts/scatter/+server.ts b/docs/src/routes/api/charts/scatter/+server.ts index 35f77a355..a6156aca3 100644 --- a/docs/src/routes/api/charts/scatter/+server.ts +++ b/docs/src/routes/api/charts/scatter/+server.ts @@ -2,6 +2,8 @@ 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; From a17672ddee9bbd0b4696c1f0d39b08aff707cbff Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Sat, 4 Apr 2026 23:26:30 -0400 Subject: [PATCH 07/16] Skip url.searchParams during prerendering to avoid SvelteKit build error --- docs/src/routes/api/charts/renderChartEndpoint.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/src/routes/api/charts/renderChartEndpoint.ts b/docs/src/routes/api/charts/renderChartEndpoint.ts index 7c530f2e9..bd0868f9e 100644 --- a/docs/src/routes/api/charts/renderChartEndpoint.ts +++ b/docs/src/routes/api/charts/renderChartEndpoint.ts @@ -1,3 +1,4 @@ +import { building } from '$app/environment'; import { createCanvas, Path2D } from '@napi-rs/canvas'; import { renderChart } from 'layerchart/server'; import type { CanvasFactory } from 'layerchart/server'; @@ -11,6 +12,11 @@ if (typeof globalThis.Path2D === 'undefined') { 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; @@ -22,10 +28,10 @@ export function renderChartResponse({ props, url }: RenderChartResponseOptions): Response { - 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 background = url.searchParams.get('background') ?? 'white'; + 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, From 99d5868d8b6c92e02f4567b18f768b3093d5fd34 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Mon, 6 Apr 2026 20:13:13 -0400 Subject: [PATCH 08/16] Fix geo chart server-side rendering by converting GeoState projection from $effect.pre to $derived.by --- .../layerchart/src/lib/states/chart.svelte.ts | 8 +- .../layerchart/src/lib/states/geo.svelte.ts | 160 +++++++++--------- 2 files changed, 89 insertions(+), 79 deletions(-) diff --git a/packages/layerchart/src/lib/states/chart.svelte.ts b/packages/layerchart/src/lib/states/chart.svelte.ts index b572578b9..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. 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]; } From cc06949ca0e9c97921fae669fb82250d241214fe Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Mon, 6 Apr 2026 20:15:04 -0400 Subject: [PATCH 09/16] cleanup old changes.md --- changes.md | 197 ----------------------------------------------------- 1 file changed, 197 deletions(-) delete mode 100644 changes.md 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"`. From 4837248984088c336f2dc8c2c87e3c40eb763bec Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Mon, 6 Apr 2026 20:15:34 -0400 Subject: [PATCH 10/16] Add geo SSR image example --- docs/src/content/guides/ssr-images.md | 54 +++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/docs/src/content/guides/ssr-images.md b/docs/src/content/guides/ssr-images.md index 365d712f0..6662e0154 100644 --- a/docs/src/content/guides/ssr-images.md +++ b/docs/src/content/guides/ssr-images.md @@ -250,6 +250,60 @@ These examples are rendered live from the API endpoints in this project. ![Area chart](/api/charts/area) +### Geo chart + +```svelte + + + + {#each states.features as feature (feature)} + + {/each} + +``` + +```html + +``` + +![Geo chart](/api/charts/geo) + ### Scatter chart ```svelte From 168b2684d0fe629670f1b203457eb4d57c7e035f Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Mon, 6 Apr 2026 20:26:49 -0400 Subject: [PATCH 11/16] Add Sankey, Tree, and Treemap examples --- docs/src/content/guides/ssr-images.md | 128 ++++++++++++++++++ docs/src/routes/api/charts/sankey/+server.ts | 45 ++++++ .../api/charts/sankey/SankeyChart.svelte | 50 +++++++ docs/src/routes/api/charts/tree/+server.ts | 47 +++++++ .../routes/api/charts/tree/TreeChart.svelte | 75 ++++++++++ docs/src/routes/api/charts/treemap/+server.ts | 63 +++++++++ .../api/charts/treemap/TreemapChart.svelte | 77 +++++++++++ .../src/lib/components/Sankey.svelte | 2 - 8 files changed, 485 insertions(+), 2 deletions(-) create mode 100644 docs/src/routes/api/charts/sankey/+server.ts create mode 100644 docs/src/routes/api/charts/sankey/SankeyChart.svelte create mode 100644 docs/src/routes/api/charts/tree/+server.ts create mode 100644 docs/src/routes/api/charts/tree/TreeChart.svelte create mode 100644 docs/src/routes/api/charts/treemap/+server.ts create mode 100644 docs/src/routes/api/charts/treemap/TreemapChart.svelte diff --git a/docs/src/content/guides/ssr-images.md b/docs/src/content/guides/ssr-images.md index 6662e0154..c803f8fa4 100644 --- a/docs/src/content/guides/ssr-images.md +++ b/docs/src/content/guides/ssr-images.md @@ -330,6 +330,134 @@ These examples are rendered live from the API endpoints in this project. ![Scatter chart](/api/charts/scatter) +### Sankey chart + +```svelte + + d.id}> + {#snippet children({ links, nodes })} + {#each links as link} + + {/each} + {#each nodes as node (node.id)} + {@const nodeWidth = (node.x1 ?? 0) - (node.x0 ?? 0)} + {@const nodeHeight = (node.y1 ?? 0) - (node.y0 ?? 0)} + + + + + {/each} + {/snippet} + + +``` + +```html + +``` + +![Sankey chart](/api/charts/sankey) + +### Tree chart + +```svelte + + + {#snippet children({ nodes, links })} + {#each links as link} + + {/each} + {#each nodes as node} + + + + + {/each} + {/snippet} + + +``` + +```html + +``` + +![Tree chart](/api/charts/tree) + +### Treemap chart + +```svelte + + + {#snippet children({ nodes })} + {#each nodes as node} + {@const nodeWidth = node.x1 - node.x0} + {@const nodeHeight = node.y1 - node.y0} + {@const nodeColor = getNodeColor(node)} + + + + + {/each} + {/snippet} + + +``` + +```html + +``` + +![Treemap chart](/api/charts/treemap) + ## Supported components Server-side rendering works with components that have **canvas rendering support**. Most primitive and data mark components work: 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/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..a091e13c7 --- /dev/null +++ b/docs/src/routes/api/charts/treemap/TreemapChart.svelte @@ -0,0 +1,77 @@ + + + + + {#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/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]) From ddf18d43b113131fa1b56329a36759aa58a91624 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Mon, 6 Apr 2026 22:50:55 -0400 Subject: [PATCH 12/16] Improve treemap example (text/clip) --- .../api/charts/treemap/TreemapChart.svelte | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/docs/src/routes/api/charts/treemap/TreemapChart.svelte b/docs/src/routes/api/charts/treemap/TreemapChart.svelte index a091e13c7..9d908151b 100644 --- a/docs/src/routes/api/charts/treemap/TreemapChart.svelte +++ b/docs/src/routes/api/charts/treemap/TreemapChart.svelte @@ -7,7 +7,7 @@ import { ServerChart } from 'layerchart/server'; import type { CaptureTarget } from 'layerchart/server'; - import { Group, Rect, Text, Treemap } from 'layerchart'; + import { Group, Rect, RectClipPath, Text, Treemap } from 'layerchart'; let { data, @@ -44,12 +44,7 @@ {height} padding={{ top: 4, right: 4, bottom: 4, left: 4 }} > - + {#snippet children({ nodes })} {#each nodes as node} {@const nodeWidth = node.x1 - node.x0} @@ -64,12 +59,9 @@ fillOpacity={node.children ? 0.5 : 1} rx={5} /> - + + + {/each} {/snippet} From 33c3ccdad179fbb29fc34c1f36b01381d9cf3507 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Tue, 7 Apr 2026 12:08:16 -0400 Subject: [PATCH 13/16] Use `workspace:*` instead of `next` for `examples/*` projects to make sure `pnpm build:examples` is always using up to date library --- examples/daisyui-5/package.json | 2 +- examples/shadcn-svelte-1/package.json | 2 +- examples/skeleton-3/package.json | 2 +- examples/skeleton-4/package.json | 2 +- examples/standalone/package.json | 2 +- examples/svelte-ux-2/package.json | 2 +- examples/unocss-1/package.json | 2 +- pnpm-lock.yaml | 115 ++++---------------------- 8 files changed, 21 insertions(+), 108 deletions(-) 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/pnpm-lock.yaml b/pnpm-lock.yaml index 01e815b6f..97abb2f4c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -379,8 +379,8 @@ importers: specifier: ^5.5.19 version: 5.5.19 layerchart: - specifier: next - version: 2.0.0-next.50(@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) @@ -439,8 +439,8 @@ importers: specifier: ^2.1.1 version: 2.1.1 layerchart: - specifier: next - version: 2.0.0-next.50(@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) @@ -502,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.50(@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) @@ -556,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.50(@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 @@ -595,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.50(@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) @@ -640,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.50(@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 @@ -691,8 +691,8 @@ importers: specifier: ^66.6.7 version: 66.6.7 layerchart: - specifier: next - version: 2.0.0-next.50(@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) @@ -4344,11 +4344,6 @@ packages: known-css-properties@0.37.0: resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} - layerchart@2.0.0-next.50: - resolution: {integrity: sha512-1I2q0oOZO0qQG8tugZcRHsKAygFOZNmzbQMKGvcq9w1k5dvVPEab2Lsgd6qw0u0j1plYk+XvtGHo8OcCPidCFw==} - peerDependencies: - svelte: ^5.0.0 - levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -9572,78 +9567,6 @@ snapshots: known-css-properties@0.37.0: {} - layerchart@2.0.0-next.50(@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 - '@types/d3-contour': 3.0.6 - d3-array: 3.2.4 - d3-chord: 3.0.1 - d3-color: 3.1.0 - d3-contour: 4.0.2 - 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.50(@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 - '@types/d3-contour': 3.0.6 - d3-array: 3.2.4 - d3-chord: 3.0.1 - d3-color: 3.1.0 - d3-contour: 4.0.2 - 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 @@ -10673,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 From 7357513475cb313beae1d7e5eed4333eb2a0dd8d Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Tue, 7 Apr 2026 12:08:48 -0400 Subject: [PATCH 14/16] fix: improve compatibility with UnoCSS Svelte scoped preprocessing --- .changeset/quiet-rabbits-listen.md | 7 + .../src/lib/components/Circle.svelte | 141 ++++++++------ .../src/lib/components/Ellipse.svelte | 147 ++++++++------- .../layerchart/src/lib/components/Line.svelte | 144 ++++++++------ .../src/lib/components/Polygon.svelte | 148 +++++++++------ .../layerchart/src/lib/components/Rect.svelte | 177 +++++++++++------- .../layerchart/src/lib/components/Text.svelte | 135 ++++++++----- 7 files changed, 542 insertions(+), 357 deletions(-) create mode 100644 .changeset/quiet-rabbits-listen.md 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/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/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/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/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} From 1d82fe53d40c7020bd88f90774bb3d924316660f Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Tue, 7 Apr 2026 12:10:31 -0400 Subject: [PATCH 15/16] Remove unneeded changeset --- .changeset/axis-grid-canvas-support.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/axis-grid-canvas-support.md diff --git a/.changeset/axis-grid-canvas-support.md b/.changeset/axis-grid-canvas-support.md deleted file mode 100644 index 15ace0e80..000000000 --- a/.changeset/axis-grid-canvas-support.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'layerchart': minor ---- - -feat: Add canvas rendering support for `Axis`, `Grid`, and `Rule` components, enabling server-side image rendering with axes and grid lines From c364dd6b850024c8f0c0f0070f4bf028808fbd50 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Tue, 7 Apr 2026 12:11:24 -0400 Subject: [PATCH 16/16] Reduce changeset level for stroke/fill improvements --- .changeset/axis-grid-stroke-fill-props.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/axis-grid-stroke-fill-props.md b/.changeset/axis-grid-stroke-fill-props.md index 1d902022a..be3b22a44 100644 --- a/.changeset/axis-grid-stroke-fill-props.md +++ b/.changeset/axis-grid-stroke-fill-props.md @@ -1,5 +1,5 @@ --- -'layerchart': minor +'layerchart': patch --- feat: Add `stroke` and `fill` props to `Axis` and `Grid` for explicit color control (useful for SSR where CSS variables are unavailable)