/** * 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useShallow } from 'zustand/react/shallow'; import type { VisualizationSpec } from 'vega-embed'; import type { Config } from 'vega-lite'; import { referencedUploadedFonts } from '@core/chart-export'; import type { FitMode } from '@core/rendering'; import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering'; import { chartConfigForSelection, chartThemeOptions, type ChartThemeSelection, } from '@core/vega-themes'; import { openModal } from '../modals/ModalCoordinator'; import { renderSpec, type RenderHandle } from '../services/chart-renderer'; import { useAppStore } from '../stores/AppStore'; import { useCustomThemeStore } from '../stores/CustomThemeStore'; import { useDatasetStore } from '../stores/DatasetStore'; import { useFontStore } from '../stores/FontStore'; import { usePreviewStore } from '../stores/PreviewStore'; import { selectShownText, useSnippetStore } from '../stores/SnippetStore'; import { useUserSettingsStore } from '../stores/UserSettingsStore'; import { ChartExport } from './ChartExport'; import { CompositionWireframe } from './CompositionWireframe'; import { DataInspector } from './DataInspector'; import { InspectorSplitHandle } from './InspectorSplitHandle'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; import { SelectControl, type SelectControlOption } from './SelectControl'; 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> = [ // `title` doubles as the accessible name, so it leads with the visible label // (WCAG 2.5.3 label-in-name; see SegmentedOption.title). { value: 'default', label: 'Original', title: 'Original — the natural size from the spec' }, { value: 'width', label: 'Width', title: 'Width — fit to the pane width' }, { value: 'height', label: 'Height', title: 'Height — fit to the pane height' }, { value: 'full', label: 'Full', title: 'Full — fill the pane' }, ]; /** * 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 ( ); } /** * Chart theme picker (spec §04; docs/chart-theming-scope.md §4.2) — which config * charts render (and export) with. A global preference, not per-snippet: a * snippet's own `config` still overrides it property by property. Lives in the * header, not inside PreviewSettings: SelectControl and SettingsPopover share * the one-open-popover registry, so a select nested in the popover would close * (and unmount) its own parent on open (the contract: arch 10 §5 — a control * needing its own popover sits beside the gear, never inside the panel). The * trigger's 16ch label cap keeps long preset/custom names from crowding a * narrow header. */ /** Sentinel option that opens the Theme Builder instead of selecting a theme. */ const EDIT_THEMES = 'edit-themes'; type ThemePickerValue = ChartThemeSelection | typeof EDIT_THEMES; function ChartThemeControl() { const chartTheme = useAppStore((s) => s.chartTheme); const setChartTheme = useAppStore((s) => s.setChartTheme); const customThemes = useCustomThemeStore((s) => s.themes); // Fresh-array derivation — memoize so the picker doesn't re-render the world // (docs/architecture/01: derive with useMemo, never store). const options = useMemo>>(() => { const list: SelectControlOption[] = [...chartThemeOptions(customThemes)]; // The "manage" entry rides in the value list (the VS Code theme-picker // pattern); choosing it opens the builder and leaves the selection alone. // It closes the custom-themes block — right after the built-ins, BEFORE the // long preset roster — so it is visible without scrolling and sits next to // the entries it manages. The roster boundary itself (the divider) is set by // chartThemeOptions where the order is decided, so the splice is the only // index this component owns. The first preset is the divider-carrying option, // so splicing right before it needs no count arithmetic. The row needs no // special styling: SelectControl options are real buttons (not listbox // options), the "…" is the opens-further-UI convention, and `hasPopup` // announces the dialog to AT. const firstPreset = list.findIndex((o) => o.dividerBefore); list.splice(firstPreset === -1 ? list.length : firstPreset, 0, { value: EDIT_THEMES, label: 'Edit themes…', detail: 'Create and manage custom themes', hasPopup: 'dialog', }); return list; }, [customThemes]); return ( { if (value === EDIT_THEMES) openModal('themeBuilder'); else setChartTheme(value); }} triggerTitle="Chart theme — how charts are styled when rendered and exported" /> ); } /** 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); // The config the live view was last rendered with — read at SVG export to find // which uploaded fonts the chart references (a font may live only in the theme // config, not the spec). Tracks `handleRef`: set together, valid whenever a // handle is. const configRef = useRef(null); const generationRef = useRef(0); // Serializes node mutations across overlapping renders: each render chains onto // the previous one's promise so only one vega-embed ever touches the shared host // node at a time (see the render effect for why this matters). const renderChainRef = useRef>(Promise.resolve()); const shownText = useSnippetStore(selectShownText); const fitMode = useAppStore((s) => s.previewFitMode); const uiTheme = useAppStore((s) => s.uiTheme); const chartTheme = useAppStore((s) => s.chartTheme); // Data inspector: open-state gates the divider + table; its height is the divider's. const inspectorOpen = useAppStore((s) => s.dataInspectorOpen); const inspectorHeight = useAppStore((s) => s.dataInspectorHeight); // Custom themes feed `custom:` selection resolution; re-rendering on a // change keeps the chart live while a selected theme is edited in the builder. const customThemes = useCustomThemeStore((s) => s.themes); // 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); // 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); // Bumped whenever a render settles and changes the live view (success, clear, // error) so the data inspector re-reads the resolved rows. A monotonic counter, // not `chartReady` — consecutive successful renders keep `chartReady` true, but // each one is new data the inspector must pick up. const [renderEpoch, setRenderEpoch] = useState(0); // Bumped (debounced, inside the handle) when an interactive selection changes // the inspected data without a re-render — the live data inspector (spec §04; // multi-view scope doc M5). Kept separate from `renderEpoch` so a brush pulse // re-reads the table without re-subscribing the listener; their sum is the // inspector's single refresh trigger (each event bumps exactly one, so the sum // is strictly monotonic — no collisions). const [liveEpoch, setLiveEpoch] = useState(0); // 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 () => { // Every render — including the empty/clear path — advances the generation // token, so a slow in-flight render that resolves later knows it's stale. // (Clearing must invalidate a pending render too, or it would redraw the // chart the user just cleared.) const mine = ++generationRef.current; const isEmpty = text === ''; // Parse before touching the node so a syntax error leaves the chart up. let parsed: unknown; if (!isEmpty) { try { parsed = JSON.parse(text); } catch (e) { if (mine === generationRef.current) setError(`Invalid JSON: ${(e as Error).message}`); return; } } /** 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); }; // Arm the busy indicator: if a (non-empty) 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); if (!isEmpty) { busyTimerRef.current = setTimeout(() => { // Only arm for the current generation; a superseded render doesn't own busy. if (mine === generationRef.current) setBusy(true); }, 1000); } // Serialize node mutations: wait for any in-flight render to settle before // we touch the shared host node. Two concurrent vega-embed calls on one node // interleave, and a superseded render's destroy() (which clears the node) // could blank the chart a newer render is showing. Holding this lock makes // both impossible. After the wait, bail if a newer render already superseded // us — no wasted embed, and the clear/destroy below only runs while we hold // the lock, so it can never wipe a chart another render is currently showing. const prior = renderChainRef.current; let release!: () => void; renderChainRef.current = new Promise((r) => { release = r; }); try { await prior; if (mine !== generationRef.current) { clearBusy(); return; } // Empty/blank is not an error — clean, empty pane (spec §04). if (isEmpty) { handleRef.current?.destroy(); handleRef.current = null; setChartReady(false); setRenderEpoch((e) => e + 1); setError(null); clearBusy(); return; } try { const prepared = prepareSpecForRender(parsed, { fitMode, datasets }); const config = chartConfigForSelection(chartTheme, uiTheme, customThemes); handleRef.current?.destroy(); handleRef.current = null; const handle = await renderSpec(node, prepared as VisualizationSpec, config); if (mine !== generationRef.current) { // A newer render superseded this one. We still hold the lock, so the // newer render hasn't drawn yet (it's queued behind us) — finalizing // this handle and clearing the node is safe; the newer render redraws // on a clean node when it acquires the lock. handle.destroy(); return; } handleRef.current = handle; configRef.current = config; setChartReady(true); setRenderEpoch((e) => e + 1); setError(null); clearBusy(); } catch (e) { if (mine === generationRef.current) { setChartReady(false); setRenderEpoch((e) => e + 1); 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.`, ); } } } } finally { release(); } })(); }, delay); return () => clearTimeout(timer); }, [ shownText, fitMode, uiTheme, chartTheme, customThemes, 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 only when no view is live // (the "not ready" case). A rasterize/serialize *failure* is a real error, not a // not-ready state, so it propagates for the export UI to report with its detail — // burying it here would misreport a tainted canvas or a serialization bug as // "not ready yet" (arch 02 fail-loud). Stable identity (no deps). const getImageUrl = useCallback( async ( format: 'png' | 'svg', options: { scale: number; background: string | null }, ): Promise => { const handle = handleRef.current; if (!handle) return null; // Embed the referenced uploaded faces into an SVG so it renders off-app // (spec §08). Read fresh: a font can be uploaded after the last render, and // configRef holds the config that render used (a font may be theme-only). const embedFonts = format === 'svg' ? referencedUploadedFonts( selectShownText(useSnippetStore.getState()), configRef.current, useFontStore.getState().fonts, ) : undefined; return await handle.toImageURL(format, { ...options, embedFonts }); }, [], ); // The input + resolved rows for the data inspector — reads the live view through // the handle (null when no chart is up). Stable identity; the inspector re-reads // on `renderEpoch`, so this need not depend on it (it always reads the latest handle). const getInspectData = useCallback(() => handleRef.current?.inspectData() ?? null, []); // Live data inspection (spec §04): while the inspector is open, re-read the table // when an interactive selection changes the data it shows (a filtering brush). The // handle owns the Vega listeners + debounce; we just bump `liveEpoch` on each fire. // Re-subscribes whenever a render settles (`renderEpoch`) so it tracks the current // handle, and only while the inspector is open so a collapsed one costs nothing. // handleRef is a ref (read, not a dep); the cleanup unsubscribes. useEffect(() => { if (!inspectorOpen) return; const handle = handleRef.current; if (!handle) return; return handle.onDataChange(() => setLiveEpoch((e) => e + 1)); }, [inspectorOpen, renderEpoch]); // 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; setChartReady(false); if (busyTimerRef.current !== null) clearTimeout(busyTimerRef.current); usePreviewStore.getState().setError(null); usePreviewStore.getState().setBusy(false); }, [], ); return (
{/* Right cluster: chart theme, export this chart, the structure wireframe, then the settings gear. */}
{/* * 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. */} {/* Data inspector — input vs. resolved rows, stacked below the chart (a collapsed disclosure by default); reads the live view through getInspectData. When open, a draggable divider sizes it (chart absorbs the change) and the stored height fills the table; collapsed, it's just the disclosure bar. */} {inspectorOpen && ( )}
); }