Implement fit-mode rendering contract with container sizing and pane re-fit

This commit is contained in:
2026-06-05 10:45:41 +03:00
parent f4253f50ca
commit 411bfbc6c2
13 changed files with 552 additions and 73 deletions
+99 -20
View File
@@ -1,40 +1,90 @@
/**
* Live Preview — the right pane (spec §04).
*
* Renders the active snippet's current buffer as a Vega-Lite chart, debounced so
* typing stays smooth. The pipeline is: buffer text → JSON.parse →
* prepareSpecForRender (copy, pure) → renderSpec (vega-embed). A render-
* generation token guards against a slow render resolving after a newer one.
* 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.
*
* M1 scope: inline-data specs, Original sizing, basic error text. Fit modes (M2)
* and dataset reference resolution (M3) plug into prepareSpecForRender without
* changing this component.
* 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, useState } from 'react';
import { useEffect, useRef } from 'react';
import type { VisualizationSpec } from 'vega-embed';
import type { FitMode } from '@core/rendering';
import { prepareSpecForRender } from '@core/rendering';
import { chartConfigFor } from '@core/vega-themes';
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
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_MODES: ReadonlyArray<{ mode: FitMode; label: string }> = [
{ mode: 'default', label: 'Original' },
{ mode: 'width', label: 'Width' },
{ mode: 'height', label: 'Height' },
{ mode: '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);
return (
<div className={styles.fit} role="group" aria-label="Fit chart to pane">
{FIT_MODES.map(({ mode, label }) => (
<button
key={mode}
type="button"
className={`${styles.fitOption} ${mode === fitMode ? styles.fitActive : ''}`}
aria-pressed={mode === fitMode}
onClick={() => setFitMode(mode)}
>
{label}
</button>
))}
</div>
);
}
export function LivePreview() {
const hostRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<RenderHandle | null>(null);
const generationRef = useRef(0);
const draftText = useSnippetStore((s) => s.draftText);
const shownText = useSnippetStore(selectShownText);
const fitMode = useAppStore((s) => s.previewFitMode);
const uiTheme = useAppStore((s) => s.uiTheme);
const [error, setError] = useState<string | null>(null);
const error = usePreviewStore((s) => s.error);
const setError = usePreviewStore((s) => s.setError);
useEffect(() => {
const node = hostRef.current;
if (!node) return;
const text = draftText.trim();
const text = shownText.trim();
// 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).
@@ -58,7 +108,7 @@ export function LivePreview() {
const mine = ++generationRef.current;
try {
const prepared = prepareSpecForRender(parsed, { fitMode: 'default' });
const prepared = prepareSpecForRender(parsed, { fitMode });
const config = chartConfigFor(uiTheme);
handleRef.current?.destroy();
handleRef.current = null;
@@ -67,9 +117,9 @@ export function LivePreview() {
// 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 in M1; when fit-mode/dataset
// work (M2/M3) lands, serialize renders or finalize the stale view
// without clearing the shared node.
// 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;
}
@@ -87,21 +137,50 @@ export function LivePreview() {
}, RENDER_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [draftText, uiTheme]);
}, [shownText, fitMode, uiTheme, setError]);
// Finalize the live view on unmount.
// 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.chart} ref={hostRef} hidden={error !== null} />
{error !== null && <pre className={styles.error}>{error}</pre>}
<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>
{error !== null && <pre className={styles.error}>{error}</pre>}
</div>
</div>
);
}