/** * 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> = [ { 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 = { 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 ( ); } /** 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 ( setPerformance({ renderDebounce })} /> ); } export function LivePreview() { const hostRef = useRef(null); const handleRef = useRef(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 | 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 (
{/* * 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. */}
{/* 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. */}
); }