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
+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;