mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
596 lines
26 KiB
Markdown
596 lines
26 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.
|
||
|
||
---
|
||
|
||
## 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, default mark colors. Astrolabe ships one
|
||
config per UI theme so charts visually belong to the app rather than looking like
|
||
stock Vega-Lite.
|
||
|
||
```ts
|
||
// src/core/vega-themes.ts (sketch)
|
||
import type { Config } from 'vega-lite';
|
||
|
||
export const lightChartConfig: Config = {
|
||
background: 'transparent',
|
||
font: '"Inter", sans-serif',
|
||
title: { fontSize: 15, fontWeight: 600, color: '#1c1c1e' },
|
||
axis: {
|
||
domainColor: '#1c1c1e',
|
||
gridColor: '#e4e4e7',
|
||
gridDash: [3, 3],
|
||
labelColor: '#52525b',
|
||
titleColor: '#1c1c1e',
|
||
labelFontSize: 11,
|
||
titleFontSize: 12,
|
||
},
|
||
range: {
|
||
category: ['#2f6df6', '#f5a524', '#17b890', '#e5484d', '#8b5cf6', '#0ea5e9'],
|
||
},
|
||
view: { stroke: 'transparent' },
|
||
};
|
||
|
||
export const darkChartConfig: Config = {
|
||
background: 'transparent',
|
||
font: '"Inter", sans-serif',
|
||
title: { fontSize: 15, fontWeight: 600, color: '#f4f4f5' },
|
||
axis: {
|
||
domainColor: '#a1a1aa',
|
||
gridColor: '#3f3f46',
|
||
gridDash: [3, 3],
|
||
labelColor: '#a1a1aa',
|
||
titleColor: '#f4f4f5',
|
||
labelFontSize: 11,
|
||
titleFontSize: 12,
|
||
},
|
||
range: {
|
||
category: ['#5b8def', '#f5a524', '#2dd4a7', '#f0666b', '#a78bfa', '#38bdf8'],
|
||
},
|
||
view: { stroke: 'transparent' },
|
||
};
|
||
```
|
||
|
||
One mapping, in one place, is the single source of truth for theme → config:
|
||
|
||
```ts
|
||
// src/core/vega-themes.ts
|
||
import type { UiTheme } from './theme'; // core-local — never import from src/app
|
||
|
||
const CHART_CONFIG: Record<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.
|
||
|
||
```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.
|
||
|
||
---
|
||
|
||
## 6. Rendering Contract Lives Upstream (reference)
|
||
|
||
Before a spec reaches `renderSpec`, it passes through a **pure** transform in
|
||
`src/core/rendering.ts`:
|
||
|
||
```ts
|
||
prepareSpecForRender(spec, { fitMode }): TopLevelSpec
|
||
```
|
||
|
||
It does two deterministic things, on a **deep copy** of the spec:
|
||
|
||
1. **Dataset reference resolution** — replaces any named-data reference with the
|
||
referenced dataset's actual contents (inline values, raw CSV/TSV text, or a
|
||
URL reference), recursing into layered/concat/child sub-specs.
|
||
2. **Fit-mode sizing** — rewrites `width`/`height` per the active fit mode using
|
||
Vega-Lite's `"container"` keyword (Original = untouched; Width sets
|
||
`width:"container"` and **removes** `height`; Height sets `height:"container"`
|
||
and **removes** `width`; Full sets both — see spec §04 → Fit-mode sizing),
|
||
recursing the same way.
|
||
|
||
This is _content_ preparation, not embedding, and it is fully covered by the
|
||
_Live Preview_ spec. The only invariant this doc cares about:
|
||
|
||
> `prepareSpecForRender` runs on a copy and returns a new spec. The renderer
|
||
> embeds that returned spec. **The user's stored spec is never mutated by
|
||
> rendering.**
|
||
|
||
The container-relative fit modes (Width/Height/Full) depend on `"container"`
|
||
sizing to follow the pane. Re-fitting on a **pane resize** is _not_ a re-embed:
|
||
the existing view is re-measured via a `ResizeObserver`-driven event — see §8.
|
||
|
||
### Rules
|
||
|
||
- **Do** call `prepareSpecForRender` between parse and embed, every render.
|
||
- **Don't** put reference resolution or fit-mode logic in the renderer — it is
|
||
pure core logic and must be unit-testable without a DOM.
|
||
- **Don't** mutate the input spec anywhere in the pipeline.
|
||
|
||
---
|
||
|
||
## 7. Error Handling
|
||
|
||
A spec that cannot be rendered must produce a **readable** message in the preview
|
||
area and recover on its own once the spec is valid again. Errors arise at three
|
||
stages, all funneled to one error field the preview reads:
|
||
|
||
| Stage | Failure | Surfaced as |
|
||
| -------------------------------- | -------------------------------------- | ---------------------- |
|
||
| Parse | Invalid JSON | "Invalid JSON: …" |
|
||
| Prepare (`prepareSpecForRender`) | Referenced dataset missing/unfetchable | "Dataset not found: …" |
|
||
| Embed (`vega-embed`) | Vega-Lite compile / data error | "Rendering error: …" |
|
||
|
||
```ts
|
||
// inside render(), driven by the debounced renderer
|
||
async function render(): Promise<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`) |
|