mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
456 lines
20 KiB
TypeScript
456 lines
20 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 { 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 { 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<SegmentedOption<FitMode>> = [
|
|
// `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<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}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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<ReadonlyArray<SelectControlOption<ThemePickerValue>>>(() => {
|
|
const list: SelectControlOption<ThemePickerValue>[] = [...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 (
|
|
<SelectControl
|
|
id="preview-chart-theme"
|
|
label="Chart theme"
|
|
options={options}
|
|
value={chartTheme}
|
|
onSelect={(value) => {
|
|
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 (
|
|
<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);
|
|
// 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<Config | null>(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<void>>(Promise.resolve());
|
|
const shownText = useSnippetStore(selectShownText);
|
|
const fitMode = useAppStore((s) => s.previewFitMode);
|
|
const uiTheme = useAppStore((s) => s.uiTheme);
|
|
const chartTheme = useAppStore((s) => s.chartTheme);
|
|
// Custom themes feed `custom:<id>` 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);
|
|
|
|
// 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 () => {
|
|
// 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<void>((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);
|
|
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);
|
|
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:
|
|
// 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<string | null> => {
|
|
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 });
|
|
},
|
|
[],
|
|
);
|
|
|
|
// 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 (
|
|
<div className={styles.preview}>
|
|
<div className={styles.header}>
|
|
<FitControl />
|
|
{/* Right cluster: chart theme, export this chart, then the settings gear. */}
|
|
<div className={styles.headerEnd}>
|
|
<ChartThemeControl />
|
|
<ChartExport chartReady={chartReady} getImageUrl={getImageUrl} />
|
|
<PreviewSettings />
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|