From 8aa05e503df5dab0e87c175ed519b02a6f621546 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Mon, 29 Jun 2026 09:58:32 +0300 Subject: [PATCH] Inspector: live data updates under interactive selections --- docs/IMPLEMENTATION-PLAN.md | 8 +- .../05-rendering-theming-preview.md | 6 + .../multi-view-data-model-scope.md | 43 ++++--- src/app/components/ChartBuilderModal.test.tsx | 7 +- src/app/components/ColorControls.test.tsx | 1 + src/app/components/DataInspector.tsx | 7 +- src/app/components/LivePreview.test.tsx | 1 + src/app/components/LivePreview.tsx | 22 +++- src/app/components/Onboarding.test.tsx | 8 +- src/app/components/ThemeBuilderModal.test.tsx | 1 + .../components/ThemeControlPanels.test.tsx | 1 + src/app/services/chart-renderer.ts | 67 ++++++++++ src/app/services/chart-renderer.watch.test.ts | 118 ++++++++++++++++++ 13 files changed, 269 insertions(+), 21 deletions(-) create mode 100644 src/app/services/chart-renderer.watch.test.ts diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md index 8ec899b..7f47e71 100644 --- a/docs/IMPLEMENTATION-PLAN.md +++ b/docs/IMPLEMENTATION-PLAN.md @@ -75,9 +75,11 @@ This is the at-a-glance list; keep it in sync with them. **Next (flagged for build):** - **Multi-view data model** ([`multi-view-data-model-scope.md`](exploration/multi-view-data-model-scope.md)) — - durable composition support across the data-facing features. Done: the Vega-Lite-fidelity - reference classifier (`core/spec-data`) and the view-scoped editor data context. Remaining: - per-view data inspection (`DataInspector` view selector), then view-scoped Extract. + durable composition support across the data-facing features. **Complete** (M1–M5): the + Vega-Lite-fidelity reference classifier (`core/spec-data`), the view-scoped editor data + context, per-view data inspection, view-scoped Extract (inline + self-defined `datasets`), + and live/interactive inspection. The durable contract is recorded in `docs/architecture` + 05 (live inspection) and 07 (reference detection + extraction, §3.1–3.2). - **Chart Builder · 3B starter examples** ([`chart-builder-enhancement-scope.md`](exploration/chart-builder-enhancement-scope.md) §3) — a small set of curated starters, one per covered FT intent. Reshaped by 3C: a builder-openable starter must reference a dataset, so it ships paired sample datasets (or is diff --git a/docs/architecture/05-rendering-theming-preview.md b/docs/architecture/05-rendering-theming-preview.md index b83eff8..4024617 100644 --- a/docs/architecture/05-rendering-theming-preview.md +++ b/docs/architecture/05-rendering-theming-preview.md @@ -61,6 +61,12 @@ when no chart is up), wrapping the view exactly like `toImageURL`. It works in t a collapsed inspector costs nothing, which is why the panel reads on demand rather than on every render. (A multi-view spec yields several tables; the panel's `SelectControl` picker chooses which to show — labels never expose Vega's compiler names, see arch 10.) +- **Stay live under interaction.** A selection that _filters_ a downstream view recomputes + that view's compiled table in place (no re-embed), so the open panel re-reads to track it — + "what am I visualizing now". `RenderHandle.onDataChange` attaches a debounced + `view.addDataListener` to each drawn table; a highlight selection (a `condition` encoding) + changes no data, so it never fires. Always live, no toggle — gated on the panel being open + like the read itself, and re-subscribed per settled render so it tracks the current handle. --- diff --git a/docs/exploration/multi-view-data-model-scope.md b/docs/exploration/multi-view-data-model-scope.md index c61464c..399b47a 100644 --- a/docs/exploration/multi-view-data-model-scope.md +++ b/docs/exploration/multi-view-data-model-scope.md @@ -82,19 +82,34 @@ collection (`spec-fields`), config baking (`spec-config`), standalone export `columns · rows` cue. Enumerating by drawn table (not authored view) is forced by Vega-Lite desugaring (a `point: true` line compiles to two layers). Replaced the single-pair `core/result-data`. -- **M5 — live / interactive inspection** — make the inspector react to interactive - selections. A selection-as-**filter** (`filter: {param}`) recomputes a downstream - view's `data_N` live, so the inspector should re-read on selection change to show - the brushed result ("what am I visualizing _now_"); a selection-as-**highlight** - (a `condition` encoding) changes no data, so nothing to react to. Needs a refresh - model beyond the per-render `renderEpoch`: subscribe to the live view - (`view.addDataListener` / selection signals), debounced (a brush drag pulses - continuously — latency/interaction `/council` pass), and a default-on-vs-toggle - choice. Selection `*_store` tables are not drawn, so the M4 enumeration already - ignores them. -- **M3 — view-scoped extract** — seed Extract from the focused view's inline data - (reusing the cursor-scope machinery) and rewrite that view's `data`. +- **M5 — live / interactive inspection** ✅ — the inspector reacts to interactive + selections. `RenderHandle.onDataChange` attaches a debounced `view.addDataListener` + to each drawn table's resolved + input names; a selection-as-**filter** + (`filter: {param}`) recomputes a downstream view's `data_N`, so its listener fires + and `LivePreview` bumps a `liveEpoch` that re-reads the table ("what am I + visualizing _now_"); a selection-as-**highlight** (a `condition` encoding) changes + no data, so nothing fires. The watcher is gated on the inspector being open + (a collapsed one costs nothing) and is **always live, no toggle** — the table just + tracks the brush; the ~120ms debounce coalesces a drag's continuous pulses. + Selection `*_store` tables are not drawn, so the M4 enumeration already ignores them. +- **M3 — view-scoped extract** ✅ — Extract is scoped to the view at the cursor. + `services/extract-action` resolves the focused view's data binding + (`dataBindingAtPath`) and lifts whichever of two embedded-data shapes it carries: + a view's inline **`data.values`** (`inlineValuesOf` → rewrite that view's `data` + block at its anchor path), or a **`{ name }` reference to a self-defined + `datasets` entry** (`selfDefinedPayloadOf` → `promoteSelfDefinedDataset`: drop the + `datasets` entry, and the map when it empties, so the same reference resolves to + the new library dataset; rename refs when the name changes, pre-filled with the + existing name). The toolbar offers Extract whenever any view carries either shape + (`specHasExtractableData`); a cursor in a view with neither (a library ref, url, + generator) gets a guide toast. A single-view spec resolves to the root binding + from any cursor, so the common case is unchanged. Confirm re-serializes in the + app's house style. A `lookup` transform's inline `from.data` is covered incidentally + — `dataBindingAtPath` finds it like any view binding (which also means the editor + _hints_ read the lookup table's columns when the cursor sits inside the transform; + acceptable for now, noted). The orphan case — a `datasets` entry no view references + — is out of scope (no view to scope the cursor to; it is dead data to delete). Delivery is incremental, one milestone per commit, verified against real behavior. -The consolidated data-model contract write-up into `docs/architecture` (05/08) -lands once the shape is final. +The durable contract is recorded in `docs/architecture` 05 (live inspection) and 07 +(reference detection + extraction, §3.1–3.2); this memo stays the point-in-time record. diff --git a/src/app/components/ChartBuilderModal.test.tsx b/src/app/components/ChartBuilderModal.test.tsx index e28593e..a39f318 100644 --- a/src/app/components/ChartBuilderModal.test.tsx +++ b/src/app/components/ChartBuilderModal.test.tsx @@ -25,7 +25,12 @@ vi.mock('../services/chart-renderer', () => { } return { renderSpec: vi.fn(() => - Promise.resolve({ destroy() {}, resize() {}, inspectData: () => null }), + Promise.resolve({ + destroy() {}, + resize() {}, + inspectData: () => null, + onDataChange: () => () => {}, + }), ), ChartTooLargeError, }; diff --git a/src/app/components/ColorControls.test.tsx b/src/app/components/ColorControls.test.tsx index 0f4346c..2e78b93 100644 --- a/src/app/components/ColorControls.test.tsx +++ b/src/app/components/ColorControls.test.tsx @@ -21,6 +21,7 @@ vi.mock('../services/chart-renderer', () => ({ resize() {}, toImageURL: () => Promise.resolve(''), inspectData: () => null, + onDataChange: () => () => {}, }), ), })); diff --git a/src/app/components/DataInspector.tsx b/src/app/components/DataInspector.tsx index 2f20b16..bd7306f 100644 --- a/src/app/components/DataInspector.tsx +++ b/src/app/components/DataInspector.tsx @@ -73,7 +73,12 @@ interface DataInspectorPanelProps { * `renderEpoch`). */ getData: () => InspectedData | null; - /** Bumps whenever a render settles, so the open table re-reads the new rows. */ + /** + * Refresh trigger: bumps whenever the data to show may have changed, so the open + * table re-reads. The live-preview pane bumps it on each settled render *and* on + * an interactive selection that changes the inspected rows (live mode, M5); the + * builder bumps it on render only. + */ renderEpoch: number; /** * Explicit panel height (px) — the live-preview pane sets this from its diff --git a/src/app/components/LivePreview.test.tsx b/src/app/components/LivePreview.test.tsx index be75603..f8b0b72 100644 --- a/src/app/components/LivePreview.test.tsx +++ b/src/app/components/LivePreview.test.tsx @@ -45,6 +45,7 @@ vi.mock('../services/chart-renderer', () => ({ }, resize() {}, inspectData: () => null, + onDataChange: () => () => {}, }); }); }); diff --git a/src/app/components/LivePreview.tsx b/src/app/components/LivePreview.tsx index 1379f8b..568c5e4 100644 --- a/src/app/components/LivePreview.tsx +++ b/src/app/components/LivePreview.tsx @@ -211,6 +211,13 @@ export function LivePreview() { // not `chartReady` — consecutive successful renders keep `chartReady` true, but // each one is new data the inspector must pick up. const [renderEpoch, setRenderEpoch] = useState(0); + // Bumped (debounced, inside the handle) when an interactive selection changes + // the inspected data without a re-render — the live data inspector (spec §04; + // multi-view scope doc M5). Kept separate from `renderEpoch` so a brush pulse + // re-reads the table without re-subscribing the listener; their sum is the + // inspector's single refresh trigger (each event bumps exactly one, so the sum + // is strictly monotonic — no collisions). + const [liveEpoch, setLiveEpoch] = useState(0); // Busy-indication timer ref: if a render exceeds ~1s we surface a non-blocking // overlay (arch §10.2 NN/g: >1s owes a busy indication; <1s shows nothing to @@ -397,6 +404,19 @@ export function LivePreview() { // on `renderEpoch`, so this need not depend on it (it always reads the latest handle). const getInspectData = useCallback(() => handleRef.current?.inspectData() ?? null, []); + // Live data inspection (spec §04): while the inspector is open, re-read the table + // when an interactive selection changes the data it shows (a filtering brush). The + // handle owns the Vega listeners + debounce; we just bump `liveEpoch` on each fire. + // Re-subscribes whenever a render settles (`renderEpoch`) so it tracks the current + // handle, and only while the inspector is open so a collapsed one costs nothing. + // handleRef is a ref (read, not a dep); the cleanup unsubscribes. + useEffect(() => { + if (!inspectorOpen) return; + const handle = handleRef.current; + if (!handle) return; + return handle.onDataChange(() => setLiveEpoch((e) => e + 1)); + }, [inspectorOpen, renderEpoch]); + // 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; @@ -478,7 +498,7 @@ export function LivePreview() { diff --git a/src/app/components/Onboarding.test.tsx b/src/app/components/Onboarding.test.tsx index cac9eb5..00d51c3 100644 --- a/src/app/components/Onboarding.test.tsx +++ b/src/app/components/Onboarding.test.tsx @@ -18,7 +18,13 @@ import { Onboarding } from './Onboarding'; // never touches vega-embed. A resolved no-op handle is enough — Onboarding only // finalizes it on unmount. vi.mock('../services/chart-renderer', () => ({ - renderSpec: () => Promise.resolve({ destroy() {}, resize() {}, inspectData: () => null }), + renderSpec: () => + Promise.resolve({ + destroy() {}, + resize() {}, + inspectData: () => null, + onDataChange: () => () => {}, + }), })); (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; diff --git a/src/app/components/ThemeBuilderModal.test.tsx b/src/app/components/ThemeBuilderModal.test.tsx index 0aafc78..62f408a 100644 --- a/src/app/components/ThemeBuilderModal.test.tsx +++ b/src/app/components/ThemeBuilderModal.test.tsx @@ -21,6 +21,7 @@ const okHandle = () => ({ resize() {}, toImageURL: () => Promise.resolve(''), inspectData: () => null, + onDataChange: () => () => {}, }); (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; diff --git a/src/app/components/ThemeControlPanels.test.tsx b/src/app/components/ThemeControlPanels.test.tsx index e65fe4b..ac2a568 100644 --- a/src/app/components/ThemeControlPanels.test.tsx +++ b/src/app/components/ThemeControlPanels.test.tsx @@ -22,6 +22,7 @@ vi.mock('../services/chart-renderer', () => ({ resize() {}, toImageURL: () => Promise.resolve(''), inspectData: () => null, + onDataChange: () => () => {}, }), ), })); diff --git a/src/app/services/chart-renderer.ts b/src/app/services/chart-renderer.ts index 3152f9e..480eac1 100644 --- a/src/app/services/chart-renderer.ts +++ b/src/app/services/chart-renderer.ts @@ -66,6 +66,60 @@ export interface InspectedData { tables: InspectableTable[]; } +/** + * Debounce for the live data inspector (spec §04; multi-view scope doc M5). A + * brush drag pulses `addDataListener` continuously; coalescing to one re-read per + * ~quiet-frame keeps the table feeling live without thrashing the grid on every + * pixel of the drag. + */ +export const LIVE_INSPECT_DEBOUNCE_MS = 120; + +/** The minimal Vega `View` surface the live-inspect watcher needs. */ +interface DataChangeView { + addDataListener(name: string, handler: () => void): unknown; + removeDataListener(name: string, handler: () => void): unknown; +} + +/** + * Attach debounced listeners to every table the inspector shows so an interactive + * selection that recomputes a drawn table (a `filter: {param}` brush) re-reads the + * inspector live — see `RenderHandle.onDataChange`. Watches the union of each + * drawn table's resolved + input names (deduped); a highlight selection changes no + * data, so none fire. Returns an unsubscribe that cancels any pending re-read and + * detaches the listeners — but skips detaching once the view is finalized, since + * `view.finalize()` has already dropped every listener (and the unmount cleanup + * order can run this after the destroy). `isFinalized` is a getter, not a boolean, + * so it reflects the view's state at unsubscribe time, not subscribe time. + */ +export function watchInspectableData( + view: DataChangeView, + vgSpec: unknown, + onChange: () => void, + isFinalized: () => boolean, +): () => void { + if (isFinalized()) return () => {}; + const names = [...new Set(inspectableViews(vgSpec).flatMap((v) => [v.resolved, v.input]))]; + if (names.length === 0) return () => {}; + + let timer: ReturnType | null = null; + const handler = (): void => { + if (timer !== null) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + onChange(); + }, LIVE_INSPECT_DEBOUNCE_MS); + }; + for (const name of names) view.addDataListener(name, handler); + + return () => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + if (!isFinalized()) for (const name of names) view.removeDataListener(name, handler); + }; +} + export interface RenderHandle { /** Finalize the underlying Vega view and clear the node. */ destroy(): void; @@ -104,6 +158,16 @@ export interface RenderHandle { * "no chart" so the inspector can say which. */ inspectData(): InspectedData | null; + /** + * Subscribe to live changes of the inspected tables, for the data inspector's + * live mode (spec §04; multi-view scope doc M5). An interactive selection that + * *filters* a downstream view recomputes that view's compiled table in place — + * no re-embed — so a static inspector would show stale rows until the next full + * render; this fires (debounced) so the caller can re-read via `inspectData()`. + * A highlight selection (a `condition` encoding) changes no data, so it never + * fires. Returns an unsubscribe; a no-op when the view is already finalized. + */ + onDataChange(listener: () => void): () => void; } export interface RenderOptions { @@ -323,5 +387,8 @@ export async function renderSpec( })); return { tables }; }, + onDataChange(listener) { + return watchInspectableData(result.view, result.vgSpec, listener, () => finalized); + }, }; } diff --git a/src/app/services/chart-renderer.watch.test.ts b/src/app/services/chart-renderer.watch.test.ts new file mode 100644 index 0000000..0ae336a --- /dev/null +++ b/src/app/services/chart-renderer.watch.test.ts @@ -0,0 +1,118 @@ +/** + * Live-inspection watcher (`watchInspectableData`) — the wiring behind + * `RenderHandle.onDataChange` (spec §04; multi-view scope doc M5). Verified against + * a fake Vega view + fake timers; the real selection→filter→data recompute is an + * integration behavior exercised manually (renderSpec is vega-embed-bound). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { LIVE_INSPECT_DEBOUNCE_MS, watchInspectableData } from './chart-renderer'; + +/** A compiled-Vega-shape spec: one drawn table data_0, sourced from source_0. */ +const vgSpec = { + data: [{ name: 'source_0' }, { name: 'data_0', source: 'source_0' }], + marks: [{ type: 'symbol', from: { data: 'data_0' } }], +}; + +function fakeView() { + const listeners = new Map void>>(); + return { + added: [] as string[], + removed: [] as string[], + addDataListener(name: string, handler: () => void) { + const set = listeners.get(name) ?? new Set<() => void>(); + set.add(handler); + listeners.set(name, set); + this.added.push(name); + }, + removeDataListener(name: string, handler: () => void) { + listeners.get(name)?.delete(handler); + this.removed.push(name); + }, + fire(name: string) { + for (const handler of listeners.get(name) ?? []) handler(); + }, + }; +} + +beforeEach(() => vi.useFakeTimers()); +afterEach(() => vi.useRealTimers()); + +describe('watchInspectableData', () => { + it('watches both the resolved and input table of each drawn view', () => { + const view = fakeView(); + watchInspectableData( + view, + vgSpec, + () => {}, + () => false, + ); + expect(new Set(view.added)).toEqual(new Set(['data_0', 'source_0'])); + }); + + it('debounces a burst of changes into a single re-read', () => { + const view = fakeView(); + const onChange = vi.fn(); + watchInspectableData(view, vgSpec, onChange, () => false); + + view.fire('data_0'); + view.fire('data_0'); + view.fire('data_0'); // a brush drag pulsing + expect(onChange).not.toHaveBeenCalled(); // still within the debounce window + + vi.advanceTimersByTime(LIVE_INSPECT_DEBOUNCE_MS); + expect(onChange).toHaveBeenCalledTimes(1); + }); + + it('unsubscribe detaches listeners and cancels a pending re-read', () => { + const view = fakeView(); + const onChange = vi.fn(); + const stop = watchInspectableData(view, vgSpec, onChange, () => false); + + view.fire('data_0'); + stop(); + vi.advanceTimersByTime(LIVE_INSPECT_DEBOUNCE_MS * 2); + + expect(onChange).not.toHaveBeenCalled(); // pending re-read cancelled + expect(new Set(view.removed)).toEqual(new Set(['data_0', 'source_0'])); + }); + + it('is a no-op when the view is already finalized at subscribe', () => { + const view = fakeView(); + const stop = watchInspectableData( + view, + vgSpec, + () => {}, + () => true, + ); + expect(view.added).toEqual([]); + stop(); // safe + }); + + it('after finalize, unsubscribe cancels the timer but does not touch the dead view', () => { + const view = fakeView(); + const onChange = vi.fn(); + let finalized = false; + const stop = watchInspectableData(view, vgSpec, onChange, () => finalized); + + view.fire('data_0'); + finalized = true; // view.finalize() ran (dropping its own listeners) before cleanup + stop(); + vi.advanceTimersByTime(LIVE_INSPECT_DEBOUNCE_MS * 2); + + expect(onChange).not.toHaveBeenCalled(); + expect(view.removed).toEqual([]); // didn't call removeDataListener on a dead view + }); + + it('does nothing for a spec that draws no inspectable table', () => { + const view = fakeView(); + const stop = watchInspectableData( + view, + { data: [], marks: [] }, + () => {}, + () => false, + ); + expect(view.added).toEqual([]); + stop(); + }); +});