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

449 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:
```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<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:
```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.
- **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 experimentalChartConfig: 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<UiTheme, Config> = {
light: lightChartConfig,
experimental: experimentalChartConfig,
};
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.
---
## 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 ~5005000 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<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:
```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
}
});
```
### 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.
---
## 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/Height/Full set
the corresponding dimension(s) to `"container"`), 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 `renderer: 'svg'`
plus `"container"` sizing to follow the pane; when the pane resizes, re-running
`prepareSpecForRender` + re-embedding (a `flush()`) re-fits the chart.
### 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<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.
---
## 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 | `createDebouncedRenderer`, delay from `renderDebounce` setting | `src/app/services/debounced-renderer.ts` |
| 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` |