# Rendering, Theming & Live Preview How Astrolabe turns a user-authored Vega-Lite specification into a live chart in the preview pane. This covers four mechanics: **embedding** a spec via `vega-embed`, **theming** so charts match the active UI theme, **debounced re-rendering** so typing stays smooth, and **error handling** so a broken spec produces a readable message and self-heals. It deliberately stops at the embedding boundary — the _content_ of the spec (resolving named-dataset references, applying fit-mode sizing) is prepared upstream by a pure transform; see §6. --- ## 1. The Embedding Boundary The preview is a thin imperative layer wrapping the `vega-embed` library, driven by reactive store state. The flow is always the same: ``` spec text ──parse──▶ Vega-Lite spec object │ ▼ prepareSpecForRender(spec, { fitMode }) ← pure, src/core/rendering.ts │ (operates on a COPY; never mutates the stored spec) ▼ render(node, preparedSpec, config) ← src/app, this doc │ vega-embed ─▶ View ─▶ SVG in the DOM node ``` `vega-embed` is the only place in the app that touches the chart DOM. Everything above it is data; everything below it is a Vega `View` we own and must tear down. ### Rules - **Do** keep all `vega-embed` calls behind one small renderer module. Components ask the renderer to draw a spec into a node; they never import `vega-embed` directly. - **Do** treat the renderer as imperative glue driven by store state (via a `subscribe` listener), not as reactive state itself. - **Don't** scatter `vegaEmbed(...)` calls across components. > **Forward note — data inspector.** A planned vega-editor-style inspector (show the > _resolved, post-transform_ data tables, in the builder and as a togglable panel below > the main Live Preview) reads runtime rows via `view.data(name)`. It rides this same > boundary: a `RenderHandle.data()` accessor wraps the view (like `toImageURL`), so no > component touches the raw view. Parked, not built — see > [`docs/data-inspector-exploration.md`](../data-inspector-exploration.md). --- ## 2. vega-embed Integration A single async `render` function embeds a prepared spec into a DOM node. Three non-negotiable embed options, plus disciplined teardown of the previous view: ```ts // src/app/services/chart-renderer.ts (sketch) import vegaEmbed, { type Result as EmbedResult } from 'vega-embed'; import type { Config, TopLevelSpec } from 'vega-lite'; export interface RenderHandle { /** Finalize the underlying Vega view and release its resources. */ destroy(): void; } export async function renderSpec( node: HTMLElement, spec: TopLevelSpec, config: Config, ): Promise { const result: EmbedResult = await vegaEmbed(node, spec, { actions: false, // no built-in export/source/editor menu — clean chart renderer: 'svg', // crisp, inspectable, copyable output config, // theme config (see §3) }); return { destroy() { // Frees timers, listeners, and the canvas/SVG the view created. result.view.finalize(); node.replaceChildren(); // drop any leftover DOM the embed inserted }, }; } ``` ### The view lifecycle is the bug surface Every successful `vegaEmbed` returns a `result.view` (a live Vega `View` instance). It owns timers, signal listeners, and DOM. If you embed a new spec into the same node _without_ finalizing the old view, the old one leaks — its listeners keep firing and resources accumulate over a long editing session. The renderer that drives re-rendering must therefore hold the previous handle and destroy it before (or while) creating the next: ```ts let current: RenderHandle | null = null; async function rerender(node: HTMLElement, spec: TopLevelSpec, config: Config) { current?.destroy(); // tear down the previous view first current = await renderSpec(node, spec, config); } ``` ### Rules - **Do** pass `actions: false`. Astrolabe owns its own export/copy affordances; the library's overlay menu does not belong on the preview. - **The per-chart export goes through the handle, not the raw view.** `RenderHandle` exposes `toImageURL('png' | 'svg', { scale, background })` so the preview's Export control can rasterize/serialize the live chart without any component importing `vega-embed` or touching the `View` directly — the embedding boundary holds. PNG goes via `view.toCanvas` → `blob:` URL (revoked after the download); SVG via `view.toSVG` → `data:` URL. Renderer-agnostic: both work from the SVG-backed LivePreview view, since Vega draws to its own off-screen surface here. Two non-obvious details live in the handle, not the caller: **(1) dpr-aware scale** — the PNG is drawn at `scale × devicePixelRatio`, so a `1×` export is as crisp as the chart on a Retina screen (raw `toImageURL` scaleFactor ignores dpr, so a naive 1× looks half-resolution on a 2× display). **(2) background fill** — the chart config renders a transparent background (so the on-screen chart shows the pane colour), which would make a naive export transparent; an opaque colour is composited under the PNG canvas and added as a full-bleed `` to the SVG. The spec-text exports (copy / `.vl.json`) need no view. - **SVG is the default renderer, canvas is an opt-in for many-mark previews.** SVG is crisp/inspectable/copyable and stays the default for the editor's LivePreview. But an SVG chart renders one DOM node per mark, so a many-mark chart (e.g. the Chart Builder's default one-bar-per-row on a 10k-row dataset) costs **seconds** of main-thread layout/paint per render (measured ~6.5s on 9994 rows; the chart paints _after_ it first appears, freezing the tab). The **Chart Builder preview** therefore passes `renderSpec(…, { renderer: 'canvas' })` — canvas is a single node and paints in milliseconds. The raster trade-off is invisible for an ephemeral preview, and image export (`view.toImageURL`) is renderer-agnostic. - **Canvas has a hard max dimension; SVG doesn't.** A canvas larger than the browser's limit (~32k px/side, less on Safari) fails to allocate and draws _nothing_ — silently. So for canvas, `renderSpec` first runs a headless (`'none'`) layout probe, reads the resolved height, and throws `ChartTooLargeError(heightPx, limitPx)` when it exceeds `MAX_CANVAS_PX ÷ devicePixelRatio`, so the caller can show the real cause. This is a **render-size** limit (the chart is physically too big), distinct from the readability cardinality warnings — don't conflate them. Only an _unbounded_ axis overflows: a `width: 'container'` axis is bounded, so it's the deleted (natural-height) axis to watch. - **Do** call `view.finalize()` on every previous view before rendering a new one, and on component unmount. - **Do** keep exactly one live view per preview node. - **Don't** re-embed into a node whose previous view you have not finalized. - **Don't** keep a reference to a finalized view; null it out. --- ## 3. Theme Follows the UI Theme A Vega-Lite **config** object styles every chart globally — fonts, axis colors, background, the categorical color range, default mark colors. Astrolabe ships one config per UI theme so charts visually belong to the app rather than looking like stock Vega-Lite. ```ts // src/core/vega-themes.ts (sketch) import type { Config } from 'vega-lite'; export const lightChartConfig: Config = { background: 'transparent', font: '"Inter", sans-serif', title: { fontSize: 15, fontWeight: 600, color: '#1c1c1e' }, axis: { domainColor: '#1c1c1e', gridColor: '#e4e4e7', gridDash: [3, 3], labelColor: '#52525b', titleColor: '#1c1c1e', labelFontSize: 11, titleFontSize: 12, }, range: { category: ['#2f6df6', '#f5a524', '#17b890', '#e5484d', '#8b5cf6', '#0ea5e9'], }, view: { stroke: 'transparent' }, }; export const darkChartConfig: Config = { background: 'transparent', font: '"Inter", sans-serif', title: { fontSize: 15, fontWeight: 600, color: '#f4f4f5' }, axis: { domainColor: '#a1a1aa', gridColor: '#3f3f46', gridDash: [3, 3], labelColor: '#a1a1aa', titleColor: '#f4f4f5', labelFontSize: 11, titleFontSize: 12, }, range: { category: ['#5b8def', '#f5a524', '#2dd4a7', '#f0666b', '#a78bfa', '#38bdf8'], }, view: { stroke: 'transparent' }, }; ``` One mapping, in one place, is the single source of truth for theme → config: ```ts // src/core/vega-themes.ts import type { UiTheme } from './theme'; // core-local — never import from src/app const CHART_CONFIG: Record = { light: lightChartConfig, dark: darkChartConfig, }; export function chartConfigFor(theme: UiTheme): Config { return CHART_CONFIG[theme]; } ``` The renderer reads the active UI theme (from the store) and passes the matching config into `renderSpec`. When the theme changes, the same subscriber that drives re-rendering picks up the new config and the chart restyles automatically. ### Rules - **Do** keep `chartConfigFor` as the _only_ place that maps a UI theme to a Vega config. Adding a UI theme = adding one config and one map entry. - **Do** set chart `background: 'transparent'` so the pane's own background shows through and theme switches look seamless. - **Don't** inline colors or fonts into individual specs to "match the theme" — that is the config's job, and per-spec styling drifts from the app. - **Don't** let the user's stored spec carry a `config`; the theme config is applied at embed time via the embed options, leaving the spec theme-agnostic. ### Theme flow (end to end) Theme spans several layers; the path is: `AppStore.uiTheme` (+ `toggleTheme`) → `orchestration/theme.ts` mirrors it onto `` and writes through to `infrastructure/settings-store.ts` (localStorage `ui.theme`). On load, `initTheme()` — called from `main.tsx` **before** `createRoot().render` — hydrates the saved theme. Chart and editor follow by subscribing to `uiTheme`: `LivePreview` re-embeds with `chartConfigFor(theme)`, `SpecEditor` sets the Monaco theme. UI chrome repaints purely from the `[data-theme]` token swap in `styles/tokens.css`. The header `ThemeToggle` is the user control. - **Do** hydrate the theme **synchronously before first paint** — an async hydrate (e.g. inside `initApp`) flashes the default theme on load. - **Do** keep the store browser-free: the `data-theme` write and the localStorage write-through live in `orchestration/theme.ts`, never in the store or a component. - The control currently lives in the header; spec §07 houses it in the Settings modal (M5), which will share the same `ui.theme` key. --- ## 4. Field-Name Escaping Vega-Lite treats `.`, `[`, and `]` inside a `field:` string as **nested-property accessors**: `field: "user.age"` reads `row.user.age`, not a column literally named `"user.age"`. Astrolabe renders arbitrary user data whose column names may contain those characters, so any column name placed into a `field:` (or `as:`, `groupby:`, tooltip `field:`, etc.) must be escaped first. ```ts // src/core/rendering.ts (sketch) /** Escape `.`/`[`/`]` so Vega-Lite treats the string as a literal field name. */ export function escapeVegaField(name: string): string { return name.replace(/([.[\]])/g, '\\$1'); } ``` ```ts // usage when constructing/normalizing an encoding that references a column: encoding.x = { field: escapeVegaField(columnName), type: 'quantitative' }; ``` This matters wherever Astrolabe _constructs_ spec fragments from data-derived column names — most notably the chart builder (see _Chart Builder_ spec) and any helper that injects an encoding. For specs the user authored by hand, escaping is the user's responsibility; Astrolabe does not rewrite hand-authored `field:` values. ### Rules - **Do** route every data-derived column name through `escapeVegaField` before it lands in a `field:` (or any field-position key). - **Don't** ever pass a raw column name to `field:`. If the name came from data, it is unescaped until proven otherwise. --- ## 5. Debounced Preview Rendering must never compete with typing. The preview re-renders only after the user pauses, the pending render is cancelled on each new keystroke, and a render in flight never blocks the editor. The debounce delay is **user-configurable** via the `performance.renderDebounce` setting (range ~500–5000 ms). Read it live so changes take effect without reload. ```ts // src/app/services/debounced-renderer.ts (sketch) export interface DebouncedRenderer { /** Schedule a render after the debounce window; resets the timer. */ schedule(): void; /** Render now, skipping the debounce (e.g. on fit-mode change or theme flip). */ flush(): void; /** Cancel a pending render without rendering. */ cancel(): void; } export function createDebouncedRenderer(opts: { /** Current debounce delay in ms; read fresh each schedule so settings apply live. */ delayMs: () => number; /** Performs one render. Reads the current spec/theme; awaits the embed. */ render: () => Promise; /** Toggle the non-blocking busy indicator. */ setBusy: (busy: boolean) => void; }): DebouncedRenderer { let timer: ReturnType | null = null; let generation = 0; // guards against a stale in-flight render finishing late const run = async () => { timer = null; const mine = ++generation; opts.setBusy(true); try { await opts.render(); } finally { // Only the most recent render clears the indicator. if (mine === generation) opts.setBusy(false); } }; return { schedule() { if (timer) clearTimeout(timer); // cancel the pending render timer = setTimeout(run, opts.delayMs()); }, flush() { if (timer) { clearTimeout(timer); timer = null; } void run(); }, cancel() { if (timer) { clearTimeout(timer); timer = null; } generation++; // abandon any in-flight result }, }; } ``` ### Wiring it to the store Startup subscribers observe the inputs that affect the picture — the current spec text, the active fit mode, the UI theme — and call `schedule()` (debounced) for spec edits, or `flush()` for instantaneous controls like a fit-mode toggle: ```ts // wired once at startup useEditorStore.subscribe((s, prev) => { if (s.currentSpecText !== prev.currentSpecText) renderer.schedule(); // react to edits }); useSettingsStore.subscribe((s, prev) => { if (s.previewFitMode !== prev.previewFitMode || s.uiTheme !== prev.uiTheme) { renderer.flush(); // immediate, no debounce } }); ``` ### Implemented policy: what renders immediately vs. debounced > The service above is a **sketch** — its `useEditorStore`/`useSettingsStore` are > illustrative placeholders; the real inputs are `useAppStore` (`previewFitMode` + > `uiTheme`) and `useSnippetStore` (draft text / `bufferEpoch`). The shipped renderer > lives inline in `LivePreview.tsx` (one `setTimeout` whose delay is computed per > change) and subscribes to the stores via hooks rather than startup subscribers. > When it is extracted into a service, preserve this policy. The debounce exists to stay out of the way **while typing** — nothing else. So the delay is `0` (immediate) for everything except keystrokes (spec §03C): - **Immediate** — a _programmatic buffer load_ (`SnippetStore.bufferEpoch` changed: select / create / duplicate / revert / hydrate) or a _Draft↔Published switch_ (`editorView` changed). These are the cases §03C names; the editor and preview both key off `bufferEpoch` to tell a load from a keystroke. - **Debounced** — a keystroke (only `shownText` changed). This is the churn the debounce protects against. Detect "this was a keystroke" by elimination: `shownText` changed but `bufferEpoch` and `editorView` did **not**. Fit-mode and theme changes currently fall through the debounce too (harmless; not typing) — flush them if instant feedback is wanted, but never debounce a load or a view switch. ### Busy indicator `setBusy(true/false)` toggles store state that the preview reads to overlay a **subtle, non-blocking** spinner/shimmer. It sits _over_ the existing chart so the last good render stays visible while the next one computes — the pane never goes blank mid-edit. ### Rules - **Do** read `renderDebounce` fresh on each `schedule()` (via the `delayMs()` thunk) so a settings change applies immediately. - **Do** cancel the pending timer on every new input before scheduling the next. - **Do** guard against out-of-order completion (the `generation` counter): a slow render that resolves after a newer one must not clobber the indicator or view. - **Do** keep the busy indicator non-blocking and overlaid; never clear the chart to show "rendering…". - **Don't** render synchronously on every keystroke. - **Don't** await a render inside an input/keydown handler. ### A second preview surface: the Chart Builder The editor's `LivePreview` is **bound to the snippet editor** — it reads `SnippetStore` (shown spec), `AppStore` (fit mode/theme), and `PreviewStore` (shared error). The **Chart Builder modal** needs a preview of a _different_ spec source (its config), so it does **not** reuse `LivePreview`; it runs its own small debounced render over the same `chart-renderer.renderSpec` + `prepareSpecForRender`, with **local** error state (never the shared `PreviewStore`, which would cross-talk with the editor). Two preview surfaces, one renderer service. Builder flow: `chart-builder.ts` (pure spec assembler) → `ChartBuilderStore` (config + create) → `ChartBuilderModal`'s `BuilderPreview`. Reach for a reusable preview component only if a _third_ surface appears. The builder's **X/Y axis controls live in the preview pane, not the config pane**: the on-chart Columns/Rows shelves (`OnChartShelves`) sit _above_ `BuilderPreview`, because axis position is a property of the chart (Tableau's Columns/Rows metaphor). The field shelf and the Colour/Size Marks card stay in the config pane. A reserved faceting slot in each shelf is a placeholder only. --- ## 6. Rendering Contract Lives Upstream (reference) Before a spec reaches `renderSpec`, it passes through a **pure** transform in `src/core/rendering.ts`: ```ts prepareSpecForRender(spec, { fitMode }): TopLevelSpec ``` It does two deterministic things, on a **deep copy** of the spec: 1. **Dataset reference resolution** — replaces any named-data reference with the referenced dataset's actual contents (inline values, raw CSV/TSV text, or a URL reference), recursing into layered/concat/child sub-specs. 2. **Fit-mode sizing** — rewrites `width`/`height` per the active fit mode using Vega-Lite's `"container"` keyword (Original = untouched; Width sets `width:"container"` and **removes** `height`; Height sets `height:"container"` and **removes** `width`; Full sets both — see spec §04 → Fit-mode sizing), recursing the same way. This is _content_ preparation, not embedding, and it is fully covered by the _Live Preview_ spec. The only invariant this doc cares about: > `prepareSpecForRender` runs on a copy and returns a new spec. The renderer > embeds that returned spec. **The user's stored spec is never mutated by > rendering.** The container-relative fit modes (Width/Height/Full) depend on `"container"` sizing to follow the pane. Re-fitting on a **pane resize** is _not_ a re-embed: the existing view is re-measured via a `ResizeObserver`-driven event — see §8. ### Rules - **Do** call `prepareSpecForRender` between parse and embed, every render. - **Don't** put reference resolution or fit-mode logic in the renderer — it is pure core logic and must be unit-testable without a DOM. - **Don't** mutate the input spec anywhere in the pipeline. --- ## 7. Error Handling A spec that cannot be rendered must produce a **readable** message in the preview area and recover on its own once the spec is valid again. Errors arise at three stages, all funneled to one error field the preview reads: | Stage | Failure | Surfaced as | | -------------------------------- | -------------------------------------- | ---------------------- | | Parse | Invalid JSON | "Invalid JSON: …" | | Prepare (`prepareSpecForRender`) | Referenced dataset missing/unfetchable | "Dataset not found: …" | | Embed (`vega-embed`) | Vega-Lite compile / data error | "Rendering error: …" | ```ts // inside render(), driven by the debounced renderer async function render(): Promise { const text = useEditorStore.getState().currentSpecText.trim(); // Empty/blank is NOT an error — render nothing, clean pane. if (!text) { current?.destroy(); current = null; usePreviewStore.getState().setError(null); return; } let parsed: unknown; try { parsed = JSON.parse(text); } catch (e) { usePreviewStore.getState().setError(`Invalid JSON: ${(e as Error).message}`); return; // keep the last good chart underneath the error, or show the message } try { const { previewFitMode, uiTheme } = useSettingsStore.getState(); const prepared = prepareSpecForRender(parsed, { fitMode: previewFitMode }); const config = chartConfigFor(uiTheme); current?.destroy(); current = await renderSpec(node, prepared, config); usePreviewStore.getState().setError(null); // success clears any prior error } catch (e) { usePreviewStore .getState() .setError( `Rendering error: ${(e as Error).message}. ` + `Check your JSON syntax and that the spec is valid Vega-Lite.`, ); } } ``` The preview component renders the chart node when `error` is `null`, and the error panel when it is set. Because **every successful render clears the error**, recovery is automatic: the next valid edit re-renders and wipes the message — no manual retry, no reload. ### Rules - **Do** treat empty/blank spec text as "render nothing" — finalize the current view, clear the error, show a clean empty pane. - **Do** clear the error state on every successful render. - **Do** make messages legible and actionable (the underlying reason plus a hint to check JSON/Vega-Lite validity), never a raw stack trace dump. - **Do** distinguish the failing stage in the message (Invalid JSON vs Dataset not found vs Rendering error). - **Don't** show a broken/partial chart — replace the chart area with the message. - **Don't** require a manual "retry"; validity restores the chart on its own. --- ## 8. Container Sizing & Pane Resize (two gotchas that cost real time) Vega-Lite's `"container"` sizing is responsible for the Width/Height/Full fit modes, and it has **two non-obvious failure modes**. Both were rediscovered the hard way; this section is the shortcut. ### Gotcha 1 — the embed host shrink-wraps, collapsing `width:"container"` `vega-embed` brands the element you embed into with its own `.vega-embed { display: inline-block }`, injected into `` at runtime so it **wins the cascade** over a class you put on that same element. `inline-block` shrink-wraps horizontally, and `"container"` width reads `host.clientWidth` — so the chart collapses to near-zero width. (Height often survives because a tall box keeps `clientHeight`, which is why the symptom is "Width broken, Height fine".) Note also: `vega-embed` only adds its responsive `chart-wrapper` (the element its `width:100%` rule targets) **when `actions` are enabled** — we pass `actions: false`, so that path is dead and the host is branded directly. **Fix:** embed into a dedicated **inner host** with a _static_ className (React never re-reconciles it, so Vega's runtime classes survive) nested inside a **React-owned frame** that carries the fit-mode class. Size the host with **two-class selectors** (`.fitWidth .host { width: 100% }`) that out-specify `.vega-embed`. Original mode lets the host stay natural and the pane scrolls. ### Gotcha 2 — Vega re-measures only on `window:resize` The compiled `width`/`height` signals re-evaluate `containerSize()` **only** on `events: "window:resize"`. Consequences: `view.resize()` re-runs layout with the **stale** size (it does _not_ re-measure), and a pane drag fires no window resize, so a responsive chart does **not** follow the pane on its own. **Fix:** a `ResizeObserver` on the host → `window.dispatchEvent(new Event('resize'))` (behind `RenderHandle.resize()`, keeping the Vega knowledge in the renderer). `ResizeObserver` callbacks are frame-batched, so this tracks a drag without a debounce. Because only the container-bound dimension carries the resize handler, Width re-fits width and leaves height natural automatically — no fit-mode bookkeeping. Gate the observer to responsive modes (Original needs no re-fit). ### Rules - **Do** give `vega-embed` its own inner host element; never put a React-managed, changing `className` on the element `vega-embed` brands. - **Do** out-specify `.vega-embed` (two-class selectors) when you must size the host. - **Do** bridge pane-resize via a synthetic `window:resize`, not `view.resize()`. - **Don't** assume `actions: false` leaves you the responsive `chart-wrapper` — it doesn't. - **Don't** re-embed just to re-fit a resize; re-measure the existing view. --- ## Summary | Concern | Mechanism | Source of truth | | ------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------- | | Embedding | One `renderSpec` over `vega-embed`, `actions: false`, `renderer: 'svg'` | `src/app/services/chart-renderer.ts` | | View teardown | `view.finalize()` before each re-render and on unmount | the renderer's `RenderHandle` | | Theming | Vega `Config` per UI theme, applied at embed time | `chartConfigFor()` in `src/core/vega-themes.ts` | | Field names | `escapeVegaField` on every data-derived `field:` | `src/core/rendering.ts` | | Debounce | Inline timer; `0` on buffer-load/view-switch, `renderDebounce` on keystroke (§5) | `LivePreview.tsx` (service not yet extracted) | | Spec prep | `prepareSpecForRender` (pure, on a copy) | `src/core/rendering.ts` (see _Live Preview_) | | Errors | One error field, cleared on success, empty = nothing | `PreviewStore.error` | | Container fit | Inner host + frame (out-specify `.vega-embed`); resize via synthetic `window:resize` | §8 (`LivePreview` + `chart-renderer`) |