Per-chart export: copy/download spec and PNG/SVG from the preview header

This commit is contained in:
2026-06-10 18:30:31 +03:00
parent cad19445b0
commit 1791ee9f8d
15 changed files with 921 additions and 33 deletions
+82 -1
View File
@@ -11,9 +11,40 @@ import vegaEmbed, { type Result as EmbedResult } from 'vega-embed';
import type { VisualizationSpec } from 'vega-embed';
import type { Config } from 'vega-lite';
/** Options for `RenderHandle.toImageURL` (spec §08 → Per-chart export). */
export interface ImageExportOptions {
/**
* Pixel-density multiplier for the **PNG** raster, **relative to the device**.
* The image is drawn at `scale × devicePixelRatio` logical-pixel density, so
* `scale: 1` already matches on-screen crispness on a Retina display — the fix
* for a soft "1×" export (`toImageURL`'s raw `scaleFactor` ignores dpr, so a
* naive 1× looks half-resolution on a 2× display). Raise for print/zoom.
* Ignored for the resolution-independent SVG. Default `1`.
*/
scale?: number;
/**
* Opaque colour to paint behind the chart. The chart config renders a
* **transparent** background (so the on-screen chart shows the pane colour),
* which makes a naive export transparent; pass a colour to fill it. PNG is
* composited onto the colour; SVG gets a background `<rect>`. Null/omitted keeps
* it transparent. Default `null`.
*/
background?: string | null;
}
export interface RenderHandle {
/** Finalize the underlying Vega view and clear the node. */
destroy(): void;
/**
* Rasterize/serialize the current view to a downloadable URL (spec §08 →
* Per-chart export). `'png'` resolves to a `blob:` object URL (caller revokes
* after download); `'svg'` to a `data:` URL. Renderer-agnostic — works from the
* SVG-backed LivePreview view as well as a canvas one — because Vega draws to its
* own off-screen surface here, independent of the display backend. Honors
* `options` (dpr-aware scale, background fill). Rejects if the view was already
* finalized.
*/
toImageURL(format: 'png' | 'svg', options?: ImageExportOptions): Promise<string>;
/**
* Re-fit the chart to its container's current size (spec §04 Responsiveness).
*
@@ -36,7 +67,7 @@ export interface RenderOptions {
* main-thread layout/paint per render — measured ~6.5s paint on 9994 rows —
* because each mark is a DOM node; canvas is a single node and paints in
* milliseconds. Canvas is raster (not crisp on zoom) but that's invisible for an
* ephemeral preview, and image export (`view.toImageURL`) is renderer-agnostic.
* ephemeral preview, and image export (`RenderHandle.toImageURL`) is renderer-agnostic.
*/
renderer?: 'svg' | 'canvas';
}
@@ -79,6 +110,41 @@ function canvasLimitPx(): number {
return MAX_CANVAS_PX / dpr;
}
/** Encode an SVG string as a `data:` URL (no blob to revoke). */
function svgDataUrl(svg: string): string {
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
}
/** Paint a full-bleed background `<rect>` as the first child of the root `<svg>`,
* so the exported SVG isn't transparent. Vega emits explicit width/height on the
* root, so `100%` resolves to the chart's box. */
function withSvgBackground(svg: string, color: string): string {
return svg.replace(/(<svg\b[^>]*>)/, `$1<rect width="100%" height="100%" fill="${color}"/>`);
}
/** Composite a (transparent) chart canvas onto an opaque colour, same dimensions. */
function compositeOnColor(chart: HTMLCanvasElement, color: string): HTMLCanvasElement {
const out = document.createElement('canvas');
out.width = chart.width;
out.height = chart.height;
const ctx = out.getContext('2d');
if (!ctx) return chart; // no 2d context — fall back to the transparent chart
ctx.fillStyle = color;
ctx.fillRect(0, 0, out.width, out.height);
ctx.drawImage(chart, 0, 0);
return out;
}
/** A canvas → `blob:` object URL (PNG). The caller revokes it after the download. */
function canvasObjectUrl(canvas: HTMLCanvasElement): Promise<string> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(URL.createObjectURL(blob));
else reject(new Error('Could not encode the chart as a PNG.'));
}, 'image/png');
});
}
/** Embed a prepared spec into `node`. Always: no actions menu. Renderer per `options`. */
export async function renderSpec(
node: HTMLElement,
@@ -114,6 +180,21 @@ export async function renderSpec(
result.view.finalize();
node.replaceChildren();
},
async toImageURL(format, options = {}) {
const { scale = 1, background = null } = options;
if (format === 'svg') {
// Vector — resolution-independent, so dpr/scale don't apply. A background
// is added as a full-bleed rect rather than baked into the live view.
const svg = await result.view.toSVG();
return svgDataUrl(background ? withSvgBackground(svg, background) : svg);
}
// Multiply the requested scale by the device pixel ratio so a "1×" export is
// as crisp as the chart on screen (the Retina fix — see ImageExportOptions).
const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
const chart = await result.view.toCanvas(scale * dpr);
const out = background ? compositeOnColor(chart, background) : chart;
return canvasObjectUrl(out);
},
resize() {
// Synthesize the window:resize the container signals listen for (see the
// interface doc). The view re-reads containerSize() and re-renders itself;