Files
astrolabe/src/app/components/LivePreview.tsx
T

294 lines
12 KiB
TypeScript

/**
* Live Preview — the right pane (spec §04).
*
* Renders the active snippet's currently-shown spec (draft or published, per the
* editor view) as a Vega-Lite chart, debounced so typing stays smooth. The
* pipeline is: shown text → JSON.parse → prepareSpecForRender (copy, pure, fit
* mode applied) → renderSpec (vega-embed). A render-generation token guards
* against a slow render resolving after a newer one.
*
* The pane header carries the Fit control (4 sizing modes, §04). Render errors
* are published to the shared PreviewStore so the editor pane mirrors them
* (§03E); the preview shows the same message in place of the chart.
*
* M2 scope: inline-data specs, all four fit modes. Dataset reference resolution
* (M3) plugs into prepareSpecForRender without changing this component.
*/
import { useEffect, useRef } from 'react';
import { useShallow } from 'zustand/react/shallow';
import type { VisualizationSpec } from 'vega-embed';
import type { FitMode } from '@core/rendering';
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
import { chartConfigFor } from '@core/vega-themes';
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore';
import { useDatasetStore } from '../stores/DatasetStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { RangeControl, SettingRow, SettingsPopover } from './SettingsPopover';
import styles from './LivePreview.module.css';
/** The four fit modes in display order (spec §04 → Fit / Sizing Modes). */
const FIT_OPTIONS: ReadonlyArray<SegmentedOption<FitMode>> = [
{ value: 'default', label: 'Original' },
{ value: 'width', label: 'Width' },
{ value: 'height', label: 'Height' },
{ value: 'full', label: 'Full' },
];
/**
* Sizing class for the chart frame per fit mode. The frame is React-owned, so
* these classes drive how the host element (which vega-embed brands with its own
* `display:inline-block`) is sized: the responsive modes give the host a
* definite width/height for Vega's `"container"` measurement (`containerSize()`
* reads `host.clientWidth/Height`), while Original lets it shrink to natural size.
*/
const FIT_CLASS: Record<FitMode, string> = {
default: styles.fitOriginal,
width: styles.fitWidth,
height: styles.fitHeight,
full: styles.fitFull,
};
function FitControl() {
const fitMode = useAppStore((s) => s.previewFitMode);
const setFitMode = useAppStore((s) => s.setPreviewFitMode);
// Single-select set → a radio group, not independent toggles (APG; doc §10.5).
return (
<SegmentedControl
label="Fit chart to pane"
options={FIT_OPTIONS}
value={fitMode}
onChange={setFitMode}
/>
);
}
/** Preview settings cluster (spec §07 → Performance), disclosed beside Fit. */
function PreviewSettings() {
const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce);
const setPerformance = useUserSettingsStore((s) => s.setPerformance);
return (
<SettingsPopover id="preview-settings" label="Preview settings" title="Preview">
<SettingRow label="Render debounce" htmlFor="set-debounce">
<RangeControl
id="set-debounce"
min={500}
max={5000}
step={100}
value={renderDebounce}
suffix="ms"
onChange={(renderDebounce) => setPerformance({ renderDebounce })}
/>
</SettingRow>
</SettingsPopover>
);
}
export function LivePreview() {
const hostRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<RenderHandle | null>(null);
const generationRef = useRef(0);
const shownText = useSnippetStore(selectShownText);
const fitMode = useAppStore((s) => s.previewFitMode);
const uiTheme = useAppStore((s) => s.uiTheme);
// Datasets feed reference resolution (spec §04 step 1). Re-rendering on a
// dataset change keeps a referencing chart live as its data is edited.
const datasets = useDatasetStore(useShallow((s) => s.datasets));
// A programmatic buffer load (select/create/revert/hydrate — `bufferEpoch`) or a
// Draft/Published switch (`editorView`) must render *immediately*, not after the
// typing debounce (spec §03C). Keystrokes change only `shownText`, so when these
// two are unchanged the change is typing and the debounce applies.
const bufferEpoch = useSnippetStore((s) => s.bufferEpoch);
const editorView = useSnippetStore((s) => s.editorView);
// User-tunable render debounce (spec §07 → Performance).
const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce);
// Seed with a sentinel epoch so the very first paint counts as a load (immediate).
const lastLoadRef = useRef({ bufferEpoch: -1, editorView });
const error = usePreviewStore((s) => s.error);
const setError = usePreviewStore((s) => s.setError);
const busy = usePreviewStore((s) => s.busy);
const setBusy = usePreviewStore((s) => s.setBusy);
// 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
// avoid flicker on typical fast renders).
const busyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const node = hostRef.current;
if (!node) return;
const text = shownText.trim();
// Immediate on snippet load / view switch (spec §03C), debounced while typing.
const prevLoad = lastLoadRef.current;
const immediate = bufferEpoch !== prevLoad.bufferEpoch || editorView !== prevLoad.editorView;
lastLoadRef.current = { bufferEpoch, editorView };
const delay = immediate ? 0 : renderDebounce;
// The debounced body is async; wrap in a void IIFE so the timer callback
// returns void (it handles its own errors internally — nothing awaits it).
const timer = setTimeout(() => {
void (async () => {
// Empty/blank is not an error — clean, empty pane (spec §04).
if (text === '') {
handleRef.current?.destroy();
handleRef.current = null;
setError(null);
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (e) {
setError(`Invalid JSON: ${(e as Error).message}`);
return;
}
const mine = ++generationRef.current;
// Arm the busy indicator: if the render hasn't settled within ~1s, flip the
// flag. Sub-1s renders (the common case) never show the overlay — no flicker
// (arch §10.2; NN/g ≤1s = uninterrupted thought, >1s = noticeably waiting).
if (busyTimerRef.current !== null) clearTimeout(busyTimerRef.current);
busyTimerRef.current = setTimeout(() => {
// Only arm for the current generation; a superseded render doesn't own busy.
if (mine === generationRef.current) setBusy(true);
}, 1000);
/** Clear busy and the timer unconditionally — called on settle or error. */
const clearBusy = () => {
if (busyTimerRef.current !== null) {
clearTimeout(busyTimerRef.current);
busyTimerRef.current = null;
}
setBusy(false);
};
try {
const prepared = prepareSpecForRender(parsed, { fitMode, datasets });
const config = chartConfigFor(uiTheme);
handleRef.current?.destroy();
handleRef.current = null;
const handle = await renderSpec(node, prepared as VisualizationSpec, config);
if (mine !== generationRef.current) {
// TODO: a superseded render's destroy() calls node.replaceChildren(),
// which can blank the live chart if two embeds on the same node are
// ever in flight at once (heavy spec whose embed outlasts the 300ms
// debounce). The debounce makes this rare; when dataset work (M3)
// lands, serialize renders or finalize the stale view without
// clearing the shared node.
handle.destroy(); // a newer render superseded this one
return;
}
handleRef.current = handle;
setError(null);
clearBusy();
} catch (e) {
if (mine === generationRef.current) {
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:
// GOV.UK error-message + NN/g #9 — name the problem, give the real fix).
if (e instanceof DatasetNotFoundError) {
setError(
`Dataset "${e.datasetName}" not found. Create it from Datasets ` +
`(⌘/Ctrl+K), or check the dataset name in your spec.`,
);
} else {
setError(
`Rendering error: ${(e as Error).message}. ` +
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
);
}
}
}
})();
}, delay);
return () => clearTimeout(timer);
}, [
shownText,
fitMode,
uiTheme,
datasets,
setError,
setBusy,
bufferEpoch,
editorView,
renderDebounce,
]);
// 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;
// Original is fixed natural size and the pane just scrolls. ResizeObserver
// callbacks are frame-batched, so this tracks the drag without thrashing.
useEffect(() => {
const node = hostRef.current;
if (!node || typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver(() => {
if (useAppStore.getState().previewFitMode === 'default') return;
handleRef.current?.resize();
});
ro.observe(node);
return () => ro.disconnect();
}, []);
// Finalize the live view on unmount, and clear the shared error + busy state so
// stale transient state never outlives this pane.
useEffect(
() => () => {
handleRef.current?.destroy();
handleRef.current = null;
if (busyTimerRef.current !== null) clearTimeout(busyTimerRef.current);
usePreviewStore.getState().setError(null);
usePreviewStore.getState().setBusy(false);
},
[],
);
return (
<div className={styles.preview}>
<div className={styles.header}>
<FitControl />
<PreviewSettings />
</div>
{/*
* aria-busy on the chart region tells AT the area is being updated (arch §10.2;
* spec §04). The overlay is a non-blocking sibling inside the relative-positioned
* body; it never covers the header or editor, so editing stays fully interactive.
*/}
<div className={styles.body} aria-busy={busy || undefined}>
{/* Frame is React-owned and carries the fit-sizing class; the inner host
is owned by vega-embed (it brands it `.vega-embed` and mutates its
classList at runtime), so its className stays static and React never
clobbers Vega's own classes. */}
<div className={`${styles.frame} ${FIT_CLASS[fitMode]}`} hidden={error !== null}>
<div className={styles.host} ref={hostRef} />
</div>
{/* Visual only — no live region. The same error is announced once by the
editor pane's role="alert" (one producer, two subscribers; doc §10.1),
so adding one here would double-announce it. */}
{error !== null && <pre className={styles.error}>{error}</pre>}
{/*
* Busy overlay: non-blocking, overlays only the chart body, never the header
* or the editor (arch §10.2; spec §04/§10). Shown only after the ~1s threshold
* so sub-1s renders produce no flicker. The spinner animation is suppressed
* under prefers-reduced-motion (base.css *{animation-duration:0.01ms}).
*/}
{busy && (
<div className={styles.busyOverlay} aria-hidden="true">
<span className={styles.busySpinner} />
<span className={styles.busyLabel}>Rendering</span>
</div>
)}
</div>
</div>
);
}