39 KiB
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-embedcalls behind one small renderer module. Components ask the renderer to draw a spec into a node; they never importvega-embeddirectly. - Do treat the renderer as imperative glue driven by store state (via a
subscribelistener), not as reactive state itself. - Don't scatter
vegaEmbed(...)calls across components.
The data inspector rides the boundary too
The data inspector (the Live Preview and Chart Builder panel showing each drawn table's
input vs. resolved rows — spec §04) reads runtime rows through the handle, never the raw
view: RenderHandle.inspectData() returns the inspectable tables ({ tables }, or null
when no chart is up), wrapping the view exactly like toImageURL. It works in two layers:
- Enumerate from the compiled spec (
core/inspect-views). A composed spec draws several tables;inspectableViewswalks the compiled Vega spec — the marks tree'sfrom.data(what each mark draws) anddata[].source(the lineage, the documented Vega format) — to list, in document order, one entry per distinct drawn table with itsresolved(post-transform, what the marks draw) andinput(most-upstream source) ends. Enumerating by drawn table, not by authored view, is forced by Vega-Lite desugaring (apoint: trueline compiles to two layers — a compiled table can't be traced back to one authored view). Selection*_stores andfacet_domain*layout tables aren't drawn, so they fall out for free. The walk is pure (incore, unit-tested); the boundary reads each table's rows viaview.data(name). - Read lazily. Reading serializes rows, so it happens only while the panel is open —
a collapsed inspector costs nothing, which is why the panel reads on demand rather than on
every render. (A multi-view spec yields several tables; the panel's
SelectControlpicker chooses which to show — labels never expose Vega's compiler names, see arch 10.) - Stay live under interaction. A selection that filters a downstream view recomputes
that view's compiled table in place (no re-embed), so the open panel re-reads to track it —
"what am I visualizing now".
RenderHandle.onDataChangeattaches a debouncedview.addDataListenerto each drawn table; a highlight selection (aconditionencoding) changes no data, so it never fires. Always live, no toggle — gated on the panel being open like the read itself, and re-subscribed per settled render so it tracks the current handle.
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:
// 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<RenderHandle> {
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:
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.
RenderHandleexposestoImageURL('png' | 'svg', { scale, background })so the preview's Export control can rasterize/serialize the live chart without any component importingvega-embedor touching theViewdirectly — the embedding boundary holds. PNG goes viaview.toCanvas→blob:URL (revoked after the download); SVG viaview.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 atscale × devicePixelRatio, so a1×export is as crisp as the chart on a Retina screen (rawtoImageURLscaleFactor 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<rect>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 (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,
renderSpecfirst runs a headless ('none') layout probe, reads the resolved height, and throwsChartTooLargeError(heightPx, limitPx)when it exceedsMAX_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: awidth: 'container'axis is bounded, so it's the deleted (natural-height) axis to watch. - Load fonts before rendering. Vega measures every text label via canvas
measureTextregardless of renderer (even the'none'probe runs layout), so a face that finishes loading after embed lays the whole chart out with fallback metrics.renderSpectherefore gates ondocument.fonts.loadfor the families a spec+config reference (collectFontFamilies, core) before any layout pass. This is a non-critical enhancement, so it waits onallSettled+ a timeout: a face failing (offline, 404, a system family with no@font-face) degrades to fallback metrics rather than failing the chart (the sanctioned swallow under §7's fail-loud rule). Chart fonts are self-hosted instyles/chart-fonts.css(offered by the Theme Builder's font control); only their latin subsets are precached, the rest runtime-cached (vite.config Workbox). User-uploaded faces are registered ondocument.fontstoo, so the same gate resolves them (§3 → User-uploaded fonts). - 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. Astrolabe ships one config per UI theme
so charts visually belong to the app rather than looking like stock Vega-Lite.
src/core/vega-themes.ts is the single source of truth; each house config is
two merged layers (the full audit and forward plan live in
docs/exploration/chart-theming-scope.md):
- Base (
lightBaseConfig/darkBaseConfig) — the legibility minimum:background: 'transparent'(the pane shows through) plus guide colors on the app's text/border tokens. Without it, stock black-on-white chart text is illegible on the dark pane. - Expressive (
lightExpressiveConfig/darkExpressiveConfig) — the house style: IBM Plex, the Carbon data-viz 14-color categorical palette, dotted grid, bumped guide sizes/weights, no plot border.
mergeChartLayers(base, expressive) produces lightChartConfig/
darkChartConfig, and chartConfigFor(uiTheme) is the one UI-theme → config
mapping. The split exists so a non-house style can keep the base layer while
swapping the expressive one (future custom themes).
Selectable chart themes
On top of the house pair, the user picks a chart theme (spec §04 → Chart
theme) — ChartThemeSelection = 'astrolabe' | 'stock' | <vega-themes preset id> | 'custom:<id>':
'astrolabe'resolves viachartConfigFor(uiTheme)(follows light/dark);'stock'resolves to{}— nothing injected, pure Vega-Lite defaults;- preset ids resolve to the
vega-themespackage's configs verbatim (the same presets as the Vega editor's theme dropdown; the package is already in the tree as a vega-embed dependency); custom:<id>resolves to a savedCustomThemerecord's config (spec §09G). Selection is keyed by record id, not name, so a rename never invalidates the persisted preference; a missing record (themes hydrate async from IndexedDB; the record may be deleted) resolves to the house config rather than rendering unstyled, and deleting the actively-selected theme resetsAppStore.chartThemeto'astrolabe'(CustomThemeStore.remove).
chartConfigForSelection(selection, uiTheme, customThemes) is the only
resolver; chartThemeOptions(customThemes) derives the full picker list
(built-ins, customs, presets — memoize the call: it returns a fresh array). The
choice lives in AppStore.chartTheme, persisted as ui.chartTheme by
orchestration/preferences.ts (the previewFitMode pattern; persistence
validates with isChartThemeSelection, which accepts custom:<id> on shape
alone), and is surfaced by a SelectControl in the LivePreview header — not
inside the PreviewSettings popover: 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 "Edit themes…" action row opens the
Theme Builder without changing the selection (the VS Code theme-picker
pattern); it closes the custom-themes block — after the built-ins, before
the long preset roster — so it's visible without scrolling and sits next to
the entries it manages.
Custom themes & the Theme Builder
CustomTheme records (core/custom-theme.ts) persist in their own IndexedDB
store through the standard stack: infrastructure/theme-store.ts (+ read-time
theme-migrations.ts), stores/CustomThemeStore.ts (the themes array plus the
builder's draft state), and orchestration/theme-persistence.ts (diffing
write-through, wired after hydrate in startup.ts) — the exact dataset
pattern, one tier each.
The Theme Builder modal (ThemeBuilderModal, registered as themeBuilder,
xlarge shell, no backdrop dismissal) edits a draft held in the store:
{ name, configText } plus draftConfig — the last text state that parsed.
The gallery (core/theme-preview-specs.ts, fixed inline-data swatch specs)
renders draftConfig per card through the shared renderSpec with the
canvas renderer and a per-card debounce + chain-lock (the LivePreview
serialization pattern, one lock per card) — so invalid JSON mid-edit never
blanks the preview, and seven concurrent embeds never interleave on a node. A
card whose render throws shows the error message in place of the chart (the same
fail-loud treatment as LivePreview, §7), never a silent blank.
applyFontToConfig(config, family) is the font control's transform: it sets
the top-level font and rewrites every font/*Font string slot at any
depth — explicit slots would otherwise keep overriding the new default.
Creation paths: the builder's "New theme" duplicates the currently selected
chart theme's resolved config, and the editor's Extract Config to New
Theme action (runExtractConfigToTheme, spec §03G) lifts a spec's config
block into a theme, selects it, and removes the block — the spec-to-library
direction of the same boundary the merge action crosses the other way.
Render-time precedence: vega-lite merges the injected config under the
spec's own config (mergeConfig(opt.config, spec.config) — the spec wins
key-by-key), so a snippet can always override or opt out locally. The
core/spec-config.ts merge/extract operations (spec §03G) move styling across
that boundary deliberately: merge bakes the selected theme into spec.config
(spec keys win — rendering unchanged), extract lifts spec.config out.
User-uploaded fonts
Beyond the self-hosted roster, a user can upload font files (.woff2/.woff/
.ttf/.otf) for chart themes. A FontAsset (core/font-asset.ts) holds the
raw bytes and persists through the standard entity-store stack — a fonts
IndexedDB store, infrastructure/font-store.ts (+ font-migrations.ts),
stores/FontStore.ts, orchestration/font-persistence.ts — the same
one-tier-each shape as datasets and custom themes. The browser seam that turns
stored bytes into a live face, infrastructure/font-faces.ts, registers on
document.fonts at startup (before the first render, so the §2 font gate
resolves user faces like the roster) and on each upload; uploads/deletes are
orchestrated by services/fonts.ts. The Type panel lists user fonts ahead of the
roster and applies one via the same applyFontToConfig transform.
Font dependencies are derived from the config, never stored on the theme. A
CustomTheme carries no font field: the family a theme (or a snippet) uses is
already in its config's font/*Font slots, that JSON config is the source of
truth, and snippets use uploaded fonts with no theme to carry such a field. So a
used face is discovered by scanning configs/specs for the families they reference
(collectFontFamilies, core) and matching against the font library — a stored
field would only drift from the config it duplicates.
(Embedding the matched faces' bytes into the §08 workspace export and per-chart
SVG export is the remaining transfer work, on shared base64 machinery.)
Variable fonts: weight is the only leverageable axis. parseFontAxes reads
the OpenType fvar table (uncompressed ttf/otf only — woff/woff2 wrap
their tables in compression we don't unpack, so those register as static), and a
variable face is registered with its wght→weight and wdth→stretch ranges
so one file serves the whole weight range, driven by the Type panel's weight
controls. Only those two axes take effect because Vega's text rendering emits a
CSS font shorthand (family/size/weight/style) with no font-variation-settings
hook — optical size, grade, and custom axes pin at the registered default and
can't be exposed. Declaring the wdth range also defaults the face to normal
width rather than a variable font's possibly-condensed default instance.
Structured controls
The builder's panels — Color, Marks, Type, Title, Layout, Axes & grid, Legend,
Headers, Formats — are accelerators over the same draftConfig, one panel per
config domain (each an XxxControls component on the shared ThemeFields
primitives). ThemeBuilderModal holds the single id → label → Panel registry
(THEME_TABS); panels switch via a vertical tab list (APG vertical tabs),
and each panel groups its properties into a single-expand accordion
(ThemeFields Accordion — one section open at a time, each with a set-count
badge so customized sections are scannable while collapsed). Each control reads a
value and writes one back through CustomThemeStore.mutateDraftConfig(fn) — the
single transform path, which reparses, reformats, and updates draftConfig so
the JSON editor and gallery follow (a parse error disables the controls). The raw
JSON below the panels is the full-power escape hatch for the long tail the
structured controls deliberately omit. The pure transforms live in
core/theme-controls.ts: immutable config path get/set, leaf coercion, the
named-scheme catalog (THEME_SCHEMES), and schemeColors (scheme name → hex
swatches, from the vega-scale registry — a focused vega sub-package). A color
family holds either a named scheme as Vega's range-scheme object
{ scheme: name } or an explicit color array; the picker materializes one to
the other. Family by scale: range.category (nominal), range.ramp (continuous;
range.heatmap for rect), range.diverging (continuous color with a
domainMid).
Repeated control labels across panels ("Size", "Color", "Weight") get a
qualified accessible name while keeping the short visible label; the section is a
role="group" labelled by its heading (APG group pattern), so the name a screen
reader announces is unambiguous.
- Do bind a panel's writes to the shared
useConfigSetter()hook (inCustomThemeStore— besidemutateDraftConfig, not the JSX field module, which stays component-only for fast refresh). It ismutateDraftConfig+setConfigValue: sets a value at a path immutably, preserving sibling keys, and deletes (pruning emptied ancestors) onundefinedso a theme stays a diff. A new panel uses it rather than re-inlining the pair. - Don't rebuild the config from a fixed schema: vega-themes presets carry
Vega-layer keys (
symbol/shape/path/group) absent from the Vega-LiteConfigschema but forwarded to Vega — a rebuild drops them. Merge in place. - Do write a named scheme into
range.*as the object{ scheme: name }. A bare scheme-name string passes vega-lite compile but Vega rejects it at render ("Unrecognized scale range value"), blanking the chart.normalizeRangeSchemes(core) heals the bare form at the render-resolution points (chartConfigForSelection; the builder gallery) for configs authored or saved before this was enforced. - Do give every control a visible mirror in the
theme-preview-specs.tsgallery, and keep those sample specs on bare marks (mark: 'point', not{ type: 'point', size: 80 }): a property hard-coded in a spec overrides the injected config, making the matching control a no-op in the preview. Mark styling belongs in the config (a theme), never inline in a card. Default chart size and tooltips are the unavoidable exceptions — fixed-size swatches, and hover-only respectively.
Rules
- Do keep
chartConfigForSelectionas the only place that maps the user's selection (and UI theme) to a Vega config. - Do set chart
background: 'transparent'in the house configs so the pane's own background shows through and theme switches look seamless. Preset themes carry their own backgrounds (often white) and render as their authors intended — honest preview beats pane-matching. - Do keep the Chart Builder preview and onboarding thumbnails on
chartConfigFor(uiTheme)— they are app surfaces, not destination previews. - 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 write the injected config into the user's stored spec implicitly; it is applied at embed time, leaving the spec theme-agnostic. Baking it in is the explicit, user-invoked merge action only.
Theme flow (end to end)
Theme spans several layers; the path is:
AppStore.uiTheme (+ toggleTheme) → orchestration/theme.ts mirrors it onto
<html data-theme> 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
chartConfigForSelection(chartTheme, uiTheme), 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-themewrite and the localStorage write-through live inorchestration/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.themekey.
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.
// 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');
}
// 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
escapeVegaFieldbefore it lands in afield:(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.
// 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;
}
// The factory wires the three operations over one timer. schedule() does
// `setTimeout(run, delayMs())`, clearing any pending timer first (delayMs read fresh
// so a settings change applies live); flush() clears the timer and runs now; cancel()
// clears it and bumps `generation`. The non-obvious part is out-of-order protection:
function createDebouncedRenderer(opts): DebouncedRenderer {
let generation = 0;
const run = async () => {
const mine = ++generation; // capture this render's turn
opts.setBusy(true);
try {
await opts.render();
} finally {
if (mine === generation) opts.setBusy(false); // only the latest render clears it
}
};
// …schedule/flush/cancel as above. A slow render that resolves after a newer one
// fails the `mine === generation` check, so it can't clobber the fresh view/indicator.
}
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:
// 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/useSettingsStoreare illustrative placeholders; the real inputs areuseAppStore(previewFitMode+uiTheme) anduseSnippetStore(draft text /bufferEpoch). The shipped renderer lives inline inLivePreview.tsx(onesetTimeoutwhose 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.bufferEpochchanged: select / create / duplicate / revert / hydrate) or a Draft↔Published switch (editorViewchanged). These are the cases §03C names; the editor and preview both key offbufferEpochto tell a load from a keystroke. - Debounced — a keystroke (only
shownTextchanged). 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
renderDebouncefresh on eachschedule()(via thedelayMs()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
generationcounter): 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:
prepareSpecForRender(spec, { fitMode }): TopLevelSpec
It does two deterministic things, on a deep copy of the spec:
- 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.
- Fit-mode sizing — rewrites
width/heightper the active fit mode using Vega-Lite's"container"keyword (Original = untouched; Width setswidth:"container"and removesheight; Height setsheight:"container"and removeswidth; 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:
prepareSpecForRenderruns 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
prepareSpecForRenderbetween 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.
- Fit modes overwrite the spec's own sizing (Width replaces
widthand deletesheight, etc.), so a surface that lets the user set an explicit width/height must passfitMode: 'default'while either is set and reserve the container fit for auto sizing — the Chart Builder preview does exactly this.
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: …" |
// inside render(), driven by the debounced renderer
async function render(): Promise<void> {
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 <head> 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-embedits own inner host element; never put a React-managed, changingclassNameon the elementvega-embedbrands. - Do out-specify
.vega-embed(two-class selectors) when you must size the host. - Do bridge pane-resize via a synthetic
window:resize, notview.resize(). - Don't assume
actions: falseleaves you the responsivechart-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) |