Files
astrolabe/docs/architecture/05-rendering-theming-preview.md
T

25 KiB
Raw Blame History

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.

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.
  • 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.

// 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:

// src/core/vega-themes.ts
import type { UiTheme } from './theme'; // core-local — never import from src/app

const CHART_CONFIG: Record<UiTheme, Config> = {
  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 <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 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.

// 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 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 ~5005000 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;
}

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<void>;
  /** Toggle the non-blocking busy indicator. */
  setBusy: (busy: boolean) => void;
}): DebouncedRenderer {
  let timer: ReturnType<typeof setTimeout> | 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:

// 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.


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:

  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: …"
// 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-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)