Inspector: live data updates under interactive selections

This commit is contained in:
2026-06-29 09:58:32 +03:00
parent 6656811b8e
commit 8aa05e503d
13 changed files with 269 additions and 21 deletions
@@ -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,
};
@@ -21,6 +21,7 @@ vi.mock('../services/chart-renderer', () => ({
resize() {},
toImageURL: () => Promise.resolve(''),
inspectData: () => null,
onDataChange: () => () => {},
}),
),
}));
+6 -1
View File
@@ -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
+1
View File
@@ -45,6 +45,7 @@ vi.mock('../services/chart-renderer', () => ({
},
resize() {},
inspectData: () => null,
onDataChange: () => () => {},
});
});
});
+21 -1
View File
@@ -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() {
<DataInspector
id="preview-data-inspector"
getData={getInspectData}
renderEpoch={renderEpoch}
renderEpoch={renderEpoch + liveEpoch}
heightPx={inspectorOpen ? inspectorHeight : undefined}
/>
</div>
+7 -1
View File
@@ -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;
@@ -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;
@@ -22,6 +22,7 @@ vi.mock('../services/chart-renderer', () => ({
resize() {},
toImageURL: () => Promise.resolve(''),
inspectData: () => null,
onDataChange: () => () => {},
}),
),
}));
+67
View File
@@ -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<typeof setTimeout> | 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);
},
};
}
@@ -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<string, Set<() => 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();
});
});