mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Implement fit-mode rendering contract with container sizing and pane re-fit
This commit is contained in:
@@ -174,7 +174,7 @@ set to Plex Mono explicitly since it can't read the CSS token.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## M2 · Editor robustness
|
## M2 · Editor robustness ✅ (done)
|
||||||
|
|
||||||
**Goal:** the editor becomes trustworthy — draft vs published, schema-aware
|
**Goal:** the editor becomes trustworthy — draft vs published, schema-aware
|
||||||
assistance, and the fit-mode rendering contract.
|
assistance, and the fit-mode rendering contract.
|
||||||
@@ -185,7 +185,9 @@ assistance, and the fit-mode rendering contract.
|
|||||||
Vega-Lite `"container"`), recursing into layered/concat/child specs (spec §04
|
Vega-Lite `"container"`), recursing into layered/concat/child specs (spec §04
|
||||||
Rendering Contract, step 2).
|
Rendering Contract, step 2).
|
||||||
- `vega-lite-schema.ts` — provide the Vega-Lite JSON schema for Monaco's
|
- `vega-lite-schema.ts` — provide the Vega-Lite JSON schema for Monaco's
|
||||||
validation/autocomplete (mine vega-editor for sourcing/versioning the schema).
|
validation/autocomplete. _Delivered early in M1.5 as
|
||||||
|
`infrastructure/monaco-schema.ts` (bundled schema, offline, `markdownDescription`
|
||||||
|
hover docs); no further work needed in M2._
|
||||||
|
|
||||||
**App**
|
**App**
|
||||||
|
|
||||||
@@ -210,6 +212,30 @@ assistance, and the fit-mode rendering contract.
|
|||||||
- Invalid spec shows inline error; autocomplete suggests Vega-Lite properties.
|
- Invalid spec shows inline error; autocomplete suggests Vega-Lite properties.
|
||||||
- Each fit mode resizes the chart as specified; choice survives reload.
|
- Each fit mode resizes the chart as specified; choice survives reload.
|
||||||
|
|
||||||
|
**Verified:** `typecheck` + `test` (83 passing — `rendering` fit-mode incl.
|
||||||
|
nested layer/concat/facet specs, `SnippetStore` draft/publish/revert/editorView,
|
||||||
|
`settings-store` `previewFitMode` round-trip) + `build` (PWA, 41 precache
|
||||||
|
entries) + `eslint` clean. Implementation notes: editing now writes the
|
||||||
|
**draft** only (`commitDraft` no longer touches `spec`); `publish`/`revert` live
|
||||||
|
in `SnippetStore`, with a `bufferEpoch` counter so programmatic buffer reloads
|
||||||
|
(select/create/revert) refresh Monaco without fighting the cursor mid-typing.
|
||||||
|
The Draft/Published view is a store-level `editorView`; the published view is
|
||||||
|
read-only and the preview renders whichever version is shown (`selectShownText`).
|
||||||
|
The editor (§03E) and preview (§04) share one render error via a small
|
||||||
|
`PreviewStore`. `previewFitMode` was pulled into `AppStore` + the settings
|
||||||
|
adapter, hydrated/persisted by a new `orchestration/preferences.ts` mirroring the
|
||||||
|
theme slice. Publish/Revert **success toasts** stay deferred to M6 (TODO
|
||||||
|
breadcrumbs at the call sites), matching the existing delete-toast convention.
|
||||||
|
|
||||||
|
Fit-mode rendering needed a layout fix: vega-embed brands the embed host with its
|
||||||
|
own `.vega-embed { display: inline-block }` (injected at runtime, wins the
|
||||||
|
cascade), which shrink-wrapped the host so `width: "container"` collapsed (Height
|
||||||
|
survived only via the old `min-height: 100%`). Fix: embed into a static-class
|
||||||
|
inner host (React never reconciles its className, so Vega's runtime classes
|
||||||
|
survive) inside a React-owned frame that carries the fit-sizing class via
|
||||||
|
two-class selectors that out-specify `.vega-embed`. All four fit modes
|
||||||
|
user-verified in the running app.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## M3 · Datasets
|
## M3 · Datasets
|
||||||
@@ -329,8 +355,12 @@ the reference.
|
|||||||
|
|
||||||
**Goal:** the workspace feels finished and meets §10.
|
**Goal:** the workspace feels finished and meets §10.
|
||||||
|
|
||||||
- **Panes:** drag-resize handles with min widths; per-pane show/hide toggle strip;
|
- **Panes:** ~~drag-resize handles with min widths; widths persist~~ ✅ **pulled
|
||||||
widths + visibility persist (§01A, §09D).
|
forward after M2** (coupled to the preview's container sizing — see
|
||||||
|
[arch 05 §8](architecture/05-rendering-theming-preview.md)). Side panes carry
|
||||||
|
remembered widths, the editor flexes between them, widths persist to
|
||||||
|
`astrolabe:ux-prefs`. **Remaining:** per-pane show/hide **toggle strip** +
|
||||||
|
visibility persist + proportional redistribution on hide (§01A, §09D).
|
||||||
- **Routing:** URL hash view-state (`#snippet-<id>`, `#datasets/...`) with Back/Forward;
|
- **Routing:** URL hash view-state (`#snippet-<id>`, `#datasets/...`) with Back/Forward;
|
||||||
restore on load (§01E). _(see [Architecture 04 · Routing & Events](architecture/04-routing-and-events.md))_
|
restore on load (§01E). _(see [Architecture 04 · Routing & Events](architecture/04-routing-and-events.md))_
|
||||||
- **Shortcuts:** Cmd/Ctrl+Shift+N / +K / +S / +, / Esc via a single key router
|
- **Shortcuts:** Cmd/Ctrl+Shift+N / +K / +S / +, / Esc via a single key router
|
||||||
|
|||||||
@@ -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
|
> embeds that returned spec. **The user's stored spec is never mutated by
|
||||||
> rendering.**
|
> rendering.**
|
||||||
|
|
||||||
The container-relative fit modes (Width/Height/Full) depend on `renderer: 'svg'`
|
The container-relative fit modes (Width/Height/Full) depend on `"container"`
|
||||||
plus `"container"` sizing to follow the pane; when the pane resizes, re-running
|
sizing to follow the pane. Re-fitting on a **pane resize** is _not_ a re-embed:
|
||||||
`prepareSpecForRender` + re-embedding (a `flush()`) re-fits the chart.
|
the existing view is re-measured via a `ResizeObserver`-driven event — see §8.
|
||||||
|
|
||||||
### Rules
|
### Rules
|
||||||
|
|
||||||
@@ -463,10 +463,59 @@ 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
|
## Summary
|
||||||
|
|
||||||
| Concern | Mechanism | Source of truth |
|
| Concern | Mechanism | Source of truth |
|
||||||
| ------------- | ----------------------------------------------------------------------- | ----------------------------------------------- |
|
| ------------- | ------------------------------------------------------------------------------------ | ----------------------------------------------- |
|
||||||
| Embedding | One `renderSpec` over `vega-embed`, `actions: false`, `renderer: 'svg'` | `src/app/services/chart-renderer.ts` |
|
| 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` |
|
| 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` |
|
| Theming | Vega `Config` per UI theme, applied at embed time | `chartConfigFor()` in `src/core/vega-themes.ts` |
|
||||||
@@ -474,3 +523,4 @@ manual retry, no reload.
|
|||||||
| Debounce | `createDebouncedRenderer`, delay from `renderDebounce` setting | `src/app/services/debounced-renderer.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_) |
|
| 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` |
|
| 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`) |
|
||||||
|
|||||||
@@ -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`
|
(`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.
|
to `vegaEmbed`, and when our theme changes, re-embed with the new config.
|
||||||
- **`"width":"container"` / `"height":"container"` is how VL responsiveness works** — it
|
- **`"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
|
compiles `width`/`height` to signals that re-read `containerSize()` **only on a
|
||||||
**`ResizeObserver`** on the preview pane → `view.resize().runAsync()`. This is cleaner than
|
`window:resize` event** (`renderer.tsx:78-90` detects container sizing). Two things a
|
||||||
vega/editor's `window.dispatchEvent(new Event('resize'))` hack (`renderer.tsx:101-122`) and is
|
from-scratch impl _will_ get wrong (we did): (1) `view.resize().runAsync()` does **not**
|
||||||
the mechanism behind our M2 fit-mode contract.
|
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
|
- **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`).
|
(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`).
|
- **Capture warnings separately from errors** via a buffering logger (see §4's `LocalLogger`).
|
||||||
@@ -274,7 +278,7 @@ defaults-spread" discipline is worth keeping.
|
|||||||
## Borrow list (where each lands)
|
## Borrow list (where each lands)
|
||||||
|
|
||||||
| Technique | Lands in | Milestone |
|
| Technique | Lands in | Milestone |
|
||||||
| ---------------------------------------------------------------------------------------- | -------------------------------------- | --------- |
|
| ----------------------------------------------------------------------------------------- | -------------------------------------- | --------- |
|
||||||
| Bundle VL schema from package `build/`; `setDiagnosticsOptions` | `src/app/infrastructure/` Monaco setup | M2 |
|
| Bundle VL schema from package `build/`; `setDiagnosticsOptions` | `src/app/infrastructure/` Monaco setup | M2 |
|
||||||
| `markdownDescription` patch + compact formatter | Monaco setup | M2 |
|
| `markdownDescription` patch + compact formatter | Monaco setup | M2 |
|
||||||
| Explicit Vite worker wiring (`MonacoEnvironment.getWorker`) | Monaco setup | M2 |
|
| Explicit Vite worker wiring (`MonacoEnvironment.getWorker`) | Monaco setup | M2 |
|
||||||
@@ -283,7 +287,7 @@ defaults-spread" discipline is worth keeping.
|
|||||||
| ajv wrapper (`strict:false`, draft-06, color-hex, compile-once) → structured diagnostics | `src/core/` | 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 |
|
| `LocalLogger`-style buffered diagnostics from pure compile | `src/core/` | M2 |
|
||||||
| Fatal-vs-advisory two-tier error model | rendering/store contract | M1/M2 |
|
| Fatal-vs-advisory two-tier error model | rendering/store contract | M1/M2 |
|
||||||
| `"container"` sizing + `ResizeObserver` → `view.resize()` | `rendering.ts` + LivePreview | M2 |
|
| `"container"` sizing + `ResizeObserver` → synthetic `window:resize` (not `view.resize()`) | `chart-renderer` + LivePreview | M2 |
|
||||||
| `finalize()`-before-reembed + **render-generation guard** | LivePreview | M1 |
|
| `finalize()`-before-reembed + **render-generation guard** | LivePreview | M1 |
|
||||||
| theme = `vega-themes` config merged into `vegaEmbed` | preview + settings | M5 |
|
| theme = `vega-themes` config merged into `vegaEmbed` | preview + settings | M5 |
|
||||||
| `json-stringify-pretty-compact` format action | editor | M2 |
|
| `json-stringify-pretty-compact` format action | editor | M2 |
|
||||||
|
|||||||
@@ -1,16 +1,114 @@
|
|||||||
.preview {
|
.preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: auto;
|
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart {
|
.header {
|
||||||
|
flex: 0 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: flex-end;
|
||||||
min-height: 100%;
|
gap: var(--space-3);
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 var(--space-4);
|
||||||
|
border-bottom: var(--border-width) solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
padding: var(--space-5);
|
padding: var(--space-5);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Segmented control — the four fit modes (spec §04). */
|
||||||
|
.fit {
|
||||||
|
display: inline-flex;
|
||||||
|
border: var(--border-width) solid var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitOption {
|
||||||
|
appearance: none;
|
||||||
|
border: none;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background var(--dur-fast) var(--ease),
|
||||||
|
color var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitOption + .fitOption {
|
||||||
|
border-left: var(--border-width) solid var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitOption:hover {
|
||||||
|
background: var(--layer-01);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fitActive,
|
||||||
|
.fitActive:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--accent-contrast);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Chart sizing. The host (passed to vega-embed) is branded `.vega-embed`
|
||||||
|
* (display:inline-block) at runtime; that shrink-wraps it, which is why
|
||||||
|
* width:"container" collapsed before. The frame carries the fit class and the
|
||||||
|
* two-class selectors below out-specify `.vega-embed` to give the host a
|
||||||
|
* definite box for the responsive modes. `box-sizing:border-box` keeps the
|
||||||
|
* chart inside the body padding rather than overflowing it.
|
||||||
|
*/
|
||||||
|
.frame {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.host {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Original — natural size; the body scrolls if the chart is larger than the pane. */
|
||||||
|
.fitOriginal {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Width — host spans the pane width; height stays natural. */
|
||||||
|
.fitWidth {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.fitWidth .host {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Height — host spans the pane height; width stays natural. */
|
||||||
|
.fitHeight {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.fitHeight .host {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full — host fills the pane in both dimensions. */
|
||||||
|
.fitFull {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.fitFull .host {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
|
|||||||
@@ -1,40 +1,90 @@
|
|||||||
/**
|
/**
|
||||||
* Live Preview — the right pane (spec §04).
|
* Live Preview — the right pane (spec §04).
|
||||||
*
|
*
|
||||||
* Renders the active snippet's current buffer as a Vega-Lite chart, debounced so
|
* Renders the active snippet's currently-shown spec (draft or published, per the
|
||||||
* typing stays smooth. The pipeline is: buffer text → JSON.parse →
|
* editor view) as a Vega-Lite chart, debounced so typing stays smooth. The
|
||||||
* prepareSpecForRender (copy, pure) → renderSpec (vega-embed). A render-
|
* pipeline is: shown text → JSON.parse → prepareSpecForRender (copy, pure, fit
|
||||||
* generation token guards against a slow render resolving after a newer one.
|
* mode applied) → renderSpec (vega-embed). A render-generation token guards
|
||||||
|
* against a slow render resolving after a newer one.
|
||||||
*
|
*
|
||||||
* M1 scope: inline-data specs, Original sizing, basic error text. Fit modes (M2)
|
* The pane header carries the Fit control (4 sizing modes, §04). Render errors
|
||||||
* and dataset reference resolution (M3) plug into prepareSpecForRender without
|
* are published to the shared PreviewStore so the editor pane mirrors them
|
||||||
* changing this component.
|
* (§03E); the preview shows the same message in place of the chart.
|
||||||
|
*
|
||||||
|
* M2 scope: inline-data specs, all four fit modes. Dataset reference resolution
|
||||||
|
* (M3) plugs into prepareSpecForRender without changing this component.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import type { VisualizationSpec } from 'vega-embed';
|
import type { VisualizationSpec } from 'vega-embed';
|
||||||
|
import type { FitMode } from '@core/rendering';
|
||||||
import { prepareSpecForRender } from '@core/rendering';
|
import { prepareSpecForRender } from '@core/rendering';
|
||||||
import { chartConfigFor } from '@core/vega-themes';
|
import { chartConfigFor } from '@core/vega-themes';
|
||||||
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
|
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
|
||||||
import { useAppStore } from '../stores/AppStore';
|
import { useAppStore } from '../stores/AppStore';
|
||||||
import { useSnippetStore } from '../stores/SnippetStore';
|
import { usePreviewStore } from '../stores/PreviewStore';
|
||||||
|
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||||
import styles from './LivePreview.module.css';
|
import styles from './LivePreview.module.css';
|
||||||
|
|
||||||
/** Render debounce (ms). Becomes the configurable `renderDebounce` setting in M5. */
|
/** Render debounce (ms). Becomes the configurable `renderDebounce` setting in M5. */
|
||||||
const RENDER_DEBOUNCE_MS = 300;
|
const RENDER_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
/** The four fit modes in display order (spec §04 → Fit / Sizing Modes). */
|
||||||
|
const FIT_MODES: ReadonlyArray<{ mode: FitMode; label: string }> = [
|
||||||
|
{ mode: 'default', label: 'Original' },
|
||||||
|
{ mode: 'width', label: 'Width' },
|
||||||
|
{ mode: 'height', label: 'Height' },
|
||||||
|
{ mode: 'full', label: 'Full' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sizing class for the chart frame per fit mode. The frame is React-owned, so
|
||||||
|
* these classes drive how the host element (which vega-embed brands with its own
|
||||||
|
* `display:inline-block`) is sized: the responsive modes give the host a
|
||||||
|
* definite width/height for Vega's `"container"` measurement (`containerSize()`
|
||||||
|
* reads `host.clientWidth/Height`), while Original lets it shrink to natural size.
|
||||||
|
*/
|
||||||
|
const FIT_CLASS: Record<FitMode, string> = {
|
||||||
|
default: styles.fitOriginal,
|
||||||
|
width: styles.fitWidth,
|
||||||
|
height: styles.fitHeight,
|
||||||
|
full: styles.fitFull,
|
||||||
|
};
|
||||||
|
|
||||||
|
function FitControl() {
|
||||||
|
const fitMode = useAppStore((s) => s.previewFitMode);
|
||||||
|
const setFitMode = useAppStore((s) => s.setPreviewFitMode);
|
||||||
|
return (
|
||||||
|
<div className={styles.fit} role="group" aria-label="Fit chart to pane">
|
||||||
|
{FIT_MODES.map(({ mode, label }) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.fitOption} ${mode === fitMode ? styles.fitActive : ''}`}
|
||||||
|
aria-pressed={mode === fitMode}
|
||||||
|
onClick={() => setFitMode(mode)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function LivePreview() {
|
export function LivePreview() {
|
||||||
const hostRef = useRef<HTMLDivElement>(null);
|
const hostRef = useRef<HTMLDivElement>(null);
|
||||||
const handleRef = useRef<RenderHandle | null>(null);
|
const handleRef = useRef<RenderHandle | null>(null);
|
||||||
const generationRef = useRef(0);
|
const generationRef = useRef(0);
|
||||||
const draftText = useSnippetStore((s) => s.draftText);
|
const shownText = useSnippetStore(selectShownText);
|
||||||
|
const fitMode = useAppStore((s) => s.previewFitMode);
|
||||||
const uiTheme = useAppStore((s) => s.uiTheme);
|
const uiTheme = useAppStore((s) => s.uiTheme);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const error = usePreviewStore((s) => s.error);
|
||||||
|
const setError = usePreviewStore((s) => s.setError);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const node = hostRef.current;
|
const node = hostRef.current;
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
const text = draftText.trim();
|
const text = shownText.trim();
|
||||||
|
|
||||||
// The debounced body is async; wrap in a void IIFE so the timer callback
|
// The debounced body is async; wrap in a void IIFE so the timer callback
|
||||||
// returns void (it handles its own errors internally — nothing awaits it).
|
// returns void (it handles its own errors internally — nothing awaits it).
|
||||||
@@ -58,7 +108,7 @@ export function LivePreview() {
|
|||||||
|
|
||||||
const mine = ++generationRef.current;
|
const mine = ++generationRef.current;
|
||||||
try {
|
try {
|
||||||
const prepared = prepareSpecForRender(parsed, { fitMode: 'default' });
|
const prepared = prepareSpecForRender(parsed, { fitMode });
|
||||||
const config = chartConfigFor(uiTheme);
|
const config = chartConfigFor(uiTheme);
|
||||||
handleRef.current?.destroy();
|
handleRef.current?.destroy();
|
||||||
handleRef.current = null;
|
handleRef.current = null;
|
||||||
@@ -67,9 +117,9 @@ export function LivePreview() {
|
|||||||
// TODO: a superseded render's destroy() calls node.replaceChildren(),
|
// TODO: a superseded render's destroy() calls node.replaceChildren(),
|
||||||
// which can blank the live chart if two embeds on the same node are
|
// which can blank the live chart if two embeds on the same node are
|
||||||
// ever in flight at once (heavy spec whose embed outlasts the 300ms
|
// ever in flight at once (heavy spec whose embed outlasts the 300ms
|
||||||
// debounce). The debounce makes this rare in M1; when fit-mode/dataset
|
// debounce). The debounce makes this rare; when dataset work (M3)
|
||||||
// work (M2/M3) lands, serialize renders or finalize the stale view
|
// lands, serialize renders or finalize the stale view without
|
||||||
// without clearing the shared node.
|
// clearing the shared node.
|
||||||
handle.destroy(); // a newer render superseded this one
|
handle.destroy(); // a newer render superseded this one
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -87,21 +137,50 @@ export function LivePreview() {
|
|||||||
}, RENDER_DEBOUNCE_MS);
|
}, RENDER_DEBOUNCE_MS);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [draftText, uiTheme]);
|
}, [shownText, fitMode, uiTheme, setError]);
|
||||||
|
|
||||||
// Finalize the live view on unmount.
|
// Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
|
||||||
|
// observe the element, so we do: one observer on the stable host node for the
|
||||||
|
// component's life. Only responsive fit modes depend on container size;
|
||||||
|
// Original is fixed natural size and the pane just scrolls. ResizeObserver
|
||||||
|
// callbacks are frame-batched, so this tracks the drag without thrashing.
|
||||||
|
useEffect(() => {
|
||||||
|
const node = hostRef.current;
|
||||||
|
if (!node || typeof ResizeObserver === 'undefined') return;
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
if (useAppStore.getState().previewFitMode === 'default') return;
|
||||||
|
handleRef.current?.resize();
|
||||||
|
});
|
||||||
|
ro.observe(node);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Finalize the live view on unmount, and clear the shared error so a stale
|
||||||
|
// message never outlives this pane.
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
handleRef.current?.destroy();
|
handleRef.current?.destroy();
|
||||||
handleRef.current = null;
|
handleRef.current = null;
|
||||||
|
usePreviewStore.getState().setError(null);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.preview}>
|
<div className={styles.preview}>
|
||||||
<div className={styles.chart} ref={hostRef} hidden={error !== null} />
|
<div className={styles.header}>
|
||||||
|
<FitControl />
|
||||||
|
</div>
|
||||||
|
<div className={styles.body}>
|
||||||
|
{/* Frame is React-owned and carries the fit-sizing class; the inner host
|
||||||
|
is owned by vega-embed (it brands it `.vega-embed` and mutates its
|
||||||
|
classList at runtime), so its className stays static and React never
|
||||||
|
clobbers Vega's own classes. */}
|
||||||
|
<div className={`${styles.frame} ${FIT_CLASS[fitMode]}`} hidden={error !== null}>
|
||||||
|
<div className={styles.host} ref={hostRef} />
|
||||||
|
</div>
|
||||||
{error !== null && <pre className={styles.error}>{error}</pre>}
|
{error !== null && <pre className={styles.error}>{error}</pre>}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { loadUiTheme, saveUiTheme } from './settings-store';
|
import { loadPreviewFitMode, loadUiTheme, savePreviewFitMode, saveUiTheme } from './settings-store';
|
||||||
|
|
||||||
const KEY = 'astrolabe:settings';
|
const KEY = 'astrolabe:settings';
|
||||||
|
|
||||||
@@ -84,3 +84,29 @@ describe('settings-store · ui.theme', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('settings-store · preview.fitMode', () => {
|
||||||
|
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('defaults to Original (default) when nothing is stored', () => {
|
||||||
|
expect(loadPreviewFitMode()).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a stored valid fit mode', () => {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify({ preview: { fitMode: 'full' } }));
|
||||||
|
expect(loadPreviewFitMode()).toBe('full');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to default for an unrecognized value', () => {
|
||||||
|
localStorage.setItem(KEY, JSON.stringify({ preview: { fitMode: 'cover' } }));
|
||||||
|
expect(loadPreviewFitMode()).toBe('default');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('round-trips through load and preserves the theme slice', () => {
|
||||||
|
saveUiTheme('dark');
|
||||||
|
savePreviewFitMode('height');
|
||||||
|
expect(loadPreviewFitMode()).toBe('height');
|
||||||
|
expect(loadUiTheme()).toBe('dark'); // the other slice survives the merge
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
* `localStorage`; everything else goes through these typed functions.
|
* `localStorage`; everything else goes through these typed functions.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { FitMode } from '@core/rendering';
|
||||||
import type { UiTheme } from '@core/theme';
|
import type { UiTheme } from '@core/theme';
|
||||||
|
|
||||||
const KEY = 'astrolabe:settings';
|
const KEY = 'astrolabe:settings';
|
||||||
@@ -20,9 +21,13 @@ const KEY = 'astrolabe:settings';
|
|||||||
/** Spec §07 Appearance default. */
|
/** Spec §07 Appearance default. */
|
||||||
const DEFAULT_THEME: UiTheme = 'light';
|
const DEFAULT_THEME: UiTheme = 'light';
|
||||||
|
|
||||||
|
/** Spec §04 — the Fit control defaults to Original. */
|
||||||
|
const DEFAULT_FIT_MODE: FitMode = 'default';
|
||||||
|
|
||||||
/** Loose view of the stored record — M5 will give this its full typed shape. */
|
/** Loose view of the stored record — M5 will give this its full typed shape. */
|
||||||
interface StoredSettings {
|
interface StoredSettings {
|
||||||
ui?: { theme?: unknown; [k: string]: unknown };
|
ui?: { theme?: unknown; [k: string]: unknown };
|
||||||
|
preview?: { fitMode?: unknown; [k: string]: unknown };
|
||||||
[k: string]: unknown;
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +66,10 @@ function isUiTheme(v: unknown): v is UiTheme {
|
|||||||
return v === 'light' || v === 'dark';
|
return v === 'light' || v === 'dark';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isFitMode(v: unknown): v is FitMode {
|
||||||
|
return v === 'default' || v === 'width' || v === 'height' || v === 'full';
|
||||||
|
}
|
||||||
|
|
||||||
/** The persisted UI theme, or the default — unknown/legacy values fall back. */
|
/** The persisted UI theme, or the default — unknown/legacy values fall back. */
|
||||||
export function loadUiTheme(): UiTheme {
|
export function loadUiTheme(): UiTheme {
|
||||||
const stored = readRaw().ui?.theme;
|
const stored = readRaw().ui?.theme;
|
||||||
@@ -74,3 +83,15 @@ export function saveUiTheme(theme: UiTheme): void {
|
|||||||
const current = readRaw();
|
const current = readRaw();
|
||||||
writeRaw({ ...current, ui: { ...current.ui, theme } });
|
writeRaw({ ...current, ui: { ...current.ui, theme } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The persisted preview fit mode, or the default — unknown values fall back. */
|
||||||
|
export function loadPreviewFitMode(): FitMode {
|
||||||
|
const stored = readRaw().preview?.fitMode;
|
||||||
|
return isFitMode(stored) ? stored : DEFAULT_FIT_MODE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the preview fit mode, preserving every other key already in the record. */
|
||||||
|
export function savePreviewFitMode(fitMode: FitMode): void {
|
||||||
|
const current = readRaw();
|
||||||
|
writeRaw({ ...current, preview: { ...current.preview, fitMode } });
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* Preference orchestration — bridges the (browser-free) AppStore to the settings
|
||||||
|
* adapter for the small UI preferences pulled forward ahead of the M5 Settings
|
||||||
|
* modal. Same store↔adapter pattern as theme orchestration; currently the only
|
||||||
|
* such preference is the Live Preview fit mode (spec §04, `previewFitMode`).
|
||||||
|
*
|
||||||
|
* Unlike theme there is no FOUC concern (the preview renders after hydration
|
||||||
|
* anyway), but hydrating early keeps the store the single source of truth from
|
||||||
|
* the first render.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { loadPreviewFitMode, savePreviewFitMode } from '../infrastructure/settings-store';
|
||||||
|
import { useAppStore } from '../stores/AppStore';
|
||||||
|
|
||||||
|
/** Hydrate the persisted fit mode into the store. Call before render. */
|
||||||
|
export function initPreviewFitMode(): void {
|
||||||
|
useAppStore.getState().setPreviewFitMode(loadPreviewFitMode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist the fit mode on change. Returns a teardown that detaches the subscriber. */
|
||||||
|
export function wirePreviewFitMode(): () => void {
|
||||||
|
return useAppStore.subscribe((state, prev) => {
|
||||||
|
if (state.previewFitMode === prev.previewFitMode) return;
|
||||||
|
savePreviewFitMode(state.previewFitMode);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,6 +14,17 @@ import type { Config } from 'vega-lite';
|
|||||||
export interface RenderHandle {
|
export interface RenderHandle {
|
||||||
/** Finalize the underlying Vega view and clear the node. */
|
/** Finalize the underlying Vega view and clear the node. */
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
|
/**
|
||||||
|
* Re-fit the chart to its container's current size (spec §04 Responsiveness).
|
||||||
|
*
|
||||||
|
* Vega-Lite compiles `"container"` sizing to width/height signals that re-read
|
||||||
|
* `containerSize()` ONLY on a `window:resize` event (nothing observes the
|
||||||
|
* element, and `view.resize()` alone re-runs layout with the stale size). So a
|
||||||
|
* pane drag — which fires no window resize — needs us to synthesize that event.
|
||||||
|
* Doing it this way also means only the container-bound dimensions re-fit
|
||||||
|
* (fixed ones have no such handler), which is exactly right for Width/Height.
|
||||||
|
*/
|
||||||
|
resize(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Embed a prepared spec into `node`. Non-negotiable: no actions menu, SVG output. */
|
/** Embed a prepared spec into `node`. Non-negotiable: no actions menu, SVG output. */
|
||||||
@@ -33,5 +44,12 @@ export async function renderSpec(
|
|||||||
result.view.finalize();
|
result.view.finalize();
|
||||||
node.replaceChildren();
|
node.replaceChildren();
|
||||||
},
|
},
|
||||||
|
resize() {
|
||||||
|
// Synthesize the window:resize the container signals listen for (see the
|
||||||
|
// interface doc). The view re-reads containerSize() and re-renders itself;
|
||||||
|
// a finalized view has already removed its listener, so this is a safe
|
||||||
|
// no-op after destroy().
|
||||||
|
if (typeof window !== 'undefined') window.dispatchEvent(new Event('resize'));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import type { FitMode } from '@core/rendering';
|
||||||
import type { UiTheme } from '@core/theme';
|
import type { UiTheme } from '@core/theme';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,12 +20,16 @@ export type ModalName = 'datasets' | 'settings' | 'about' | 'donate' | 'chartBui
|
|||||||
export interface AppState {
|
export interface AppState {
|
||||||
/** Active UI theme; mirrored onto <html data-theme> by a subscriber. */
|
/** Active UI theme; mirrored onto <html data-theme> by a subscriber. */
|
||||||
uiTheme: UiTheme;
|
uiTheme: UiTheme;
|
||||||
|
/** Preview sizing mode (spec §04); persisted to Settings as `previewFitMode`. */
|
||||||
|
previewFitMode: FitMode;
|
||||||
/** The currently open modal, or null. */
|
/** The currently open modal, or null. */
|
||||||
activeModal: ModalName | null;
|
activeModal: ModalName | null;
|
||||||
|
|
||||||
setTheme: (theme: UiTheme) => void;
|
setTheme: (theme: UiTheme) => void;
|
||||||
/** Flip between light and dark — the header ThemeToggle's action. */
|
/** Flip between light and dark — the header ThemeToggle's action. */
|
||||||
toggleTheme: () => void;
|
toggleTheme: () => void;
|
||||||
|
/** Set the preview fit mode — the Live Preview Fit control's action. */
|
||||||
|
setPreviewFitMode: (mode: FitMode) => void;
|
||||||
/**
|
/**
|
||||||
* Low-level modal setter — the single primitive that mutates `activeModal`.
|
* Low-level modal setter — the single primitive that mutates `activeModal`.
|
||||||
* High-level open/close (snapshot for unsaved-change detection, URL sync,
|
* High-level open/close (snapshot for unsaved-change detection, URL sync,
|
||||||
@@ -36,9 +41,11 @@ export interface AppState {
|
|||||||
|
|
||||||
export const useAppStore = create<AppState>((set) => ({
|
export const useAppStore = create<AppState>((set) => ({
|
||||||
uiTheme: 'light',
|
uiTheme: 'light',
|
||||||
|
previewFitMode: 'default',
|
||||||
activeModal: null,
|
activeModal: null,
|
||||||
|
|
||||||
setTheme: (uiTheme) => set({ uiTheme }),
|
setTheme: (uiTheme) => set({ uiTheme }),
|
||||||
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
|
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
|
||||||
|
setPreviewFitMode: (previewFitMode) => set({ previewFitMode }),
|
||||||
setActiveModal: (activeModal) => set({ activeModal }),
|
setActiveModal: (activeModal) => set({ activeModal }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -14,19 +14,84 @@ describe('prepareSpecForRender', () => {
|
|||||||
const spec = {
|
const spec = {
|
||||||
data: { values: [{ a: 1 }] },
|
data: { values: [{ a: 1 }] },
|
||||||
mark: 'bar',
|
mark: 'bar',
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
encoding: { x: { field: 'a', type: 'quantitative' } },
|
encoding: { x: { field: 'a', type: 'quantitative' } },
|
||||||
};
|
};
|
||||||
const before = structuredClone(spec);
|
const before = structuredClone(spec);
|
||||||
const out = prepareSpecForRender(spec, { fitMode: 'width' });
|
// A mode that both sets and removes sizing — the most invasive transform.
|
||||||
|
prepareSpecForRender(spec, { fitMode: 'width' });
|
||||||
// Mutating the output must not touch the input.
|
|
||||||
(out as { mark: string }).mark = 'point';
|
|
||||||
expect(spec).toEqual(before);
|
expect(spec).toEqual(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('M1 is a faithful pass-through of the spec content', () => {
|
test('Original (default) leaves sizing untouched', () => {
|
||||||
const spec = { $schema: 'x', mark: 'line', width: 200, height: 100 };
|
const spec = { $schema: 'x', mark: 'line', width: 200, height: 100 };
|
||||||
expect(prepareSpecForRender(spec)).toEqual(spec);
|
expect(prepareSpecForRender(spec)).toEqual(spec);
|
||||||
|
expect(prepareSpecForRender(spec, { fitMode: 'default' })).toEqual(spec);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('prepareSpecForRender — fit modes (spec §04 Rendering Contract step 2)', () => {
|
||||||
|
const base = { mark: 'bar', width: 200, height: 100 };
|
||||||
|
|
||||||
|
test('Width: width→container, height removed', () => {
|
||||||
|
const out = prepareSpecForRender(base, { fitMode: 'width' }) as Record<string, unknown>;
|
||||||
|
expect(out.width).toBe('container');
|
||||||
|
expect('height' in out).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Height: height→container, width removed', () => {
|
||||||
|
const out = prepareSpecForRender(base, { fitMode: 'height' }) as Record<string, unknown>;
|
||||||
|
expect(out.height).toBe('container');
|
||||||
|
expect('width' in out).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Full: both dimensions→container', () => {
|
||||||
|
const out = prepareSpecForRender(base, { fitMode: 'full' }) as Record<string, unknown>;
|
||||||
|
expect(out.width).toBe('container');
|
||||||
|
expect(out.height).toBe('container');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds container sizing even when the spec declares no width/height', () => {
|
||||||
|
const out = prepareSpecForRender({ mark: 'point' }, { fitMode: 'full' }) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
expect(out).toEqual({ mark: 'point', width: 'container', height: 'container' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recurses into layered sub-specs', () => {
|
||||||
|
const spec = {
|
||||||
|
layer: [
|
||||||
|
{ mark: 'bar', width: 50, height: 50 },
|
||||||
|
{ mark: 'line', height: 50 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const out = prepareSpecForRender(spec, { fitMode: 'full' }) as unknown as {
|
||||||
|
width: string;
|
||||||
|
height: string;
|
||||||
|
layer: Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
expect(out.width).toBe('container');
|
||||||
|
expect(out.height).toBe('container');
|
||||||
|
expect(out.layer[0]).toMatchObject({ width: 'container', height: 'container' });
|
||||||
|
expect(out.layer[1]).toMatchObject({ width: 'container', height: 'container' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recurses into concat arrays and a child spec (facet/repeat)', () => {
|
||||||
|
const spec = {
|
||||||
|
facet: { field: 'g', type: 'nominal' },
|
||||||
|
spec: {
|
||||||
|
hconcat: [{ mark: 'bar', height: 80 }, { mark: 'point' }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const out = prepareSpecForRender(spec, { fitMode: 'width' }) as unknown as {
|
||||||
|
spec: { hconcat: Array<Record<string, unknown>> };
|
||||||
|
};
|
||||||
|
for (const child of out.spec.hconcat) {
|
||||||
|
expect(child.width).toBe('container');
|
||||||
|
expect('height' in child).toBe(false);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+57
-8
@@ -8,21 +8,69 @@
|
|||||||
* never mutated by rendering:
|
* never mutated by rendering:
|
||||||
*
|
*
|
||||||
* 1. Dataset reference resolution — arrives in M3 (no-op here).
|
* 1. Dataset reference resolution — arrives in M3 (no-op here).
|
||||||
* 2. Fit-mode sizing — arrives in M2 (no-op here).
|
* 2. Fit-mode sizing — implemented in M2.
|
||||||
*
|
*
|
||||||
* In M1 it is an identity transform over a copy: it establishes the
|
* The copy-not-mutate invariant and the call site the renderer depends on are
|
||||||
* copy-not-mutate invariant and the call site the renderer depends on, so M2/M3
|
* fixed; M3 fills in step 1 without the preview pipeline changing shape.
|
||||||
* can fill in the steps without the preview pipeline changing shape.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Preview sizing modes (spec §04 → Fit / Sizing Modes). `default` = Original. */
|
/** Preview sizing modes (spec §04 → Fit / Sizing Modes). `default` = Original. */
|
||||||
export type FitMode = 'default' | 'width' | 'height' | 'full';
|
export type FitMode = 'default' | 'width' | 'height' | 'full';
|
||||||
|
|
||||||
export interface PrepareOptions {
|
export interface PrepareOptions {
|
||||||
/** Active fit mode. Applied in M2; ignored in M1. */
|
/** Active fit mode. Defaults to `'default'` (Original — spec sizing untouched). */
|
||||||
fitMode?: FitMode;
|
fitMode?: FitMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The container/sub-spec keys the rendering contract recurses into (spec §04). */
|
||||||
|
const CHILD_ARRAY_KEYS = ['layer', 'concat', 'hconcat', 'vconcat'] as const;
|
||||||
|
|
||||||
|
/** A spec node we might rewrite sizing on; loose by design (any Vega-Lite spec). */
|
||||||
|
type SpecNode = Record<string, unknown>;
|
||||||
|
|
||||||
|
function isSpecNode(value: unknown): value is SpecNode {
|
||||||
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewrite one node's sizing to the fit mode (spec §04 → Rendering Contract,
|
||||||
|
* step 2). `'container'` is Vega-Lite's responsive keyword; the unconstrained
|
||||||
|
* dimension is removed so it recomputes naturally.
|
||||||
|
*/
|
||||||
|
function applyFitToNode(node: SpecNode, mode: FitMode): void {
|
||||||
|
switch (mode) {
|
||||||
|
case 'width':
|
||||||
|
node.width = 'container';
|
||||||
|
delete node.height;
|
||||||
|
break;
|
||||||
|
case 'height':
|
||||||
|
node.height = 'container';
|
||||||
|
delete node.width;
|
||||||
|
break;
|
||||||
|
case 'full':
|
||||||
|
node.width = 'container';
|
||||||
|
node.height = 'container';
|
||||||
|
break;
|
||||||
|
// 'default' (Original) leaves sizing untouched and never reaches here.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the fit mode to a spec and every nested sub-spec it recurses into —
|
||||||
|
* layered (`layer`) and concatenated (`concat`/`hconcat`/`vconcat`) children,
|
||||||
|
* and a parent spec's single child `spec` (facet/repeat). Mutates in place; the
|
||||||
|
* caller (`prepareSpecForRender`) already works on a copy.
|
||||||
|
*/
|
||||||
|
function applyFitMode(node: unknown, mode: FitMode): void {
|
||||||
|
if (!isSpecNode(node)) return;
|
||||||
|
applyFitToNode(node, mode);
|
||||||
|
for (const key of CHILD_ARRAY_KEYS) {
|
||||||
|
const children = node[key];
|
||||||
|
if (Array.isArray(children)) for (const child of children) applyFitMode(child, mode);
|
||||||
|
}
|
||||||
|
if (isSpecNode(node.spec)) applyFitMode(node.spec, mode);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Escape `.`/`[`/`]` so Vega-Lite treats a string as a literal field name rather
|
* Escape `.`/`[`/`]` so Vega-Lite treats a string as a literal field name rather
|
||||||
* than a nested-property accessor (docs/architecture/05 §4). Used wherever
|
* than a nested-property accessor (docs/architecture/05 §4). Used wherever
|
||||||
@@ -37,11 +85,12 @@ export function escapeVegaField(name: string): string {
|
|||||||
* Transform the authored spec into the spec to embed. Operates on a deep copy
|
* Transform the authored spec into the spec to embed. Operates on a deep copy
|
||||||
* and returns it; the input is never mutated.
|
* and returns it; the input is never mutated.
|
||||||
*/
|
*/
|
||||||
export function prepareSpecForRender<T>(spec: T, _options: PrepareOptions = {}): T {
|
export function prepareSpecForRender<T>(spec: T, options: PrepareOptions = {}): T {
|
||||||
const copy = structuredClone(spec);
|
const copy = structuredClone(spec);
|
||||||
|
|
||||||
// M3: resolveDatasetRefs(copy, datasets)
|
// 1. M3: resolveDatasetRefs(copy, datasets)
|
||||||
// M2: applyFitMode(copy, options.fitMode)
|
// 2. Fit-mode sizing.
|
||||||
|
applyFitMode(copy, options.fitMode ?? 'default');
|
||||||
|
|
||||||
return copy;
|
return copy;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { App } from './app/App';
|
import { App } from './app/App';
|
||||||
|
import { initPreviewFitMode, wirePreviewFitMode } from './app/orchestration/preferences';
|
||||||
import { initApp } from './app/orchestration/startup';
|
import { initApp } from './app/orchestration/startup';
|
||||||
import { initTheme, wireTheme } from './app/orchestration/theme';
|
import { initTheme, wireTheme } from './app/orchestration/theme';
|
||||||
import '../styles/base.css';
|
import '../styles/base.css';
|
||||||
@@ -10,6 +11,11 @@ import '../styles/base.css';
|
|||||||
initTheme();
|
initTheme();
|
||||||
wireTheme();
|
wireTheme();
|
||||||
|
|
||||||
|
// Hydrate + persist the preview fit mode the same way (pulled ahead of the M5
|
||||||
|
// Settings modal); hydrating before render keeps the store authoritative.
|
||||||
|
initPreviewFitMode();
|
||||||
|
wirePreviewFitMode();
|
||||||
|
|
||||||
// Load the library from IndexedDB (seeding a sample on first run) and wire
|
// Load the library from IndexedDB (seeding a sample on first run) and wire
|
||||||
// persistence. Fire-and-forget: the UI renders immediately and fills in when
|
// persistence. Fire-and-forget: the UI renders immediately and fills in when
|
||||||
// hydration resolves.
|
// hydration resolves.
|
||||||
|
|||||||
Reference in New Issue
Block a user