mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Implement M1 authoring loop: library, editor, live preview, persistence
M1 MVP from docs/IMPLEMENTATION-PLAN.md, with the /alignment pass applied. - core: Snippet model + factory; prepareSpecForRender (copy-not-mutate); per-theme Vega chart config - state/orchestration: SnippetStore with debounced auto-save; IndexedDB adapter + read-time migration; startup hydration + write-through persistence - ui: SnippetLibrary, SpecEditor (Monaco edcore.main — full editor features, JSON-only languages), LivePreview - build: Monaco/Vega manual chunks; raised PWA precache ceiling - alignment: flush a valid draft on snippet switch (+regression tests); TODO breadcrumbs for the preview render race and the window.confirm delete - housekeeping: gitignore .claude/projects/
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { VisualizationSpec } from 'vega-embed';
|
||||
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 styles from './LivePreview.module.css';
|
||||
|
||||
/** Render debounce (ms). Becomes the configurable `renderDebounce` setting in M5. */
|
||||
const RENDER_DEBOUNCE_MS = 300;
|
||||
|
||||
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 uiTheme = useAppStore((s) => s.uiTheme);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const node = hostRef.current;
|
||||
if (!node) return;
|
||||
const text = draftText.trim();
|
||||
|
||||
const timer = setTimeout(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: 'default' });
|
||||
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 in M1; when fit-mode/dataset
|
||||
// work (M2/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) {
|
||||
setError(
|
||||
`Rendering error: ${(e as Error).message}. ` +
|
||||
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}, RENDER_DEBOUNCE_MS);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [draftText, uiTheme]);
|
||||
|
||||
// Finalize the live view on unmount.
|
||||
useEffect(
|
||||
() => () => {
|
||||
handleRef.current?.destroy();
|
||||
handleRef.current = null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.chart} ref={hostRef} hidden={error !== null} />
|
||||
{error !== null && <pre className={styles.error}>{error}</pre>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user