Add live-preview busy indicator for slow renders (M6, §04/§10)

This commit is contained in:
2026-06-07 20:01:39 +03:00
parent 800a313be2
commit 14f34712b2
5 changed files with 261 additions and 8 deletions
+61 -8
View File
@@ -110,11 +110,14 @@ export function LivePreview() {
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);
// 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;
@@ -147,6 +150,25 @@ export function LivePreview() {
}
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);
@@ -165,8 +187,10 @@ export function LivePreview() {
}
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).
@@ -187,7 +211,17 @@ export function LivePreview() {
}, delay);
return () => clearTimeout(timer);
}, [shownText, fitMode, uiTheme, datasets, setError, bufferEpoch, editorView, renderDebounce]);
}, [
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
@@ -205,13 +239,15 @@ export function LivePreview() {
return () => ro.disconnect();
}, []);
// Finalize the live view on unmount, and clear the shared error so a stale
// message never outlives this pane.
// 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);
},
[],
);
@@ -222,7 +258,12 @@ export function LivePreview() {
<FitControl />
<PreviewSettings />
</div>
<div className={styles.body}>
{/*
* 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
@@ -234,6 +275,18 @@ export function LivePreview() {
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>
);