mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
218 lines
9.3 KiB
TypeScript
218 lines
9.3 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 { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
|
import styles from './LivePreview.module.css';
|
|
|
|
/** Render debounce (ms). Becomes the configurable `renderDebounce` setting in M5. */
|
|
const RENDER_DEBOUNCE_MS = 300;
|
|
|
|
/** 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}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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);
|
|
// 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);
|
|
|
|
// TODO (council backfill, doc §10.2): a render that exceeds ~1s owes a
|
|
// non-blocking busy indication (overlay + aria-busy), gated by a threshold so
|
|
// sub-1s renders show nothing. Deferred — it pairs with the heavier M3 dataset
|
|
// renders the budget flags; today's inline-data renders are effectively instant.
|
|
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 : RENDER_DEBOUNCE_MS;
|
|
|
|
// 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;
|
|
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);
|
|
} catch (e) {
|
|
if (mine === generationRef.current) {
|
|
// 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, bufferEpoch, editorView]);
|
|
|
|
// 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 so a stale
|
|
// message never outlives this pane.
|
|
useEffect(
|
|
() => () => {
|
|
handleRef.current?.destroy();
|
|
handleRef.current = null;
|
|
usePreviewStore.getState().setError(null);
|
|
},
|
|
[],
|
|
);
|
|
|
|
return (
|
|
<div className={styles.preview}>
|
|
<div className={styles.header}>
|
|
<FitControl />
|
|
</div>
|
|
<div className={styles.body}>
|
|
{/* 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>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|