mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
638 lines
31 KiB
Markdown
638 lines
31 KiB
Markdown
# 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<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.
|
||
- **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 `<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 (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. 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/chart-theming-scope.md`](../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 via `chartConfigFor(uiTheme)` (follows light/dark);
|
||
- `'stock'` resolves to `{}` — nothing injected, pure Vega-Lite defaults;
|
||
- preset ids resolve to the `vega-themes` package'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 saved `CustomTheme` record'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 resets
|
||
`AppStore.chartTheme` to `'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.
|
||
`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.
|
||
|
||
### Rules
|
||
|
||
- **Do** keep `chartConfigForSelection` as 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-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<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
|
||
}
|
||
});
|
||
```
|
||
|
||
### 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.
|
||
- **Fit modes overwrite the spec's own sizing** (Width replaces `width` _and
|
||
deletes_ `height`, etc.), so a surface that lets the user set an explicit
|
||
width/height must pass `fitMode: '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: …" |
|
||
|
||
```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.
|
||
|
||
---
|
||
|
||
## 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`) |
|