Implement fit-mode rendering contract with container sizing and pane re-fit

This commit is contained in:
2026-06-05 10:45:41 +03:00
parent f4253f50ca
commit 411bfbc6c2
13 changed files with 552 additions and 73 deletions
@@ -379,9 +379,9 @@ _Live Preview_ spec. The only invariant this doc cares about:
> 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.
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
@@ -463,14 +463,64 @@ manual retry, no reload.
---
## 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 | `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` |
| 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` |
| Container fit | Inner host + frame (out-specify `.vega-embed`); resize via synthetic `window:resize` | §8 (`LivePreview` + `chart-renderer`) |
+22 -18
View File
@@ -170,10 +170,14 @@ vega/editor's hand-rolled renderer (`src/components/renderer/renderer.tsx`) reve
(`config-editor/config-editor-header.tsx:5-37`). For Astrolabe: pass the chosen `theme`/`config`
to `vegaEmbed`, and when our theme changes, re-embed with the new config.
- **`"width":"container"` / `"height":"container"` is how VL responsiveness works** — it
compiles to a `containerSize` signal (`renderer.tsx:78-90` detects this). Pair it with a
**`ResizeObserver`** on the preview pane → `view.resize().runAsync()`. This is cleaner than
vega/editor's `window.dispatchEvent(new Event('resize'))` hack (`renderer.tsx:101-122`) and is
the mechanism behind our M2 fit-mode contract.
compiles `width`/`height` to signals that re-read `containerSize()` **only on a
`window:resize` event** (`renderer.tsx:78-90` detects container sizing). Two things a
from-scratch impl _will_ get wrong (we did): (1) `view.resize().runAsync()` does **not**
re-measure — it re-runs layout with the stale size; (2) a pane drag fires no window resize, so
nothing re-fits on its own. The fix is exactly vega/editor's
`window.dispatchEvent(new Event('resize'))` (`renderer.tsx:101-122`) — **not a hack, the
actual mechanism** — driven by a `ResizeObserver` on the pane. It also leaves a non-container
dimension natural for free. Full write-up in doc [05](05-rendering-theming-preview.md) §8.
- **Reuse the view for cheap changes.** They rebuild the `View` only on spec change; renderer
(svg/canvas) and tooltip toggles re-`initialize()` the existing view (`renderer.tsx:367-371`).
- **Capture warnings separately from errors** via a buffering logger (see §4's `LocalLogger`).
@@ -273,20 +277,20 @@ defaults-spread" discipline is worth keeping.
## Borrow list (where each lands)
| Technique | Lands in | Milestone |
| ---------------------------------------------------------------------------------------- | -------------------------------------- | --------- |
| Bundle VL schema from package `build/`; `setDiagnosticsOptions` | `src/app/infrastructure/` Monaco setup | M2 |
| `markdownDescription` patch + compact formatter | Monaco setup | M2 |
| Explicit Vite worker wiring (`MonacoEnvironment.getWorker`) | Monaco setup | M2 |
| `fileMatch` schema binding (improvement over `$schema`-only) | Monaco setup | M2 |
| jsonc-parser tolerant parse + line/col syntax errors | `src/core/` | M1/M2 |
| ajv wrapper (`strict:false`, draft-06, color-hex, compile-once) → structured diagnostics | `src/core/` | M2 |
| `LocalLogger`-style buffered diagnostics from pure compile | `src/core/` | M2 |
| Fatal-vs-advisory two-tier error model | rendering/store contract | M1/M2 |
| `"container"` sizing + `ResizeObserver` → `view.resize()` | `rendering.ts` + LivePreview | M2 |
| `finalize()`-before-reembed + **render-generation guard** | LivePreview | M1 |
| theme = `vega-themes` config merged into `vegaEmbed` | preview + settings | M5 |
| `json-stringify-pretty-compact` format action | editor | M2 |
| Technique | Lands in | Milestone |
| ----------------------------------------------------------------------------------------- | -------------------------------------- | --------- |
| Bundle VL schema from package `build/`; `setDiagnosticsOptions` | `src/app/infrastructure/` Monaco setup | M2 |
| `markdownDescription` patch + compact formatter | Monaco setup | M2 |
| Explicit Vite worker wiring (`MonacoEnvironment.getWorker`) | Monaco setup | M2 |
| `fileMatch` schema binding (improvement over `$schema`-only) | Monaco setup | M2 |
| jsonc-parser tolerant parse + line/col syntax errors | `src/core/` | M1/M2 |
| ajv wrapper (`strict:false`, draft-06, color-hex, compile-once) → structured diagnostics | `src/core/` | M2 |
| `LocalLogger`-style buffered diagnostics from pure compile | `src/core/` | M2 |
| Fatal-vs-advisory two-tier error model | rendering/store contract | M1/M2 |
| `"container"` sizing + `ResizeObserver` → synthetic `window:resize` (not `view.resize()`) | `chart-renderer` + LivePreview | M2 |
| `finalize()`-before-reembed + **render-generation guard** | LivePreview | M1 |
| theme = `vega-themes` config merged into `vegaEmbed` | preview + settings | M5 |
| `json-stringify-pretty-compact` format action | editor | M2 |
## Where we deliberately do better than the reference