layerchart 2.0.0-next.50 → 2.0.0-next.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/Arc.svelte +12 -4
- package/dist/components/Arc.svelte.d.ts +4 -0
- package/dist/components/ArcLabel.svelte +259 -0
- package/dist/components/ArcLabel.svelte.d.ts +73 -0
- package/dist/components/ArcLabel.svelte.test.d.ts +1 -0
- package/dist/components/ArcLabel.svelte.test.js +235 -0
- package/dist/components/Axis.svelte +25 -0
- package/dist/components/Axis.svelte.d.ts +10 -0
- package/dist/components/Circle.svelte +82 -59
- package/dist/components/CircleLegend.svelte +389 -0
- package/dist/components/CircleLegend.svelte.d.ts +114 -0
- package/dist/components/Ellipse.svelte +83 -64
- package/dist/components/GeoLegend.svelte +404 -0
- package/dist/components/GeoLegend.svelte.d.ts +106 -0
- package/dist/components/GeoRaster.svelte +311 -0
- package/dist/components/GeoRaster.svelte.d.ts +61 -0
- package/dist/components/Grid.svelte +15 -0
- package/dist/components/Grid.svelte.d.ts +5 -0
- package/dist/components/Image.svelte +2 -2
- package/dist/components/Labels.svelte +46 -11
- package/dist/components/Labels.svelte.d.ts +7 -3
- package/dist/components/Legend.svelte +58 -3
- package/dist/components/Legend.svelte.d.ts +7 -0
- package/dist/components/Line.svelte +82 -62
- package/dist/components/Points.svelte +2 -2
- package/dist/components/Polygon.svelte +92 -56
- package/dist/components/Rect.svelte +113 -64
- package/dist/components/Rule.svelte +2 -0
- package/dist/components/Sankey.svelte +0 -2
- package/dist/components/Text.svelte +83 -52
- package/dist/components/__screenshots__/ArcLabel.svelte.test.ts/ArcLabel-defaults-placement-to-centroid--x-y-at-the-centroid--middle-anchors--1.png +0 -0
- package/dist/components/__screenshots__/ArcLabel.svelte.test.ts/ArcLabel-defaults-placement-to-centroid--x-y-at-the-centroid--middle-anchors--2.png +0 -0
- package/dist/components/charts/ArcChart.svelte +39 -2
- package/dist/components/charts/ArcChart.svelte.d.ts +12 -1
- package/dist/components/charts/PieChart.svelte +40 -2
- package/dist/components/charts/PieChart.svelte.d.ts +10 -0
- package/dist/components/index.d.ts +8 -0
- package/dist/components/index.js +8 -0
- package/dist/components/layers/Canvas.svelte +65 -48
- package/dist/components/layers/Canvas.svelte.d.ts +10 -0
- package/dist/contexts/canvas.d.ts +3 -0
- package/dist/server/ContextCapture.svelte +30 -0
- package/dist/server/ContextCapture.svelte.d.ts +8 -0
- package/dist/server/ServerChart.svelte +26 -0
- package/dist/server/ServerChart.svelte.d.ts +11 -0
- package/dist/server/TestBarChart.svelte +35 -0
- package/dist/server/TestBarChart.svelte.d.ts +14 -0
- package/dist/server/TestLineChart.svelte +35 -0
- package/dist/server/TestLineChart.svelte.d.ts +14 -0
- package/dist/server/captureStore.d.ts +8 -0
- package/dist/server/captureStore.js +18 -0
- package/dist/server/index.d.ts +137 -0
- package/dist/server/index.js +141 -0
- package/dist/server/renderChart.ssr.test.d.ts +1 -0
- package/dist/server/renderChart.ssr.test.js +205 -0
- package/dist/server/renderTree.d.ts +8 -0
- package/dist/server/renderTree.js +29 -0
- package/dist/states/__screenshots__/chart.svelte.test.ts/ChartState-geo-projection-skips-markInfo-should-not-derive-x-y-accessors-from-marks-when-geo-projection-is-active-1.png +0 -0
- package/dist/states/__screenshots__/chart.svelte.test.ts/ChartState-geo-projection-skips-markInfo-should-not-derive-x-y-accessors-from-marks-when-geo-projection-is-active-2.png +0 -0
- package/dist/states/chart.svelte.d.ts +5 -1
- package/dist/states/chart.svelte.js +18 -3
- package/dist/states/chart.svelte.test.js +110 -0
- package/dist/states/geo.svelte.d.ts +5 -1
- package/dist/states/geo.svelte.js +80 -68
- package/dist/utils/arcText.svelte.d.ts +7 -1
- package/dist/utils/arcText.svelte.js +8 -4
- package/dist/utils/canvas.js +29 -10
- package/dist/utils/canvas.svelte.test.js +2 -2
- package/dist/utils/motion.svelte.js +14 -0
- package/package.json +7 -1
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import type { Component } from 'svelte';
|
|
2
|
+
import type { ChartState } from '../states/chart.svelte.js';
|
|
3
|
+
import type { ComponentNode } from '../states/chart.svelte.js';
|
|
4
|
+
import type { CaptureTarget } from './captureStore.js';
|
|
5
|
+
export { renderTree } from './renderTree.js';
|
|
6
|
+
export { default as ServerChart } from './ServerChart.svelte';
|
|
7
|
+
export { getSSRCapture, setSSRCapture, type CaptureTarget, type SSRCapture, } from './captureStore.js';
|
|
8
|
+
export type CapturedChart = {
|
|
9
|
+
chartState: ChartState;
|
|
10
|
+
rootNode: ComponentNode;
|
|
11
|
+
};
|
|
12
|
+
export type CanvasRenderContext = Omit<CanvasRenderingContext2D, 'drawFocusIfNeeded' | 'canvas'> & {
|
|
13
|
+
canvas?: unknown;
|
|
14
|
+
};
|
|
15
|
+
export type CanvasFactory = (width: number, height: number) => {
|
|
16
|
+
getContext(type: '2d'): unknown;
|
|
17
|
+
toBuffer(mimeType: string, ...args: any[]): Buffer | Uint8Array;
|
|
18
|
+
};
|
|
19
|
+
export type RenderOptions = {
|
|
20
|
+
/** Pixel ratio for high-DPI output. @default 1 */
|
|
21
|
+
devicePixelRatio?: number;
|
|
22
|
+
/** Output format. @default 'png' */
|
|
23
|
+
format?: 'png' | 'jpeg';
|
|
24
|
+
/** JPEG quality (0-1). Only used when format is 'jpeg'. @default 0.92 */
|
|
25
|
+
quality?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Background color to fill before rendering the chart.
|
|
28
|
+
* When omitted, PNG output is transparent.
|
|
29
|
+
* Set to `'white'` (or any CSS color) for an opaque background —
|
|
30
|
+
* recommended for JPEG which does not support transparency.
|
|
31
|
+
*/
|
|
32
|
+
background?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Canvas factory function.
|
|
35
|
+
*
|
|
36
|
+
* Example with \@napi-rs/canvas:
|
|
37
|
+
* ```ts
|
|
38
|
+
* import { createCanvas } from '\@napi-rs/canvas';
|
|
39
|
+
* createCanvas: (w, h) => createCanvas(w, h)
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
createCanvas: CanvasFactory;
|
|
43
|
+
};
|
|
44
|
+
export type RenderChartOptions = RenderOptions & {
|
|
45
|
+
/** Width of the output image in pixels. */
|
|
46
|
+
width: number;
|
|
47
|
+
/** Height of the output image in pixels. */
|
|
48
|
+
height: number;
|
|
49
|
+
/** Additional props to pass to the chart component. */
|
|
50
|
+
props?: Record<string, any>;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Create a capture callback for use with `render()` from `svelte/server`.
|
|
54
|
+
* Pass the returned `onCapture` as the `onCapture` prop to your chart component.
|
|
55
|
+
* After `render()` completes, call `getCapture()` to retrieve the chart state and
|
|
56
|
+
* component tree.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```ts
|
|
60
|
+
* import { render } from 'svelte/server';
|
|
61
|
+
* import { createCaptureCallback, renderCapturedChart } from '../index.js/server';
|
|
62
|
+
* import MyChart from './MyChart.svelte';
|
|
63
|
+
*
|
|
64
|
+
* const { onCapture, getCapture } = createCaptureCallback();
|
|
65
|
+
* const rendered = render(MyChart, { props: { data, width: 800, height: 400, onCapture } });
|
|
66
|
+
* rendered.body; // Force the SSR render to fully flush before reading capture state
|
|
67
|
+
* const capture = getCapture();
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
export declare function createCaptureCallback(): {
|
|
71
|
+
onCapture: (data: CaptureTarget) => void;
|
|
72
|
+
getCapture: () => CaptureTarget | null;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Render a chart component to an image buffer in a single call.
|
|
76
|
+
*
|
|
77
|
+
* This is a convenience function that handles SSR rendering, capture, and
|
|
78
|
+
* canvas rendering in one step. The component should use `<ServerChart>`
|
|
79
|
+
* internally and accept `width`, `height`, and `capture` props.
|
|
80
|
+
*
|
|
81
|
+
* @example
|
|
82
|
+
* ```ts
|
|
83
|
+
* import { createCanvas, Path2D } from '\@napi-rs/canvas';
|
|
84
|
+
* import { renderChart } from '../index.js/server';
|
|
85
|
+
* import MyChart from './MyChart.svelte';
|
|
86
|
+
*
|
|
87
|
+
* // Register Path2D globally for canvas rendering
|
|
88
|
+
* if (typeof globalThis.Path2D === 'undefined') (globalThis as any).Path2D = Path2D;
|
|
89
|
+
*
|
|
90
|
+
* const buffer = renderChart(MyChart, {
|
|
91
|
+
* width: 800,
|
|
92
|
+
* height: 400,
|
|
93
|
+
* props: { data: myData },
|
|
94
|
+
* createCanvas: (w, h) => createCanvas(w, h),
|
|
95
|
+
* });
|
|
96
|
+
*
|
|
97
|
+
* // Use as a Response in a SvelteKit endpoint
|
|
98
|
+
* return new Response(buffer, {
|
|
99
|
+
* headers: { 'Content-Type': 'image/png' }
|
|
100
|
+
* });
|
|
101
|
+
* ```
|
|
102
|
+
*/
|
|
103
|
+
export declare function renderChart(component: Component<any>, options: RenderChartOptions): Buffer | Uint8Array;
|
|
104
|
+
/**
|
|
105
|
+
* Render a captured chart component tree to an image buffer.
|
|
106
|
+
* Call this after `render()` from `svelte/server` has been used to build
|
|
107
|
+
* the component tree with a capture callback.
|
|
108
|
+
*
|
|
109
|
+
* For most use cases, prefer {@link renderChart} which handles the full pipeline.
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```ts
|
|
113
|
+
* import { render } from 'svelte/server';
|
|
114
|
+
* import { createCanvas, Path2D } from '\@napi-rs/canvas';
|
|
115
|
+
* import { createCaptureCallback, renderCapturedChart } from '../index.js/server';
|
|
116
|
+
* import MyChart from './MyChart.svelte';
|
|
117
|
+
*
|
|
118
|
+
* // Register canvas globals
|
|
119
|
+
* if (typeof globalThis.Path2D === 'undefined') (globalThis as any).Path2D = Path2D;
|
|
120
|
+
*
|
|
121
|
+
* // Build component tree via SSR render
|
|
122
|
+
* const { onCapture, getCapture } = createCaptureCallback();
|
|
123
|
+
* const rendered = render(MyChart, { props: { data, width: 800, height: 400, onCapture } });
|
|
124
|
+
* rendered.body; // Force the SSR render to fully flush before reading capture state
|
|
125
|
+
*
|
|
126
|
+
* // Render to image
|
|
127
|
+
* const buffer = renderCapturedChart(getCapture()!, {
|
|
128
|
+
* width: 800,
|
|
129
|
+
* height: 400,
|
|
130
|
+
* createCanvas: (w, h) => createCanvas(w, h),
|
|
131
|
+
* });
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
export declare function renderCapturedChart(capture: CapturedChart, options: RenderOptions & {
|
|
135
|
+
width: number;
|
|
136
|
+
height: number;
|
|
137
|
+
}): Buffer | Uint8Array;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { render } from 'svelte/server';
|
|
2
|
+
import { renderTree } from './renderTree.js';
|
|
3
|
+
export { renderTree } from './renderTree.js';
|
|
4
|
+
export { default as ServerChart } from './ServerChart.svelte';
|
|
5
|
+
export { getSSRCapture, setSSRCapture, } from './captureStore.js';
|
|
6
|
+
/**
|
|
7
|
+
* Create a capture callback for use with `render()` from `svelte/server`.
|
|
8
|
+
* Pass the returned `onCapture` as the `onCapture` prop to your chart component.
|
|
9
|
+
* After `render()` completes, call `getCapture()` to retrieve the chart state and
|
|
10
|
+
* component tree.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { render } from 'svelte/server';
|
|
15
|
+
* import { createCaptureCallback, renderCapturedChart } from '../index.js/server';
|
|
16
|
+
* import MyChart from './MyChart.svelte';
|
|
17
|
+
*
|
|
18
|
+
* const { onCapture, getCapture } = createCaptureCallback();
|
|
19
|
+
* const rendered = render(MyChart, { props: { data, width: 800, height: 400, onCapture } });
|
|
20
|
+
* rendered.body; // Force the SSR render to fully flush before reading capture state
|
|
21
|
+
* const capture = getCapture();
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function createCaptureCallback() {
|
|
25
|
+
let captured = null;
|
|
26
|
+
return {
|
|
27
|
+
onCapture: (data) => {
|
|
28
|
+
captured = data;
|
|
29
|
+
},
|
|
30
|
+
getCapture: () => captured,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Render a chart component to an image buffer in a single call.
|
|
35
|
+
*
|
|
36
|
+
* This is a convenience function that handles SSR rendering, capture, and
|
|
37
|
+
* canvas rendering in one step. The component should use `<ServerChart>`
|
|
38
|
+
* internally and accept `width`, `height`, and `capture` props.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```ts
|
|
42
|
+
* import { createCanvas, Path2D } from '\@napi-rs/canvas';
|
|
43
|
+
* import { renderChart } from '../index.js/server';
|
|
44
|
+
* import MyChart from './MyChart.svelte';
|
|
45
|
+
*
|
|
46
|
+
* // Register Path2D globally for canvas rendering
|
|
47
|
+
* if (typeof globalThis.Path2D === 'undefined') (globalThis as any).Path2D = Path2D;
|
|
48
|
+
*
|
|
49
|
+
* const buffer = renderChart(MyChart, {
|
|
50
|
+
* width: 800,
|
|
51
|
+
* height: 400,
|
|
52
|
+
* props: { data: myData },
|
|
53
|
+
* createCanvas: (w, h) => createCanvas(w, h),
|
|
54
|
+
* });
|
|
55
|
+
*
|
|
56
|
+
* // Use as a Response in a SvelteKit endpoint
|
|
57
|
+
* return new Response(buffer, {
|
|
58
|
+
* headers: { 'Content-Type': 'image/png' }
|
|
59
|
+
* });
|
|
60
|
+
* ```
|
|
61
|
+
*/
|
|
62
|
+
export function renderChart(component, options) {
|
|
63
|
+
const { width, height, props = {}, ...renderOptions } = options;
|
|
64
|
+
const captureTarget = {};
|
|
65
|
+
// SSR render to build the component tree and capture chart state
|
|
66
|
+
const rendered = render(component, {
|
|
67
|
+
props: { ...props, width, height, capture: captureTarget }
|
|
68
|
+
});
|
|
69
|
+
// Force the SSR render to fully flush
|
|
70
|
+
void rendered.body;
|
|
71
|
+
if (!captureTarget.chartState || !captureTarget.rootNode) {
|
|
72
|
+
throw new Error('Failed to capture chart state. Ensure the component uses <ServerChart> with a `capture` prop.');
|
|
73
|
+
}
|
|
74
|
+
return renderCapturedChart(captureTarget, {
|
|
75
|
+
width,
|
|
76
|
+
height,
|
|
77
|
+
...renderOptions,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Render a captured chart component tree to an image buffer.
|
|
82
|
+
* Call this after `render()` from `svelte/server` has been used to build
|
|
83
|
+
* the component tree with a capture callback.
|
|
84
|
+
*
|
|
85
|
+
* For most use cases, prefer {@link renderChart} which handles the full pipeline.
|
|
86
|
+
*
|
|
87
|
+
* @example
|
|
88
|
+
* ```ts
|
|
89
|
+
* import { render } from 'svelte/server';
|
|
90
|
+
* import { createCanvas, Path2D } from '\@napi-rs/canvas';
|
|
91
|
+
* import { createCaptureCallback, renderCapturedChart } from '../index.js/server';
|
|
92
|
+
* import MyChart from './MyChart.svelte';
|
|
93
|
+
*
|
|
94
|
+
* // Register canvas globals
|
|
95
|
+
* if (typeof globalThis.Path2D === 'undefined') (globalThis as any).Path2D = Path2D;
|
|
96
|
+
*
|
|
97
|
+
* // Build component tree via SSR render
|
|
98
|
+
* const { onCapture, getCapture } = createCaptureCallback();
|
|
99
|
+
* const rendered = render(MyChart, { props: { data, width: 800, height: 400, onCapture } });
|
|
100
|
+
* rendered.body; // Force the SSR render to fully flush before reading capture state
|
|
101
|
+
*
|
|
102
|
+
* // Render to image
|
|
103
|
+
* const buffer = renderCapturedChart(getCapture()!, {
|
|
104
|
+
* width: 800,
|
|
105
|
+
* height: 400,
|
|
106
|
+
* createCanvas: (w, h) => createCanvas(w, h),
|
|
107
|
+
* });
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
export function renderCapturedChart(capture, options) {
|
|
111
|
+
const { width, height, devicePixelRatio = 1, format = 'png', quality = 0.92, background, createCanvas, } = options;
|
|
112
|
+
// Create canvas
|
|
113
|
+
const canvasWidth = Math.round(width * devicePixelRatio);
|
|
114
|
+
const canvasHeight = Math.round(height * devicePixelRatio);
|
|
115
|
+
const canvas = createCanvas(canvasWidth, canvasHeight);
|
|
116
|
+
const ctx = canvas.getContext('2d');
|
|
117
|
+
// Fill background (canvas is transparent by default)
|
|
118
|
+
if (background) {
|
|
119
|
+
ctx.fillStyle = background;
|
|
120
|
+
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
|
121
|
+
}
|
|
122
|
+
// Apply DPI scaling
|
|
123
|
+
if (devicePixelRatio !== 1) {
|
|
124
|
+
ctx.scale(devicePixelRatio, devicePixelRatio);
|
|
125
|
+
}
|
|
126
|
+
// Apply padding translation (mirrors what Canvas.svelte's update() does)
|
|
127
|
+
if (capture.chartState) {
|
|
128
|
+
const padding = capture.chartState.padding;
|
|
129
|
+
if (padding) {
|
|
130
|
+
ctx.translate(padding.left ?? 0, padding.top ?? 0);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Render the component tree onto the canvas
|
|
134
|
+
renderTree(ctx, capture.rootNode);
|
|
135
|
+
// Export to buffer
|
|
136
|
+
const mimeType = format === 'jpeg' ? 'image/jpeg' : 'image/png';
|
|
137
|
+
if (format === 'jpeg') {
|
|
138
|
+
return canvas.toBuffer(mimeType, quality);
|
|
139
|
+
}
|
|
140
|
+
return canvas.toBuffer(mimeType);
|
|
141
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
2
|
+
import { render } from 'svelte/server';
|
|
3
|
+
import { createCanvas, Path2D } from '@napi-rs/canvas';
|
|
4
|
+
import { renderChart, renderCapturedChart, createCaptureCallback, } from './index.js';
|
|
5
|
+
import TestLineChart from './TestLineChart.svelte';
|
|
6
|
+
import TestBarChart from './TestBarChart.svelte';
|
|
7
|
+
// Register Path2D globally for canvas rendering
|
|
8
|
+
beforeAll(() => {
|
|
9
|
+
if (typeof globalThis.Path2D === 'undefined') {
|
|
10
|
+
globalThis.Path2D = Path2D;
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
const createNodeCanvas = (w, h) => createCanvas(w, h);
|
|
14
|
+
const lineData = Array.from({ length: 20 }, (_, i) => ({
|
|
15
|
+
date: i,
|
|
16
|
+
value: 50 + 30 * Math.sin(i / 5),
|
|
17
|
+
}));
|
|
18
|
+
const barData = [
|
|
19
|
+
{ category: 'A', value: 28 },
|
|
20
|
+
{ category: 'B', value: 55 },
|
|
21
|
+
{ category: 'C', value: 43 },
|
|
22
|
+
{ category: 'D', value: 91 },
|
|
23
|
+
];
|
|
24
|
+
describe('renderChart', () => {
|
|
25
|
+
it('renders a line chart to PNG buffer', () => {
|
|
26
|
+
const buffer = renderChart(TestLineChart, {
|
|
27
|
+
width: 400,
|
|
28
|
+
height: 200,
|
|
29
|
+
props: { data: lineData },
|
|
30
|
+
createCanvas: createNodeCanvas,
|
|
31
|
+
});
|
|
32
|
+
expect(buffer).toBeInstanceOf(Buffer);
|
|
33
|
+
expect(buffer.length).toBeGreaterThan(0);
|
|
34
|
+
// PNG magic bytes
|
|
35
|
+
expect(buffer[0]).toBe(0x89);
|
|
36
|
+
expect(buffer[1]).toBe(0x50); // P
|
|
37
|
+
expect(buffer[2]).toBe(0x4e); // N
|
|
38
|
+
expect(buffer[3]).toBe(0x47); // G
|
|
39
|
+
});
|
|
40
|
+
it('renders a bar chart to PNG buffer', () => {
|
|
41
|
+
const buffer = renderChart(TestBarChart, {
|
|
42
|
+
width: 400,
|
|
43
|
+
height: 200,
|
|
44
|
+
props: { data: barData },
|
|
45
|
+
createCanvas: createNodeCanvas,
|
|
46
|
+
});
|
|
47
|
+
expect(buffer).toBeInstanceOf(Buffer);
|
|
48
|
+
expect(buffer.length).toBeGreaterThan(0);
|
|
49
|
+
// PNG magic bytes
|
|
50
|
+
expect(buffer[0]).toBe(0x89);
|
|
51
|
+
});
|
|
52
|
+
it('renders to JPEG format', () => {
|
|
53
|
+
const buffer = renderChart(TestLineChart, {
|
|
54
|
+
width: 400,
|
|
55
|
+
height: 200,
|
|
56
|
+
format: 'jpeg',
|
|
57
|
+
props: { data: lineData },
|
|
58
|
+
createCanvas: createNodeCanvas,
|
|
59
|
+
});
|
|
60
|
+
expect(buffer).toBeInstanceOf(Buffer);
|
|
61
|
+
// JPEG magic bytes (SOI marker)
|
|
62
|
+
expect(buffer[0]).toBe(0xff);
|
|
63
|
+
expect(buffer[1]).toBe(0xd8);
|
|
64
|
+
});
|
|
65
|
+
it('respects custom dimensions', () => {
|
|
66
|
+
const buffer1 = renderChart(TestLineChart, {
|
|
67
|
+
width: 200,
|
|
68
|
+
height: 100,
|
|
69
|
+
props: { data: lineData },
|
|
70
|
+
createCanvas: createNodeCanvas,
|
|
71
|
+
});
|
|
72
|
+
const buffer2 = renderChart(TestLineChart, {
|
|
73
|
+
width: 800,
|
|
74
|
+
height: 600,
|
|
75
|
+
props: { data: lineData },
|
|
76
|
+
createCanvas: createNodeCanvas,
|
|
77
|
+
});
|
|
78
|
+
// Larger image should produce a larger buffer
|
|
79
|
+
expect(buffer2.length).toBeGreaterThan(buffer1.length);
|
|
80
|
+
});
|
|
81
|
+
it('supports devicePixelRatio', () => {
|
|
82
|
+
const buffer1x = renderChart(TestLineChart, {
|
|
83
|
+
width: 400,
|
|
84
|
+
height: 200,
|
|
85
|
+
devicePixelRatio: 1,
|
|
86
|
+
props: { data: lineData },
|
|
87
|
+
createCanvas: createNodeCanvas,
|
|
88
|
+
});
|
|
89
|
+
const buffer2x = renderChart(TestLineChart, {
|
|
90
|
+
width: 400,
|
|
91
|
+
height: 200,
|
|
92
|
+
devicePixelRatio: 2,
|
|
93
|
+
props: { data: lineData },
|
|
94
|
+
createCanvas: createNodeCanvas,
|
|
95
|
+
});
|
|
96
|
+
// 2x DPI should produce a larger buffer (more pixels)
|
|
97
|
+
expect(buffer2x.length).toBeGreaterThan(buffer1x.length);
|
|
98
|
+
});
|
|
99
|
+
it('supports background color', () => {
|
|
100
|
+
const transparentBuffer = renderChart(TestLineChart, {
|
|
101
|
+
width: 400,
|
|
102
|
+
height: 200,
|
|
103
|
+
props: { data: lineData },
|
|
104
|
+
createCanvas: createNodeCanvas,
|
|
105
|
+
});
|
|
106
|
+
const whiteBuffer = renderChart(TestLineChart, {
|
|
107
|
+
width: 400,
|
|
108
|
+
height: 200,
|
|
109
|
+
background: 'white',
|
|
110
|
+
props: { data: lineData },
|
|
111
|
+
createCanvas: createNodeCanvas,
|
|
112
|
+
});
|
|
113
|
+
// Both should be valid PNGs but different content
|
|
114
|
+
expect(transparentBuffer[0]).toBe(0x89);
|
|
115
|
+
expect(whiteBuffer[0]).toBe(0x89);
|
|
116
|
+
expect(Buffer.compare(transparentBuffer, whiteBuffer)).not.toBe(0);
|
|
117
|
+
});
|
|
118
|
+
it('throws on missing ServerChart', () => {
|
|
119
|
+
// A bare component that doesn't use ServerChart should fail
|
|
120
|
+
expect(() => renderChart(
|
|
121
|
+
// Use a dummy component-like object
|
|
122
|
+
(() => { }), {
|
|
123
|
+
width: 400,
|
|
124
|
+
height: 200,
|
|
125
|
+
createCanvas: createNodeCanvas,
|
|
126
|
+
})).toThrow('Failed to capture chart state');
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
describe('renderCapturedChart', () => {
|
|
130
|
+
it('renders a captured chart tree to a buffer', () => {
|
|
131
|
+
const captureTarget = {};
|
|
132
|
+
const rendered = render(TestLineChart, {
|
|
133
|
+
props: { data: lineData, width: 400, height: 200, capture: captureTarget },
|
|
134
|
+
});
|
|
135
|
+
void rendered.body;
|
|
136
|
+
expect(captureTarget.chartState).toBeDefined();
|
|
137
|
+
expect(captureTarget.rootNode).toBeDefined();
|
|
138
|
+
const buffer = renderCapturedChart(captureTarget, {
|
|
139
|
+
width: 400,
|
|
140
|
+
height: 200,
|
|
141
|
+
createCanvas: createNodeCanvas,
|
|
142
|
+
});
|
|
143
|
+
expect(buffer).toBeInstanceOf(Buffer);
|
|
144
|
+
expect(buffer[0]).toBe(0x89); // PNG
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
describe('createCaptureCallback', () => {
|
|
148
|
+
it('captures chart state via callback', () => {
|
|
149
|
+
const { onCapture, getCapture } = createCaptureCallback();
|
|
150
|
+
const rendered = render(TestLineChart, {
|
|
151
|
+
props: { data: lineData, width: 400, height: 200, onCapture },
|
|
152
|
+
});
|
|
153
|
+
void rendered.body;
|
|
154
|
+
const capture = getCapture();
|
|
155
|
+
expect(capture).not.toBeNull();
|
|
156
|
+
expect(capture?.chartState).toBeDefined();
|
|
157
|
+
expect(capture?.rootNode).toBeDefined();
|
|
158
|
+
});
|
|
159
|
+
it('returns null before render', () => {
|
|
160
|
+
const { getCapture } = createCaptureCallback();
|
|
161
|
+
expect(getCapture()).toBeNull();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
describe('ServerChart capture prop', () => {
|
|
165
|
+
it('populates capture target via prop', () => {
|
|
166
|
+
const captureTarget = {};
|
|
167
|
+
const rendered = render(TestLineChart, {
|
|
168
|
+
props: { data: lineData, width: 400, height: 200, capture: captureTarget },
|
|
169
|
+
});
|
|
170
|
+
void rendered.body;
|
|
171
|
+
expect(captureTarget.chartState).toBeDefined();
|
|
172
|
+
expect(captureTarget.rootNode).toBeDefined();
|
|
173
|
+
expect(captureTarget.rootNode.children.length).toBeGreaterThan(0);
|
|
174
|
+
});
|
|
175
|
+
it('captures chart state with correct padding', () => {
|
|
176
|
+
const captureTarget = {};
|
|
177
|
+
const rendered = render(TestLineChart, {
|
|
178
|
+
props: { data: lineData, width: 800, height: 400, capture: captureTarget },
|
|
179
|
+
});
|
|
180
|
+
void rendered.body;
|
|
181
|
+
const state = captureTarget.chartState;
|
|
182
|
+
expect(state.padding).toEqual({ top: 20, right: 20, bottom: 20, left: 20 });
|
|
183
|
+
});
|
|
184
|
+
it('captures component tree with children', () => {
|
|
185
|
+
const captureTarget = {};
|
|
186
|
+
const rendered = render(TestLineChart, {
|
|
187
|
+
props: { data: lineData, width: 400, height: 200, capture: captureTarget },
|
|
188
|
+
});
|
|
189
|
+
void rendered.body;
|
|
190
|
+
// Root node (Canvas) should have children
|
|
191
|
+
const root = captureTarget.rootNode;
|
|
192
|
+
expect(root.kind).toBe('group');
|
|
193
|
+
expect(root.children.length).toBeGreaterThan(0);
|
|
194
|
+
// Count all marks in the tree (may be nested in composite-marks)
|
|
195
|
+
function countMarks(node) {
|
|
196
|
+
let count = node.kind === 'mark' ? 1 : 0;
|
|
197
|
+
for (const child of node.children) {
|
|
198
|
+
count += countMarks(child);
|
|
199
|
+
}
|
|
200
|
+
return count;
|
|
201
|
+
}
|
|
202
|
+
// Should have at least 2 marks (Area and Spline)
|
|
203
|
+
expect(countMarks(root)).toBeGreaterThanOrEqual(2);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ComponentNode } from '../states/chart.svelte.js';
|
|
2
|
+
/**
|
|
3
|
+
* Recursively render the component tree onto a canvas context.
|
|
4
|
+
* Group nodes: save → render → recurse children → restore
|
|
5
|
+
* Leaf nodes: save → render → restore
|
|
6
|
+
* Non-rendering nodes: just recurse children
|
|
7
|
+
*/
|
|
8
|
+
export declare function renderTree(ctx: CanvasRenderingContext2D, node: ComponentNode): void;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recursively render the component tree onto a canvas context.
|
|
3
|
+
* Group nodes: save → render → recurse children → restore
|
|
4
|
+
* Leaf nodes: save → render → restore
|
|
5
|
+
* Non-rendering nodes: just recurse children
|
|
6
|
+
*/
|
|
7
|
+
export function renderTree(ctx, node) {
|
|
8
|
+
if (node.kind === 'group' && node.canvasRender) {
|
|
9
|
+
// Group: save state, apply transform, render children, restore
|
|
10
|
+
ctx.save();
|
|
11
|
+
node.canvasRender.render(ctx);
|
|
12
|
+
for (const child of node.children) {
|
|
13
|
+
renderTree(ctx, child);
|
|
14
|
+
}
|
|
15
|
+
ctx.restore();
|
|
16
|
+
}
|
|
17
|
+
else if (node.canvasRender) {
|
|
18
|
+
// Leaf mark: save, render, restore
|
|
19
|
+
ctx.save();
|
|
20
|
+
node.canvasRender.render(ctx);
|
|
21
|
+
ctx.restore();
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
// Non-rendering node (e.g. root, composite-mark): just recurse children
|
|
25
|
+
for (const child of node.children) {
|
|
26
|
+
renderTree(ctx, child);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -64,7 +64,11 @@ export declare class ChartState<TData = any, XScale extends AnyScale = AnyScale,
|
|
|
64
64
|
private _markInfosVersion;
|
|
65
65
|
private _nextMarkId;
|
|
66
66
|
/** Reactive accessor — reads _markInfosVersion to create a reactive dependency,
|
|
67
|
-
* returns the plain array so items are never wrapped in Svelte proxies.
|
|
67
|
+
* returns the plain array so items are never wrapped in Svelte proxies.
|
|
68
|
+
*
|
|
69
|
+
* When a geo projection is active, strips x/y/data from mark info — those
|
|
70
|
+
* values are geographic coordinates handled by the projection, not xScale/yScale.
|
|
71
|
+
* seriesKey/color/label are preserved so marks can still contribute to legends. */
|
|
68
72
|
private get _markInfos();
|
|
69
73
|
/**
|
|
70
74
|
* Register a mark with the chart. The MarkInfo snapshot is stored directly
|
|
@@ -48,10 +48,24 @@ export class ChartState {
|
|
|
48
48
|
_markInfosVersion = $state(0);
|
|
49
49
|
_nextMarkId = 0;
|
|
50
50
|
/** Reactive accessor — reads _markInfosVersion to create a reactive dependency,
|
|
51
|
-
* returns the plain array so items are never wrapped in Svelte proxies.
|
|
51
|
+
* returns the plain array so items are never wrapped in Svelte proxies.
|
|
52
|
+
*
|
|
53
|
+
* When a geo projection is active, strips x/y/data from mark info — those
|
|
54
|
+
* values are geographic coordinates handled by the projection, not xScale/yScale.
|
|
55
|
+
* seriesKey/color/label are preserved so marks can still contribute to legends. */
|
|
52
56
|
get _markInfos() {
|
|
53
57
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
|
54
58
|
this._markInfosVersion;
|
|
59
|
+
if (this.geoState.props.projection) {
|
|
60
|
+
return this._markInfosRaw.map(({ _id, info }) => ({
|
|
61
|
+
_id,
|
|
62
|
+
info: {
|
|
63
|
+
seriesKey: info.seriesKey,
|
|
64
|
+
color: info.color,
|
|
65
|
+
label: info.label,
|
|
66
|
+
},
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
55
69
|
return this._markInfosRaw;
|
|
56
70
|
}
|
|
57
71
|
/**
|
|
@@ -147,8 +161,9 @@ export class ChartState {
|
|
|
147
161
|
meta = $derived(this.props.meta ?? {});
|
|
148
162
|
constructor(propsGetter) {
|
|
149
163
|
this._propsGetter = propsGetter;
|
|
150
|
-
// Create GeoState instance
|
|
151
|
-
|
|
164
|
+
// Create GeoState instance — pass a dimensions getter so projection
|
|
165
|
+
// is available during SSR (where $effect doesn't run)
|
|
166
|
+
this.geoState = new GeoState(() => this.props.geo ?? {}, () => ({ width: this.width, height: this.height }));
|
|
152
167
|
// Create SeriesState internally from series/seriesLayout props.
|
|
153
168
|
// When no explicit series are provided, derive implicit series from mark registrations.
|
|
154
169
|
this.seriesState = new SeriesState(() => {
|