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;
+132
View File
@@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest';
import {
chartExportFilename,
inlineReferencedDatasets,
MAX_BASENAME_LEN,
referencedDatasetNames,
snippetFileBasename,
} from './chart-export';
import { DatasetNotFoundError, type ResolvableDataset } from './rendering';
describe('snippetFileBasename', () => {
it('keeps the users words and case', () => {
expect(snippetFileBasename('Sales by Region')).toBe('Sales-by-Region');
});
it('swaps whitespace runs for a single dash', () => {
expect(snippetFileBasename(' a b c ')).toBe('a-b-c');
});
it('folds dots into the separator so the extension stays unambiguous', () => {
expect(snippetFileBasename('data.v1.final')).toBe('data-v1-final');
});
it('strips characters illegal on Windows / awkward in URLs', () => {
expect(snippetFileBasename('a/b\\c:d*e?f"g<h>i|j')).toBe('abcdefghij');
});
it('keeps non-latin letters (no ASCII folding — full-script support)', () => {
expect(snippetFileBasename('Продажи по региону')).toBe('Продажи-по-региону');
expect(snippetFileBasename('売上 グラフ')).toBe('売上-グラフ');
});
it('collapses and trims dashes', () => {
expect(snippetFileBasename('--a -- b--')).toBe('a-b');
});
it('falls back to "chart" when nothing usable remains', () => {
expect(snippetFileBasename('')).toBe('chart');
expect(snippetFileBasename(' ')).toBe('chart');
expect(snippetFileBasename('/// \\\\\\')).toBe('chart');
expect(snippetFileBasename('...')).toBe('chart');
});
it('caps the length and never leaves a trailing dash from the cut', () => {
const long = snippetFileBasename('x'.repeat(200));
expect(long.length).toBe(MAX_BASENAME_LEN);
// A name whose cap boundary lands on a dash must not end in one.
const dashy = snippetFileBasename('a'.repeat(MAX_BASENAME_LEN - 1) + ' bbbb');
expect(dashy.endsWith('-')).toBe(false);
});
it('drops control characters', () => {
// Build the control byte from a code point so no literal control char is in source.
expect(snippetFileBasename(`a${String.fromCharCode(1)}bcd`)).toBe('abcd');
});
});
describe('chartExportFilename', () => {
it('appends the format as the extension', () => {
expect(chartExportFilename('Sales by Region', 'png')).toBe('Sales-by-Region.png');
expect(chartExportFilename('Sales by Region', 'svg')).toBe('Sales-by-Region.svg');
expect(chartExportFilename('Sales by Region', 'vl.json')).toBe('Sales-by-Region.vl.json');
});
it('uses the "chart" fallback for an unusable name', () => {
expect(chartExportFilename('', 'png')).toBe('chart.png');
});
});
describe('referencedDatasetNames', () => {
it('returns the saved-dataset names a spec references, deduped', () => {
const spec = JSON.stringify({
layer: [
{ data: { name: 'sales' } },
{ data: { name: 'sales' } },
{ data: { name: 'costs' } },
],
});
expect(referencedDatasetNames(spec).sort()).toEqual(['costs', 'sales']);
});
it('excludes names the spec defines for itself via top-level datasets', () => {
const spec = JSON.stringify({
datasets: { local: [{ a: 1 }] },
data: { name: 'local' },
});
expect(referencedDatasetNames(spec)).toEqual([]);
});
it('returns [] for an inline-data spec (no references)', () => {
const spec = JSON.stringify({ data: { values: [{ a: 1 }] }, mark: 'bar' });
expect(referencedDatasetNames(spec)).toEqual([]);
});
it('returns [] for unparseable text rather than throwing', () => {
expect(referencedDatasetNames('{ not json')).toEqual([]);
});
});
describe('inlineReferencedDatasets', () => {
const sales: ResolvableDataset = {
name: 'sales',
data: [{ region: 'N', value: 10 }],
format: 'json',
source: 'inline',
};
const parse = (text: string) => JSON.parse(text) as { data?: unknown; width?: unknown };
it('replaces a named reference with the datasets inline values', () => {
const spec = JSON.stringify({ data: { name: 'sales' }, mark: 'bar' });
const out = parse(inlineReferencedDatasets(spec, [sales]));
expect(out.data).toEqual({ values: [{ region: 'N', value: 10 }] });
});
it('resolves the name case-insensitively (mirrors reference resolution)', () => {
const spec = JSON.stringify({ data: { name: 'Sales' }, mark: 'bar' });
const out = parse(inlineReferencedDatasets(spec, [sales]));
expect(out.data).toEqual({ values: [{ region: 'N', value: 10 }] });
});
it('leaves sizing as authored — no fit-mode applied', () => {
const spec = JSON.stringify({ data: { name: 'sales' }, width: 300, mark: 'bar' });
const out = parse(inlineReferencedDatasets(spec, [sales]));
expect(out.width).toBe(300);
});
it('throws DatasetNotFoundError when a referenced dataset is missing', () => {
const spec = JSON.stringify({ data: { name: 'missing' }, mark: 'bar' });
expect(() => inlineReferencedDatasets(spec, [sales])).toThrow(DatasetNotFoundError);
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* Chart export — pure helpers for the single-chart export affordance
* (spec §08 → Per-chart export). Distinct from the workspace export envelope
* (`export-envelope.ts`): that backs up the whole library as one JSON file; this
* turns *one* snippet into a shareable artifact — its spec as `.vl.json`, or its
* rendered image as PNG/SVG.
*
* The naming is deterministic, and so is producing a **self-contained** spec —
* one with every saved-dataset reference replaced by its inline data, so the
* exported file renders without Astrolabe. Both live here and are tested. The
* clipboard write, the file download, and the image rasterization are browser-side
* and stay in `infrastructure/file-transfer` and the chart renderer.
*/
import { prepareSpecForRender, type ResolvableDataset } from './rendering';
import { extractDatasetRefs } from './spec-refs';
/** The formats a single chart can be exported as (file extension == the value). */
export type ChartExportFormat = 'vl.json' | 'png' | 'svg';
/** Cap on the derived base name so a very long snippet title can't blow up the
* filename (filesystems and download shelves both balk past ~255 chars). */
export const MAX_BASENAME_LEN = 60;
/** C0 control characters + DEL, built from a string so no literal control byte
* ever lands in this source file. Stripped from filenames. */
// eslint-disable-next-line no-control-regex -- intentional: scrub control chars from names
const CONTROL_CHARS = new RegExp('[\\u0000-\\u001f\\u007f]', 'g');
/** Characters illegal on Windows or awkward across filesystems and URLs. */
const ILLEGAL_CHARS = /[\\/:*?"<>|]/g;
/**
* Turn a snippet's display name into a filesystem- and URL-safe base name.
*
* Keeps the user's words and **case**, and keeps letters of *any* script (a
* Cyrillic or CJK title stays itself — we never ASCII-fold, matching the app's
* full-script support). Swaps whitespace and dot runs for single dashes (so the
* extension stays unambiguous), drops control + illegal characters, collapses and
* trims dashes, and caps the length. Falls back to `"chart"` when nothing usable
* remains (a name of only punctuation or whitespace).
*/
export function snippetFileBasename(name: string): string {
const cleaned = name
.normalize('NFC')
.replace(CONTROL_CHARS, '')
.replace(ILLEGAL_CHARS, '')
.replace(/[.\s]+/g, '-') // dot and whitespace runs → one dash boundary
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
const capped = cleaned.slice(0, MAX_BASENAME_LEN).replace(/-+$/g, '');
return capped || 'chart';
}
/**
* Download filename for a single chart export, e.g. `sales-by-region.png`. The
* base is derived from the snippet name; `format` is both the extension and the
* artifact kind. No date or `astrolabe-` prefix (unlike the workspace export) —
* the user is exporting *one named chart* and wants its name on the file.
*/
export function chartExportFilename(name: string, format: ChartExportFormat): string {
return `${snippetFileBasename(name)}.${format}`;
}
/**
* The saved-dataset names a spec references via `{ data: { name } }` (deduped).
* A name the spec defines for itself via top-level `datasets` is excluded — those
* are already self-contained. Returns `[]` for spec text that doesn't parse. Drives
* whether the export offers an "inline referenced data" option at all.
*/
export function referencedDatasetNames(specText: string): string[] {
// extractDatasetRefs already safe-parses a string (→ [] on bad JSON) and dedups.
return extractDatasetRefs(specText);
}
/**
* Re-serialize a spec with every saved-dataset reference replaced by its inline
* data, so the exported file renders standalone (outside Astrolabe). Sizing is
* left exactly as authored — unlike the preview, no fit-mode is applied. Throws
* `DatasetNotFoundError` (from `prepareSpecForRender`) if the spec references a
* name not present in `datasets`; the caller surfaces that. `specText` must be
* valid JSON (it is the spec the editor is showing).
*/
export function inlineReferencedDatasets(
specText: string,
datasets: ReadonlyArray<ResolvableDataset>,
): string {
const parsed: unknown = JSON.parse(specText);
const resolved = prepareSpecForRender(parsed, { datasets, fitMode: 'default' });
return JSON.stringify(resolved, null, 2);
}