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
+132
View File
@@ -0,0 +1,132 @@
/* ChartExport — per-chart export disclosure in the Live Preview header (spec §08). */
.wrap {
position: relative;
display: inline-flex;
}
/* Trigger — a compact ghost button (icon + label), matching the header utilities. */
.trigger {
display: inline-flex;
align-items: center;
gap: var(--space-2);
height: 28px;
padding: 0 var(--space-3);
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 13px;
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.trigger:hover:not(:disabled) {
background: var(--layer-01);
color: var(--text);
}
.trigger[aria-expanded='true'] {
background: var(--layer-02);
color: var(--text);
}
.trigger:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
.trigger:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.triggerIcon {
color: currentColor;
}
/* The disclosed panel — portaled to <body>, positioned `fixed` (top/right set
inline) so it escapes the panes' overflow clipping. Mirrors SettingsPopover. */
.pop {
position: fixed;
z-index: 1000;
display: flex;
flex-direction: column;
min-width: 264px;
max-width: min(320px, 92vw);
padding: var(--space-3);
background: var(--layer-01);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.groupTitle {
margin: var(--space-3) 0 var(--space-1);
padding: 0 var(--space-2);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-secondary);
}
/* The first group heading sits flush with the panel top. */
.groupTitle:first-child {
margin-top: 0;
}
/* Action rows — full-width, left-aligned ghost buttons. */
.action {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-3);
width: 100%;
padding: var(--space-2) var(--space-2);
border: var(--border-width) solid transparent;
border-radius: var(--radius);
background: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.action:hover:not(:disabled) {
background: var(--layer-02);
}
.action:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -1px;
}
.action:disabled {
color: var(--text-placeholder);
cursor: not-allowed;
}
/* The monospace extension hint, trailing the JSON action. */
.ext {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
}
.action:disabled .ext {
color: inherit;
}
.hint {
margin: var(--space-2) 0 0;
padding: 0 var(--space-2);
font-size: 11px;
line-height: 1.4;
color: var(--text-secondary);
}
+321
View File
@@ -0,0 +1,321 @@
/**
* Chart export — the per-chart "Export" affordance in the Live Preview header
* (spec §08 → Per-chart export). Distinct from the header's *workspace* Export
* (whole-library backup): this gets *one* chart out — its spec to the clipboard or
* a `.vl.json` file, or its rendered image as PNG/SVG.
*
* It lives in the preview header on purpose: the image formats need the live Vega
* `view` (held by LivePreview), and "export this chart" reads most naturally beside
* the chart you're looking at. The spec actions export the *shown* text (draft or
* published), so every format matches what's on screen.
*
* A few options ride along, because export has real choices to make:
* - **Resolution** (PNG) — a device-pixel-ratio-aware multiplier, so the default
* "1×" is already Retina-crisp (a naive 1× export looks soft on a 2× display).
* - **Background** — the chart config is transparent (to show the pane colour),
* which would make a naive export transparent; default to the theme colour, with
* White and Transparent on offer.
* - **Referenced data** (shown only when the spec references saved datasets) —
* inline the data so the exported spec renders standalone, or keep the reference.
*
* Widget: a **disclosure**, not an ARIA menu — like `SettingsPopover`. The container
* is a labelled `group` of option controls + action `<button>`s (Tab/Shift+Tab move
* between them); the trigger carries `aria-expanded` + `aria-controls`; Esc closes
* and restores focus; an outside click closes. It shares the single-open popover
* registry, and is portaled to `<body>` (the panes clip their overflow).
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useShallow } from 'zustand/react/shallow';
import {
chartExportFilename,
inlineReferencedDatasets,
referencedDatasetNames,
} from '@core/chart-export';
import { DatasetNotFoundError } from '@core/rendering';
import { copyText, downloadJson, downloadUrl } from '../infrastructure/file-transfer';
import { useDatasetStore } from '../stores/DatasetStore';
import { notify } from '../stores/NotificationStore';
import { useSettingsPopoverStore } from '../stores/SettingsPopoverStore';
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { Icon } from './Icon';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SettingRow } from './SettingsPopover';
import styles from './ChartExport.module.css';
/** Shared registry id (single popover open at a time across the panes). */
const POP_ID = 'chart-export';
/** Gap (px) between the trigger and the disclosed panel (matches SettingsPopover). */
const GAP = 6;
type ScaleChoice = '1' | '2' | '3';
type BackgroundChoice = 'theme' | 'white' | 'transparent';
/** PNG resolution multipliers (× device pixel ratio — see ImageExportOptions). */
const SCALE_OPTIONS: ReadonlyArray<SegmentedOption<ScaleChoice>> = [
{ value: '1', label: '1×', title: '1× — matches your screen (Retina-aware)' },
{ value: '2', label: '2×', title: '2× — double resolution, for print or zoom' },
{ value: '3', label: '3×', title: '3× — triple resolution' },
];
const BACKGROUND_OPTIONS: ReadonlyArray<SegmentedOption<BackgroundChoice>> = [
{ value: 'theme', label: 'Theme', title: 'Match the current themes background' },
{ value: 'white', label: 'White', title: 'White background' },
{ value: 'transparent', label: 'None', title: 'Transparent background' },
];
const REFDATA_OPTIONS: ReadonlyArray<SegmentedOption<'inline' | 'ref'>> = [
{ value: 'inline', label: 'Inline', title: 'Embed the data so the file renders standalone' },
{
value: 'ref',
label: 'Keep refs',
title: 'Keep the dataset reference (renders only in Astrolabe)',
},
];
/** Resolve a background choice to a CSS colour, or null for transparent. The
* "theme" colour is read live from the page so it always tracks the active theme. */
function resolveBackground(choice: BackgroundChoice): string | null {
if (choice === 'transparent') return null;
if (choice === 'white') return '#ffffff';
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim();
return bg || '#ffffff';
}
export interface ChartExportProps {
/** Whether a chart is currently rendered — gates the image (PNG/SVG) actions.
* The spec actions only need text, so they ignore this. */
chartReady: boolean;
/** Rasterize/serialize the live view to a downloadable URL, or null if the view
* isn't available (caller owns the Vega view). */
getImageUrl: (
format: 'png' | 'svg',
options: { scale: number; background: string | null },
) => Promise<string | null>;
}
export function ChartExport({ chartReady, getImageUrl }: ChartExportProps) {
const open = useSettingsPopoverStore((s) => s.openId === POP_ID);
const toggle = useSettingsPopoverStore((s) => s.toggle);
const close = useSettingsPopoverStore((s) => s.close);
// Primitive reads only (no fresh objects) so the header doesn't re-render needlessly.
const name = useSnippetStore((s) => selectActiveSnippet(s)?.name ?? 'chart');
const shownText = useSnippetStore(selectShownText);
const datasets = useDatasetStore(useShallow((s) => s.datasets));
const hasSpec = shownText.trim() !== '';
// Export options, persisted while the component is mounted (across opens).
const [scale, setScale] = useState<ScaleChoice>('1');
const [background, setBackground] = useState<BackgroundChoice>('theme');
const [inline, setInline] = useState(true);
// Which saved datasets the shown spec references — drives the inline-data option.
const refs = useMemo(() => referencedDatasetNames(shownText), [shownText]);
const triggerRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement | null>(null);
// Position the fixed panel from the trigger's rect (panes clip overflow → body
// portal + fixed). Imperative, like SettingsPopover — no state, no scroll re-render.
const place = useCallback(() => {
const trigger = triggerRef.current;
const pop = popRef.current;
if (!trigger || !pop) return;
const r = trigger.getBoundingClientRect();
pop.style.top = `${r.bottom + GAP}px`;
pop.style.right = `${window.innerWidth - r.right}px`;
pop.style.left = 'auto';
}, []);
useEffect(() => {
if (!open) return;
window.addEventListener('resize', place);
window.addEventListener('scroll', place, true);
return () => {
window.removeEventListener('resize', place);
window.removeEventListener('scroll', place, true);
};
}, [open, place]);
// Esc closes + restores focus; an outside pointer click closes (APG disclosure).
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
close();
triggerRef.current?.focus();
}
};
const onPointer = (e: PointerEvent) => {
const t = e.target as Node;
if (!popRef.current?.contains(t) && !triggerRef.current?.contains(t)) close();
};
document.addEventListener('keydown', onKey, true);
document.addEventListener('pointerdown', onPointer, true);
return () => {
document.removeEventListener('keydown', onKey, true);
document.removeEventListener('pointerdown', onPointer, true);
};
}, [open, close]);
// On open: place before paint and move focus to the first control.
const setPopNode = useCallback(
(node: HTMLDivElement | null) => {
popRef.current = node;
if (node) {
place();
node.querySelector<HTMLElement>('button, [role="radio"]')?.focus();
}
},
[place],
);
/** The spec text to export — inlined for portability when chosen and refs exist.
* Returns null after surfacing an error (a referenced dataset is missing). */
const buildSpecText = (): string | null => {
if (!inline || refs.length === 0) return shownText;
try {
return inlineReferencedDatasets(shownText, datasets);
} catch (e) {
const missing = e instanceof DatasetNotFoundError ? ` "${e.datasetName}"` : '';
notify({
kind: 'error',
title: 'Couldnt inline data',
message: `A referenced dataset${missing} isnt in your library. Add it, or choose “Keep refs”.`,
});
return null;
}
};
const copySpec = async () => {
close();
const text = buildSpecText();
if (text == null) return;
try {
await copyText(text);
notify({
kind: 'success',
title: 'Spec copied',
message: 'The charts Vega-Lite spec is on your clipboard as JSON.',
});
} catch {
notify({
kind: 'error',
title: 'Couldnt copy',
message: 'Your browser blocked clipboard access. Select and copy the spec from the editor.',
});
}
};
const downloadSpec = () => {
close();
const text = buildSpecText();
if (text == null) return;
const filename = chartExportFilename(name, 'vl.json');
downloadJson(filename, text);
notify({ kind: 'success', title: 'Spec downloaded', message: `Saved ${filename}.` });
};
const downloadImage = async (format: 'png' | 'svg') => {
close();
const url = await getImageUrl(format, {
scale: Number(scale),
background: resolveBackground(background),
});
if (!url) {
notify({
kind: 'error',
title: 'Couldnt export image',
message: 'The chart isnt ready yet. Wait for it to finish rendering, then try again.',
});
return;
}
const filename = chartExportFilename(name, format);
downloadUrl(filename, url);
notify({ kind: 'success', title: 'Chart exported', message: `Saved ${filename}.` });
};
return (
<div className={styles.wrap}>
<button
ref={triggerRef}
type="button"
className={styles.trigger}
aria-expanded={open}
aria-controls={POP_ID}
disabled={!hasSpec}
title={hasSpec ? 'Export this chart' : 'Select a snippet to export'}
onClick={() => toggle(POP_ID)}
>
<Icon name="export" className={styles.triggerIcon} />
<span>Export</span>
</button>
{open &&
createPortal(
<div
ref={setPopNode}
id={POP_ID}
className={styles.pop}
role="group"
aria-label="Export chart"
>
<h4 className={styles.groupTitle}>Spec</h4>
{refs.length > 0 && (
<SettingRow label="Referenced data">
<SegmentedControl
label="Referenced data"
options={REFDATA_OPTIONS}
value={inline ? 'inline' : 'ref'}
onChange={(v) => setInline(v === 'inline')}
/>
</SettingRow>
)}
<button type="button" className={styles.action} onClick={() => void copySpec()}>
Copy spec
</button>
<button type="button" className={styles.action} onClick={downloadSpec}>
Download JSON <span className={styles.ext}>.vl.json</span>
</button>
<h4 className={styles.groupTitle}>Image</h4>
<SettingRow label="Resolution">
<SegmentedControl
label="PNG resolution"
options={SCALE_OPTIONS}
value={scale}
onChange={setScale}
/>
</SettingRow>
<SettingRow label="Background">
<SegmentedControl
label="Background"
options={BACKGROUND_OPTIONS}
value={background}
onChange={setBackground}
/>
</SettingRow>
<button
type="button"
className={styles.action}
disabled={!chartReady}
onClick={() => void downloadImage('png')}
>
Download PNG
</button>
<button
type="button"
className={styles.action}
disabled={!chartReady}
onClick={() => void downloadImage('svg')}
>
Download SVG
</button>
{!chartReady && <p className={styles.hint}>Render the chart to export an image.</p>}
</div>,
document.body,
)}
</div>
);
}
+7 -2
View File
@@ -21,9 +21,14 @@
border-bottom: var(--border-width) solid var(--border);
}
/* Push the settings gear (the last child) to the far right. */
.header > :last-child {
/* Push the right cluster (export + settings gear) to the far right; Fit hugs
the left. The cluster itself is the last child, so any header overflow trims
from its right edge, never clipping "Original" on the left. */
.headerEnd {
margin-left: auto;
display: flex;
align-items: center;
gap: var(--space-2);
}
.body {
+4
View File
@@ -50,6 +50,10 @@ vi.mock('./SettingsPopover', () => ({
SettingRow: () => null,
RangeControl: () => null,
}));
// Isolate the busy-overlay assertions (which locate the overlay by its
// `aria-hidden="true"`) from the export control's own decorative icon: the
// per-chart export is a sibling header control, mocked out like SettingsPopover.
vi.mock('./ChartExport', () => ({ ChartExport: () => null }));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+36 -2
View File
@@ -15,7 +15,7 @@
* (M3) plugs into prepareSpecForRender without changing this component.
*/
import { useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import type { VisualizationSpec } from 'vega-embed';
import type { FitMode } from '@core/rendering';
@@ -27,6 +27,7 @@ import { useDatasetStore } from '../stores/DatasetStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { ChartExport } from './ChartExport';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { RangeControl, SettingRow, SettingsPopover } from './SettingsPopover';
import styles from './LivePreview.module.css';
@@ -116,6 +117,11 @@ export function LivePreview() {
const setError = usePreviewStore((s) => s.setError);
const busy = usePreviewStore((s) => s.busy);
const setBusy = usePreviewStore((s) => s.setBusy);
// Mirrors whether `handleRef` currently holds a live view, so the per-chart
// export's image actions (which need the view) can enable/disable reactively —
// a ref change alone wouldn't re-render. Set true on a successful render, false
// on clear/error/unmount.
const [chartReady, setChartReady] = useState(false);
// Busy-indication timer ref: if a render exceeds ~1s we surface a non-blocking
// overlay (arch §10.2 NN/g: >1s owes a busy indication; <1s shows nothing to
@@ -198,6 +204,7 @@ export function LivePreview() {
if (isEmpty) {
handleRef.current?.destroy();
handleRef.current = null;
setChartReady(false);
setError(null);
clearBusy();
return;
@@ -218,10 +225,12 @@ export function LivePreview() {
return;
}
handleRef.current = handle;
setChartReady(true);
setError(null);
clearBusy();
} catch (e) {
if (mine === generationRef.current) {
setChartReady(false);
clearBusy();
// A missing dataset reference is not a JSON/spec problem, so it gets a
// tailored, fixable message instead of the generic syntax hint (council:
@@ -253,11 +262,31 @@ export function LivePreview() {
datasets,
setError,
setBusy,
setChartReady,
bufferEpoch,
editorView,
renderDebounce,
]);
// Rasterize/serialize the live view for the per-chart export (spec §08). Reads
// `handleRef` (the view LivePreview owns); returns null when no view is live or
// the export fails, so the export UI can report it. Stable identity (no deps).
const getImageUrl = useCallback(
async (
format: 'png' | 'svg',
options: { scale: number; background: string | null },
): Promise<string | null> => {
const handle = handleRef.current;
if (!handle) return null;
try {
return await handle.toImageURL(format, options);
} catch {
return null;
}
},
[],
);
// Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
// observe the element, so we do: one observer on the stable host node for the
// component's life. Only responsive fit modes depend on container size;
@@ -280,6 +309,7 @@ export function LivePreview() {
() => () => {
handleRef.current?.destroy();
handleRef.current = null;
setChartReady(false);
if (busyTimerRef.current !== null) clearTimeout(busyTimerRef.current);
usePreviewStore.getState().setError(null);
usePreviewStore.getState().setBusy(false);
@@ -291,7 +321,11 @@ export function LivePreview() {
<div className={styles.preview}>
<div className={styles.header}>
<FitControl />
<PreviewSettings />
{/* Right cluster: export this chart, then the preview settings gear. */}
<div className={styles.headerEnd}>
<ChartExport chartReady={chartReady} getImageUrl={getImageUrl} />
<PreviewSettings />
</div>
</div>
{/*
* aria-busy on the chart region tells AT the area is being updated (arch §10.2;
+20 -5
View File
@@ -7,10 +7,13 @@
* than Blobs and anchors.
*/
/** Trigger a client-side download of `json` text as `filename`. */
export function downloadJson(filename: string, json: string): void {
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
/**
* Trigger a download of an already-built object/data `url` as `filename`. A
* `blob:` URL is revoked right after the click; a `data:` URL needs no cleanup.
* Used by the per-chart export for an image URL produced by the Vega view
* (`RenderHandle.toImageURL`).
*/
export function downloadUrl(filename: string, url: string): void {
const a = document.createElement('a');
a.href = url;
a.download = filename;
@@ -18,10 +21,22 @@ export function downloadJson(filename: string, json: string): void {
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
if (url.startsWith('blob:')) URL.revokeObjectURL(url);
}
/** Trigger a client-side download of `json` text as `filename`. */
export function downloadJson(filename: string, json: string): void {
const url = URL.createObjectURL(new Blob([json], { type: 'application/json' }));
downloadUrl(filename, url);
}
/** Read a picked file's text content (rejects on an unreadable file). */
export function readTextFile(file: File): Promise<string> {
return file.text();
}
/** Copy `text` to the clipboard (rejects when the browser blocks access). The
* one place outside a component that touches the clipboard API. */
export function copyText(text: string): Promise<void> {
return navigator.clipboard.writeText(text);
}
+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;