Inspector: multi-view data inspection with a per-table view picker

This commit is contained in:
2026-06-28 22:13:34 +03:00
parent a75ea5b59e
commit 8cad80f738
10 changed files with 537 additions and 258 deletions
@@ -42,26 +42,25 @@ above it is data; everything below it is a Vega `View` we own and must tear down
### The data inspector rides the boundary too ### The data inspector rides the boundary too
The data inspector (the Live Preview and Chart Builder panel showing the chart's input The data inspector (the Live Preview and Chart Builder panel showing each drawn table's
vs. resolved rows — spec §04) reads runtime rows through the handle, never the raw view: input vs. resolved rows — spec §04) reads runtime rows through the handle, never the raw
`RenderHandle.inspectData()` returns the input + resolved tables (`{ input, resolved }`, view: `RenderHandle.inspectData()` returns the inspectable tables (`{ tables }`, or `null`
or `null` when no chart is up), wrapping the view exactly like `toImageURL`. It works in when no chart is up), wrapping the view exactly like `toImageURL`. It works in two layers:
two layers:
- **Enumerate + pick (`view.getState` + `core/result-data`).** A compiled Vega dataflow - **Enumerate from the compiled spec (`core/inspect-views`).** A composed spec draws
holds many named datasets; Vega-Lite names them by convention — `source_<n>` per parsed several tables; `inspectableViews` walks the compiled Vega spec — the marks tree's
source, `data_<n>` per transform stage. The pure `pickSourceDataset` / `pickResultDataset` `from.data` (what each mark draws) and `data[].source` (the lineage, the documented Vega
choose the **most-upstream source** (the input) and **most-downstream output** (what the format) — to list, in document order, one entry per **distinct drawn table** with its
marks draw), skipping dataflow internals (`marks`, `root`, layout, selection `*_store`s). `resolved` (post-transform, what the marks draw) and `input` (most-upstream source) ends.
A spec with no transforms resolves both to the same table. The picking is pure (in `core`, Enumerating by drawn table, not by authored view, is forced by Vega-Lite desugaring (a
unit-tested); only the enumeration touches the view. `point: true` line compiles to two layers — a compiled table can't be traced back to one
- **Read lazily.** `getState` serializes the datasets it lists, so it is **only called while authored view). Selection `*_store`s and `facet_domain*` layout tables aren't drawn, so
the panel is open** — a collapsed inspector costs nothing, which is why the panel reads on they fall out for free. The walk is pure (in `core`, unit-tested); the boundary reads each
demand rather than on every render. table's rows via `view.data(name)`.
- **Read lazily.** Reading serializes rows, so it happens **only while the panel is open**
Limitation: one name per direction can't represent a multi-view spec (layer/concat/facet a collapsed inspector costs nothing, which is why the panel reads on demand rather than on
produce several `data_<n>`); the most-downstream/upstream ones are returned, and a full every render. (A multi-view spec yields several tables; the panel's `SelectControl` picker
dataset selector is left as a future option. chooses which to show — labels never expose Vega's compiler names, see arch 10.)
--- ---
@@ -39,8 +39,8 @@ collection (`spec-fields`), config baking (`spec-config`), standalone export
view's columns in a composition whose views bind different datasets. view's columns in a composition whose views bind different datasets.
- **Extract inline data** (`app/stores/ExtractStore`) lifts only the top-level - **Extract inline data** (`app/stores/ExtractStore`) lifts only the top-level
`data` block. `data` block.
- **Data inspector** (`core/result-data`, `DataInspector`) surfaces one input + - **Data inspector** (`DataInspector`) surfaces one input + one resolved table; a
one resolved table; a composition produces several `source_<n>`/`data_<n>`. composition produces several `source_<n>`/`data_<n>`.
- **`deriveSnippetName`** (`core/snippet`) reads top-level `mark`/`encoding` only, - **`deriveSnippetName`** (`core/snippet`) reads top-level `mark`/`encoding` only,
so a composed spec falls back to the default name (graceful, not a bug). so a composed spec falls back to the default name (graceful, not a bug).
- **Chart builder** is single-view by design; its strict round-trip hydration - **Chart builder** is single-view by design; its strict round-trip hydration
@@ -73,12 +73,25 @@ collection (`spec-fields`), config baking (`spec-config`), standalone export
for every form (library ref case-insensitive, inline, named-inline, self-defined for every form (library ref case-insensitive, inline, named-inline, self-defined
`datasets`, url/generator → none); the three Monaco providers and the `datasets`, url/generator → none); the three Monaco providers and the
facet/repeat defaults pass the cursor offset. facet/repeat defaults pass the cursor offset.
- **M4 — multi-view inspection** (pulled ahead of M3) — group the dataflow's - **M4 — multi-view inspection** (pulled ahead of M3) — `core/inspect-views`
datasets into per-view input/resolved pairs (`result-data`) and add a view enumerates the distinct tables the marks draw (from the compiled Vega spec's
selector to `DataInspector` (new interactive widget → `/council`). The data `from.data` + `data[].source` lineage), each with its input + resolved ends;
inspector executes the live pipeline, so it is the place to review post-transform `RenderHandle.inspectData()` returns those tables with rows; `DataInspector` adds
rows (melt/fold/pivot/aggregate); today it surfaces one heuristic input/resolved a `SelectControl` view picker (hidden for the single-table case), labels via
pair across the whole composition, with no per-view choice. `inspectViewLabel` (named dataset or "View N", never compiler names) + a
`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 - **M3 — view-scoped extract** — seed Extract from the focused view's inline data
(reusing the cursor-scope machinery) and rewrite that view's `data`. (reusing the cursor-scope machinery) and rewrite that view's `data`.
+87 -16
View File
@@ -32,12 +32,38 @@ afterEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
const toggle = () => container.querySelector<HTMLButtonElement>('button[aria-expanded]')!; const toggle = () =>
Array.from(container.querySelectorAll<HTMLButtonElement>('button[aria-expanded]')).find((b) =>
b.textContent?.includes('Data'),
)!;
const text = () => container.textContent ?? ''; const text = () => container.textContent ?? '';
const viewButton = (label: string) => const viewButton = (label: string) =>
Array.from(container.querySelectorAll<HTMLButtonElement>('[role="radio"]')).find( Array.from(container.querySelectorAll<HTMLButtonElement>('[role="radio"]')).find(
(b) => b.textContent === label, (b) => b.textContent === label,
)!; )!;
/** The view picker's trigger, or null when it is not shown (single-table case). */
const picker = () => container.querySelector<HTMLButtonElement>('[aria-label^="Inspected view"]');
/** Build the inspector payload — one table per (label, input, resolved) entry. */
const tablesOf = (
...entries: Array<{
label: string;
input?: Record<string, unknown>[];
resolved: Record<string, unknown>[];
}>
): InspectedData => ({
tables: entries.map((e, i) => ({
id: `t${i}`,
label: e.label,
input: e.input ?? [],
resolved: e.resolved,
})),
});
/** The common single-table payload. */
const oneTable = (
input: Record<string, unknown>[],
resolved: Record<string, unknown>[],
): InspectedData => tablesOf({ label: 'View 1', input, resolved });
const render = (props: Partial<Parameters<typeof DataInspectorPanel>[0]> = {}) => const render = (props: Partial<Parameters<typeof DataInspectorPanel>[0]> = {}) =>
act(() => { act(() => {
@@ -68,26 +94,33 @@ describe('DataInspectorPanel', () => {
expect(container.querySelector('table')).toBeNull(); expect(container.querySelector('table')).toBeNull();
}); });
test('open with a chart that draws nothing inspectable: says so', () => {
render({ getData: () => ({ tables: [] }) });
expect(text()).toContain('no inspectable data');
expect(container.querySelector('table')).toBeNull();
});
test('defaults to the Resolved view and renders its rows', () => { test('defaults to the Resolved view and renders its rows', () => {
const data: InspectedData = { render({
input: [{ region: 'West', sales: '1204' }], getData: () =>
resolved: [{ region: 'West', total: 1204 }], oneTable([{ region: 'West', sales: '1204' }], [{ region: 'West', total: 1204 }]),
}; });
render({ getData: () => data });
// Resolved is the default — its column ("total"), not the input's ("sales"). // Resolved is the default — its column ("total"), not the input's ("sales").
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent); const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
expect(headers).toEqual(['region', 'total']); expect(headers).toEqual(['region', 'total']);
}); });
test('switching to Input shows the source rows', () => { test('switching to Input shows the source rows', () => {
const data: InspectedData = { render({
input: [ getData: () =>
oneTable(
[
{ region: 'West', sales: '1204' }, { region: 'West', sales: '1204' },
{ region: 'East', sales: '980' }, { region: 'East', sales: '980' },
], ],
resolved: [{ region: 'West', total: 1204 }], [{ region: 'West', total: 1204 }],
}; ),
render({ getData: () => data }); });
act(() => viewButton('Input').click()); act(() => viewButton('Input').click());
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent); const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
expect(headers).toEqual(['region', 'sales']); expect(headers).toEqual(['region', 'sales']);
@@ -96,25 +129,25 @@ describe('DataInspectorPanel', () => {
}); });
test('resolved empty: names the empty-transform signal', () => { test('resolved empty: names the empty-transform signal', () => {
render({ getData: () => ({ input: [{ a: 1 }], resolved: [] }) }); render({ getData: () => oneTable([{ a: 1 }], []) });
expect(text()).toContain('left nothing to draw'); expect(text()).toContain('left nothing to draw');
}); });
test('input empty: names the empty source', () => { test('input empty: names the empty source', () => {
render({ getData: () => ({ input: [], resolved: [] }) }); render({ getData: () => oneTable([], []) });
act(() => viewButton('Input').click()); act(() => viewButton('Input').click());
expect(text()).toContain('source data has no rows'); expect(text()).toContain('source data has no rows');
}); });
test('caps the table and reports the total', () => { test('caps the table and reports the total', () => {
const resolved = Array.from({ length: 120 }, (_, i) => ({ i })); const resolved = Array.from({ length: 120 }, (_, i) => ({ i }));
render({ getData: () => ({ input: [], resolved }) }); render({ getData: () => oneTable([], resolved) });
expect(container.querySelectorAll('tbody tr')).toHaveLength(50); expect(container.querySelectorAll('tbody tr')).toHaveLength(50);
expect(text()).toContain('first 50 of 120'); expect(text()).toContain('first 50 of 120');
}); });
test('re-reads getData when renderEpoch changes', () => { test('re-reads getData when renderEpoch changes', () => {
const getData = vi.fn((): InspectedData => ({ input: [], resolved: [{ a: 1 }] })); const getData = vi.fn(() => oneTable([], [{ a: 1 }]));
render({ getData, renderEpoch: 0 }); render({ getData, renderEpoch: 0 });
const before = getData.mock.calls.length; const before = getData.mock.calls.length;
render({ getData, renderEpoch: 1 }); render({ getData, renderEpoch: 1 });
@@ -122,7 +155,7 @@ describe('DataInspectorPanel', () => {
}); });
test('applies an explicit height when given (resizable mode)', () => { test('applies an explicit height when given (resizable mode)', () => {
render({ getData: () => ({ input: [], resolved: [{ a: 1 }] }), heightPx: 240 }); render({ getData: () => oneTable([], [{ a: 1 }]), heightPx: 240 });
const panel = container.firstElementChild as HTMLElement; const panel = container.firstElementChild as HTMLElement;
expect(panel.style.height).toBe('240px'); expect(panel.style.height).toBe('240px');
}); });
@@ -133,6 +166,44 @@ describe('DataInspectorPanel', () => {
act(() => toggle().click()); act(() => toggle().click());
expect(onToggle).toHaveBeenCalledWith(true); expect(onToggle).toHaveBeenCalledWith(true);
}); });
// ── Multi-view selector ──────────────────────────────────────────────────────
test('a single drawn table shows no view picker', () => {
render({ getData: () => oneTable([], [{ a: 1 }]) });
expect(picker()).toBeNull();
});
test('multiple drawn tables show a picker, defaulting to the first table', () => {
render({
getData: () =>
tablesOf(
{ label: 'sales', resolved: [{ region: 'W', revenue: 1 }] },
{ label: 'regions', resolved: [{ region: 'W', population: 2 }] },
),
});
// Picker present and on the first table; the grid shows that table's columns.
expect(picker()?.getAttribute('aria-label')).toBe('Inspected view: sales');
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
expect(headers).toEqual(['region', 'revenue']);
});
test('choosing another table switches the inspected data', () => {
render({
getData: () =>
tablesOf(
{ label: 'sales', resolved: [{ region: 'W', revenue: 1 }] },
{ label: 'regions', resolved: [{ region: 'W', population: 2 }] },
),
});
act(() => picker()!.click()); // open the portaled popover
const option = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((b) =>
b.textContent?.includes('regions'),
)!;
act(() => option.click());
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
expect(headers).toEqual(['region', 'population']);
});
}); });
describe('DataInspector', () => { describe('DataInspector', () => {
+66 -23
View File
@@ -1,46 +1,67 @@
/** /**
* Data inspector — input vs. resolved rows (spec §04). * Data inspector — input vs. resolved rows, per drawn table (spec §04).
* *
* Shows the chart's data with a toggle between two views of the same rendered * Shows the chart's data with a toggle between two ends of a table's pipeline:
* view: **Input** (the parsed source rows, before the spec's transforms) and * **Input** (the parsed source rows, before the view's transforms) and
* **Resolved** (the rows the chart draws, after filters / calculated fields / * **Resolved** (the rows the marks draw, after filters / calculated fields /
* aggregation). Seeing input → output side by side is how you answer "why is my * aggregation). Seeing input → output is how you answer "why is my chart
* chart empty/wrong" — look at what the transforms did to the data. Both tables * empty/wrong" — look at what the transforms did to the data.
* come from the live Vega view via the renderer's `RenderHandle.inspectData()` *
* accessor, passed in as `getData` so this component never touches the view (the * A composed spec (layer/concat/facet/repeat) draws several tables, so a **view
* embedding boundary, arch 05). * picker** (`SelectControl`) lets the user choose which one to inspect — "what data
* am I actually visualizing?". The picker is hidden for the common single-table
* case (council: NN/g #8 — no one-option control; docs/architecture/10). Labels
* never show Vega's compiler names (`source_0`/`data_2`), only a user-authored
* dataset name or an ordinal "View N" plus a columns·rows recognition cue
* (`@core/inspect-views`; NN/g #2/#6).
*
* Tables come from the live Vega view via `RenderHandle.inspectData()`, passed in as
* `getData` so this component never touches the view (the embedding boundary, arch
* 05). Read lazily — only while expanded — so a collapsed inspector costs nothing.
* *
* `DataInspectorPanel` is the reusable shape (an APG disclosure, mirroring the * `DataInspectorPanel` is the reusable shape (an APG disclosure, mirroring the
* builder's source-rows preview); `DataInspector` binds it to the persisted * builder's source-rows preview); `DataInspector` binds it to the persisted
* preview-pane open state for the live-preview pane. The data is read lazily — * preview-pane open state for the live-preview pane.
* only while expanded — because listing the view's datasets serializes them (see
* `RenderHandle.inspectData`), so a collapsed inspector costs nothing.
*/ */
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { InspectedData } from '../services/chart-renderer'; import type { InspectableTable, InspectedData } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore'; import { useAppStore } from '../stores/AppStore';
import { DataTable } from './DataTable'; import { DataTable } from './DataTable';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SelectControl, type SelectControlOption } from './SelectControl';
import styles from './DataInspector.module.css'; import styles from './DataInspector.module.css';
/** Rows shown before truncating — matches the builder's source-preview cap (spec §06). */ /** Rows shown before truncating — matches the builder's source-preview cap (spec §06). */
const ROW_LIMIT = 50; const ROW_LIMIT = 50;
/** Columns named in a table's picker `detail` before eliding the rest. */
const DETAIL_COLUMNS = 4;
type DataView = 'input' | 'resolved'; type DataView = 'input' | 'resolved';
/** The two views, in input → output order (the natural reading direction). */ /** The two stages, in input → output order (the natural reading direction). */
const VIEW_OPTIONS: ReadonlyArray<SegmentedOption<DataView>> = [ const VIEW_OPTIONS: ReadonlyArray<SegmentedOption<DataView>> = [
// `title` doubles as the accessible name (WCAG 2.5.3 label-in-name; leads with // `title` doubles as the accessible name (WCAG 2.5.3 label-in-name; leads with
// the visible label). // the visible label).
{ value: 'input', label: 'Input', title: 'Input — the source rows before the specs transforms' }, { value: 'input', label: 'Input', title: 'Input — the source rows before the views transforms' },
{ {
value: 'resolved', value: 'resolved',
label: 'Resolved', label: 'Resolved',
title: 'Resolved — the rows the chart draws, after its transforms', title: 'Resolved — the rows the marks draw, after the views transforms',
}, },
]; ];
/** A recognition cue for the view picker: row count + the first few columns. */
function tableDetail(table: InspectableTable): string {
const rows = table.resolved;
const count = `${rows.length.toLocaleString()} ${rows.length === 1 ? 'row' : 'rows'}`;
const columns = rows[0] ? Object.keys(rows[0]) : [];
if (columns.length === 0) return count;
const shown = columns.slice(0, DETAIL_COLUMNS).join(', ');
const more = columns.length > DETAIL_COLUMNS ? `, +${columns.length - DETAIL_COLUMNS}` : '';
return `${count} · ${shown}${more}`;
}
interface DataInspectorPanelProps { interface DataInspectorPanelProps {
/** Whether the panel is expanded. */ /** Whether the panel is expanded. */
open: boolean; open: boolean;
@@ -83,13 +104,20 @@ export function DataInspectorPanel({
id, id,
}: DataInspectorPanelProps) { }: DataInspectorPanelProps) {
const [view, setView] = useState<DataView>('resolved'); const [view, setView] = useState<DataView>('resolved');
const [selectedId, setSelectedId] = useState<string | null>(null);
// Read both tables only while open; re-read when a render settles. `renderEpoch` // Read tables only while open; re-read when a render settles. `renderEpoch` is an
// is an intentional refresh trigger — not read in the body (getData is stable and // intentional refresh trigger — not read in the body (getData is stable and always
// always reads the latest view), so exhaustive-deps sees it as unnecessary. // reads the latest view), so exhaustive-deps sees it as unnecessary.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
const data = useMemo(() => (open ? getData() : null), [open, renderEpoch, getData]); const data = useMemo(() => (open ? getData() : null), [open, renderEpoch, getData]);
const rows = data === null ? null : data[view]; const tables = data?.tables ?? null;
// The chosen table, falling back to the first when the selection is stale (the
// spec changed under it) or unset — so a re-render never lands on a missing table.
const table = tables?.find((t) => t.id === selectedId) ?? tables?.[0] ?? null;
const rows = table ? table[view] : null;
const pickerId = `${id ?? 'preview'}-view-picker`;
return ( return (
<div <div
@@ -111,8 +139,21 @@ export function DataInspectorPanel({
</button> </button>
{open && ( {open && (
<> <>
{tables && tables.length > 1 && table && (
<SelectControl
id={pickerId}
label="Inspected view"
value={table.id}
onSelect={setSelectedId}
options={tables.map<SelectControlOption<string>>((t) => ({
value: t.id,
label: t.label,
detail: tableDetail(t),
}))}
/>
)}
<SegmentedControl <SegmentedControl
label="Data view" label="Pipeline stage"
options={VIEW_OPTIONS} options={VIEW_OPTIONS}
value={view} value={view}
onChange={setView} onChange={setView}
@@ -127,12 +168,14 @@ export function DataInspectorPanel({
</div> </div>
{open && {open &&
(rows === null ? ( (data === null ? (
<p className={styles.stateNote}>Render a chart to inspect its data.</p> <p className={styles.stateNote}>Render a chart to inspect its data.</p>
) : table === null || rows === null ? (
<p className={styles.stateNote}>This chart has no inspectable data.</p>
) : rows.length === 0 ? ( ) : rows.length === 0 ? (
<p className={styles.stateNote}> <p className={styles.stateNote}>
{view === 'resolved' {view === 'resolved'
? 'No rows — the specs filters or transforms left nothing to draw.' ? 'No rows — the views filters or transforms left nothing to draw.'
: 'The source data has no rows.'} : 'The source data has no rows.'}
</p> </p>
) : ( ) : (
+1 -5
View File
@@ -27,10 +27,6 @@ const H = vi.hoisted(() => ({
pending: [] as Array<() => void>, pending: [] as Array<() => void>,
destroyed: [] as number[], destroyed: [] as number[],
configs: [] as unknown[], configs: [] as unknown[],
inspected: null as {
input: ReadonlyArray<Record<string, unknown>>;
resolved: ReadonlyArray<Record<string, unknown>>;
} | null,
})); }));
vi.mock('../services/chart-renderer', () => ({ vi.mock('../services/chart-renderer', () => ({
renderSpec: (node: HTMLElement, _spec: unknown, config: unknown) => { renderSpec: (node: HTMLElement, _spec: unknown, config: unknown) => {
@@ -48,7 +44,7 @@ vi.mock('../services/chart-renderer', () => ({
H.destroyed.push(id); H.destroyed.push(id);
}, },
resize() {}, resize() {},
inspectData: () => H.inspected, inspectData: () => null,
}); });
}); });
}); });
+42 -33
View File
@@ -12,7 +12,7 @@ import type { VisualizationSpec } from 'vega-embed';
import type { Config } from 'vega-lite'; import type { Config } from 'vega-lite';
import { collectFontFamilies } from '@core/custom-theme'; import { collectFontFamilies } from '@core/custom-theme';
import { embedFontsInSvg } from '@core/chart-export'; import { embedFontsInSvg } from '@core/chart-export';
import { pickResultDataset, pickSourceDataset } from '@core/result-data'; import { inspectViewLabel, inspectableViews } from '@core/inspect-views';
import type { FontAsset } from '@core/font-asset'; import type { FontAsset } from '@core/font-asset';
/** Options for `RenderHandle.toImageURL` (spec §08 → Per-chart export). */ /** Options for `RenderHandle.toImageURL` (spec §08 → Per-chart export). */
@@ -44,15 +44,28 @@ interface ImageExportOptions {
embedFonts?: ReadonlyArray<FontAsset>; embedFonts?: ReadonlyArray<FontAsset>;
} }
/** The two ends of the chart's data pipeline, for the data inspector (spec §04). */ /** One inspectable drawn table — the two ends of its pipeline (spec §04). */
export interface InspectedData { export interface InspectableTable {
/** Parsed source rows, before the spec's transforms run — the input. */ /** Stable selection id — the resolved table's compiled name. */
id: string;
/** User-facing label (never a compiler name — `@core/inspect-views`). */
label: string;
/** Parsed source rows, before the view's transforms run — the input. */
input: ReadonlyArray<Record<string, unknown>>; input: ReadonlyArray<Record<string, unknown>>;
/** Post-transform rows the chart draws — the output (equals `input` when the /** Post-transform rows the marks draw — the output (equals `input` when the view
* spec has no transforms). */ * has no transforms). */
resolved: ReadonlyArray<Record<string, unknown>>; resolved: ReadonlyArray<Record<string, unknown>>;
} }
/**
* The chart's inspectable data: one table per distinct table the marks draw, in
* document order (a multi-view spec yields several). `tables` is empty when the
* chart draws nothing inspectable, distinct from a `null` handle (no chart).
*/
export interface InspectedData {
tables: InspectableTable[];
}
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;
@@ -78,18 +91,17 @@ export interface RenderHandle {
*/ */
resize(): void; resize(): void;
/** /**
* The chart's input and resolved (post-transform) rows — for the data inspector * The chart's inspectable tables — for the data inspector (spec §04). Enumerates
* (spec §04). Reads the live view's compiled dataflow once: lists its datasets, * the tables the marks draw from the compiled Vega spec (`@core/inspect-views`),
* picks the most-upstream * and for each reads its input + resolved rows from the live view. A multi-view
* source and most-downstream result (`@core/result-data`), and returns both * spec yields several tables; a unit spec yields one. This is the one place
* tables' rows. This is the one place besides export that reaches into the view, * besides export that reaches into the view, so the embedding boundary holds
* so the embedding boundary holds (arch 05 §1–§2) — callers get rows, never the * (arch 05 §1–§2) — callers get rows, never the `view`.
* `view`.
* *
* Returns `null` when there is nothing to inspect (the view was finalized, or * Returns `null` when the view was finalized (no chart). The result's `tables`
* the spec produced no inspectable table). Either side can be `[]` when its * is empty when a chart draws nothing inspectable, and any table's `input`/
* table is empty — a real signal (e.g. a filter removed every row on the * `resolved` can be `[]` (e.g. a filter removed every row) — kept distinct from
* resolved side), kept distinct from "no chart" so the inspector can say which. * "no chart" so the inspector can say which.
*/ */
inspectData(): InspectedData | null; inspectData(): InspectedData | null;
} }
@@ -297,22 +309,19 @@ export async function renderSpec(
}, },
inspectData() { inspectData() {
if (finalized) return null; if (finalized) return null;
// Enumerate the dataflow's datasets once, then pick the input + result // The tables the marks draw + their input lineage come from the compiled Vega
// tables. getState with a truthy `data` filter is Vega's documented way to // spec (a byproduct of the embed, not recompiled); the rows come from
// list datasets (vega/editor's Data Viewer does the same) — we read only the // view.data(name), which hands back the live array (no copy).
// keys. Rows come from view.data(name), which hands back the live array (no copy). const views = inspectableViews(result.vgSpec);
const state = result.view.getState({ const rows = (name: string): ReadonlyArray<Record<string, unknown>> =>
data: () => true, (result.view.data(name) ?? []) as Record<string, unknown>[];
signals: () => false, const tables = views.map((v, i) => ({
recurse: true, id: v.resolved,
}) as { data?: Record<string, unknown> }; label: inspectViewLabel(v.input, i),
const names = Object.keys(state.data ?? {}); input: rows(v.input),
const sourceName = pickSourceDataset(names); resolved: rows(v.resolved),
const resultName = pickResultDataset(names); }));
if (sourceName === null && resultName === null) return null; return { tables };
const rows = (name: string | null): ReadonlyArray<Record<string, unknown>> =>
name === null ? [] : ((result.view.data(name) ?? []) as Record<string, unknown>[]);
return { input: rows(sourceName), resolved: rows(resultName) };
}, },
}; };
} }
+173
View File
@@ -0,0 +1,173 @@
import { describe, expect, test } from 'vitest';
import { inspectViewLabel, inspectableViews } from './inspect-views';
// Fixtures mirror the shapes Vega-Lite 6 actually compiles to (verified by
// compiling each composition and dumping `vgSpec.data` + `vgSpec.marks`).
describe('inspectableViews', () => {
test('a single unit: one drawn table, resolved + input ends of its pipeline', () => {
const vg = {
data: [
{ name: 'source_0', values: [] },
{ name: 'data_0', source: 'source_0' },
],
marks: [{ type: 'rect', name: 'marks', from: { data: 'data_0' } }],
};
expect(inspectableViews(vg)).toEqual([{ resolved: 'data_0', input: 'source_0' }]);
});
test('vconcat sharing one source: two tables, same input, different resolved', () => {
const vg = {
data: [
{ name: 'source_0', values: [] },
{ name: 'data_1', source: 'source_0' },
{ name: 'data_2', source: 'source_0' },
],
marks: [
{
type: 'group',
name: 'concat_0_group',
marks: [{ type: 'rect', name: 'concat_0_marks', from: { data: 'data_1' } }],
},
{
type: 'group',
name: 'concat_1_group',
marks: [{ type: 'rect', name: 'concat_1_marks', from: { data: 'data_2' } }],
},
],
};
expect(inspectableViews(vg)).toEqual([
{ resolved: 'data_1', input: 'source_0' },
{ resolved: 'data_2', input: 'source_0' },
]);
});
test('layers binding different data: each table traces to its own named source', () => {
const vg = {
data: [
{ name: 'a', values: [] },
{ name: 'b', values: [] },
{ name: 'data_0', source: 'a' },
{ name: 'data_1', source: 'b' },
],
marks: [
{ type: 'line', name: 'layer_0_marks', from: { data: 'data_0' } },
{ type: 'symbol', name: 'layer_1_marks', from: { data: 'data_1' } },
],
};
expect(inspectableViews(vg)).toEqual([
{ resolved: 'data_0', input: 'a' },
{ resolved: 'data_1', input: 'b' },
]);
});
test('repeat: one drawn table per repeated child', () => {
const vg = {
data: [
{ name: 'source_0', values: [] },
{ name: 'data_1', source: 'source_0' },
{ name: 'data_2', source: 'source_0' },
],
marks: [
{
type: 'group',
name: 'child__a_group',
marks: [{ type: 'rect', name: 'child__a_marks', from: { data: 'data_1' } }],
},
{
type: 'group',
name: 'child__b_group',
marks: [{ type: 'rect', name: 'child__b_marks', from: { data: 'data_2' } }],
},
],
};
expect(inspectableViews(vg).map((v) => v.resolved)).toEqual(['data_1', 'data_2']);
});
test('facet: the cell data via from.facet.data; layout-helper tables excluded', () => {
const vg = {
data: [
{ name: 'source_0', values: [] },
{ name: 'data_0', source: 'source_0' },
{ name: 'facet_domain', source: 'data_0' },
{ name: 'facet_domain_row' },
{ name: 'facet_domain_column' },
],
marks: [
{ type: 'group', name: 'facet-title' },
{ type: 'group', name: 'row_header', from: { data: 'facet_domain_row' } },
{ type: 'group', name: 'column_footer', from: { data: 'facet_domain_column' } },
{
type: 'group',
name: 'cell',
from: { facet: { data: 'data_0' } },
marks: [{ type: 'rect', name: 'child_marks', from: { data: 'facet' } }],
},
],
};
// Only the faceted cell data is inspectable; facet_domain* are layout, and the
// child's `from: { data: 'facet' }` names no real table.
expect(inspectableViews(vg)).toEqual([{ resolved: 'data_0', input: 'source_0' }]);
});
test('input equals resolved when the drawn table is itself the source (no transforms)', () => {
const vg = {
data: [{ name: 'source_0', values: [] }],
marks: [{ type: 'rect', name: 'marks', from: { data: 'source_0' } }],
};
expect(inspectableViews(vg)).toEqual([{ resolved: 'source_0', input: 'source_0' }]);
});
test('a line+point unit shows two tables (Vega-Lite desugars point into a layer)', () => {
// Documented consequence of enumerating drawn tables: point overlay → two
// near-identical tables (data_1 derives from data_0), both tracing to source_0.
const vg = {
data: [
{ name: 'source_0', values: [] },
{ name: 'data_0', source: 'source_0' },
{ name: 'data_1', source: 'data_0' },
],
marks: [
{ type: 'line', name: 'layer_0_marks', from: { data: 'data_0' } },
{ type: 'symbol', name: 'layer_1_marks', from: { data: 'data_1' } },
],
};
expect(inspectableViews(vg)).toEqual([
{ resolved: 'data_0', input: 'source_0' },
{ resolved: 'data_1', input: 'source_0' },
]);
});
test('deduplicates a table drawn by more than one mark', () => {
const vg = {
data: [
{ name: 'source_0', values: [] },
{ name: 'data_0', source: 'source_0' },
],
marks: [
{ type: 'rect', name: 'm1', from: { data: 'data_0' } },
{ type: 'text', name: 'm2', from: { data: 'data_0' } },
],
};
expect(inspectableViews(vg)).toEqual([{ resolved: 'data_0', input: 'source_0' }]);
});
test('returns [] for malformed or data-less specs', () => {
expect(inspectableViews(null)).toEqual([]);
expect(inspectableViews({})).toEqual([]);
expect(inspectableViews({ data: [], marks: [] })).toEqual([]);
expect(inspectableViews({ marks: [{ type: 'rect', from: { data: 'ghost' } }] })).toEqual([]);
});
});
describe('inspectViewLabel', () => {
test('compiler-generated names become an ordinal "View N"', () => {
expect(inspectViewLabel('source_0', 0)).toBe('View 1');
expect(inspectViewLabel('data_2', 1)).toBe('View 2');
});
test('a user-authored dataset name is shown verbatim', () => {
expect(inspectViewLabel('sales', 0)).toBe('sales');
expect(inspectViewLabel('data_foo', 2)).toBe('data_foo'); // no trailing digits → not compiler
});
});
+126
View File
@@ -0,0 +1,126 @@
/**
* Inspectable drawn tables of a compiled chart (the data inspector — spec §04;
* docs/architecture/05 → "the data inspector rides the boundary").
*
* Portable core: pure analysis of a **compiled Vega spec** (the byproduct
* `vega-embed` already produces to render the chart — nothing is compiled here).
* A multi-view Vega-Lite spec (layer/concat/facet/repeat) compiles to several
* data tables, and the inspector lets the user pick which one to look at — "what
* data am I actually visualizing?" — to debug in-spec transforms.
*
* What counts as inspectable is the set of tables the **marks actually draw**, read
* from the compiled marks tree's `from.data` (and a facet cell's `from.facet.data`).
* That is the honest answer to the question and it sidesteps a mapping that cannot
* be made reliable: Vega-Lite *desugars* some marks into layers (e.g. a `line` with
* `point: true` becomes two marks over two tables), so a compiled table cannot be
* traced back to a single authored view. We therefore enumerate by drawn table, not
* by authored view — one consequence being that a point-overlay shows as two nearly
* identical tables (the line's and the point's), which is literally what is drawn.
*
* For each drawn table we report two ends, mirroring the inspector's Input |
* Resolved toggle: `resolved` is the table the marks draw (after the view's
* transforms), and `input` is its most-upstream source (before them), found by
* following each table's `source` link — the documented, stable Vega dataflow
* format.
*/
/** A drawn table the user can inspect, with the two ends of its pipeline. */
export interface InspectableView {
/** The compiled dataset the marks draw — the post-transform "Resolved" rows. */
resolved: string;
/** The most-upstream source of `resolved` — the "Input" rows before transforms. */
input: string;
}
interface VgData {
name?: unknown;
source?: unknown;
}
interface VgMark {
from?: { data?: unknown; facet?: { data?: unknown } };
marks?: unknown;
}
function isObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}
/** Vega's compiler-generated table names (`source_0`, `data_2`) — jargon, never shown. */
function isCompilerName(name: string): boolean {
return /^(source|data)_\d+$/.test(name);
}
/**
* A user-facing label for an inspectable view: its input source's name when that
* is user-authored (a named dataset like `sales`), else an ordinal `View N`
* (1-based, by document order). Compiler-generated names never surface — they are
* jargon to the user (council: NN/g #2 "speak the users' language";
* docs/architecture/10). The richer recognition cue — columns + row count — is the
* UI's `detail` line, built from the rows.
*/
export function inspectViewLabel(input: string, index: number): string {
return isCompilerName(input) ? `View ${index + 1}` : input;
}
/** Facet layout helper tables (domains/headers), never a table the user draws into. */
function isLayoutHelper(name: string): boolean {
return name.startsWith('facet_domain');
}
/** The table a mark draws, from `from.data` or a facet cell's `from.facet.data`. */
function drawnTable(mark: VgMark): string | null {
const from = mark.from;
if (!isObject(from)) return null;
if (typeof from.data === 'string') return from.data;
if (isObject(from.facet) && typeof from.facet.data === 'string') return from.facet.data;
return null;
}
/**
* The inspectable drawn tables of a compiled Vega spec, in document order, one per
* distinct table the marks render. Returns `[]` for a spec with no drawable data
* (or a malformed input).
*/
export function inspectableViews(vgSpec: unknown): InspectableView[] {
if (!isObject(vgSpec)) return [];
const dataList: VgData[] = Array.isArray(vgSpec.data) ? (vgSpec.data as VgData[]) : [];
const sourceOf = new Map<string, string | undefined>();
for (const d of dataList) {
if (isObject(d) && typeof d.name === 'string') {
sourceOf.set(d.name, typeof d.source === 'string' ? d.source : undefined);
}
}
// The most-upstream ancestor of `name` via the `source` chain (cycle-guarded).
const rootSourceOf = (name: string): string => {
const seen = new Set<string>();
let current = name;
while (sourceOf.has(current) && !seen.has(current)) {
const next = sourceOf.get(current);
if (next === undefined) break;
seen.add(current);
current = next;
}
return current;
};
const views: InspectableView[] = [];
const seen = new Set<string>();
const visit = (node: unknown): void => {
if (Array.isArray(node)) {
for (const item of node) visit(item);
return;
}
if (!isObject(node)) return;
const mark = node as VgMark;
const table = drawnTable(mark);
if (table !== null && sourceOf.has(table) && !isLayoutHelper(table) && !seen.has(table)) {
seen.add(table);
views.push({ resolved: table, input: rootSourceOf(table) });
}
if (Array.isArray(mark.marks)) visit(mark.marks);
};
visit(vgSpec.marks);
return views;
}
-56
View File
@@ -1,56 +0,0 @@
import { describe, expect, it } from 'vitest';
import { pickResultDataset, pickSourceDataset } from './result-data';
describe('pickResultDataset', () => {
it('prefers the most-downstream transform output over the source', () => {
expect(pickResultDataset(['source_0', 'data_0', 'marks', 'root'])).toBe('data_0');
});
it('picks the highest data_<n> numerically, not lexically', () => {
expect(pickResultDataset(['source_0', 'data_0', 'data_1', 'data_2'])).toBe('data_2');
// 10 must beat 2 — guards against string ordering ('data_10' < 'data_2').
expect(pickResultDataset(['data_2', 'data_10', 'data_9'])).toBe('data_10');
});
it('falls back to the most-downstream source when there is no data_<n>', () => {
expect(pickResultDataset(['source_0', 'source_1', 'marks'])).toBe('source_1');
});
it('ignores dataflow internals when choosing', () => {
expect(pickResultDataset(['root', 'marks', 'layout', 'cell', 'source_0'])).toBe('source_0');
expect(pickResultDataset(['brush_store', '_facet', 'a:b', 'data_0'])).toBe('data_0');
});
it('returns a remaining non-internal table when no source/data convention matches', () => {
expect(pickResultDataset(['root', 'marks', 'my_named_source'])).toBe('my_named_source');
});
it('returns null when nothing is inspectable', () => {
expect(pickResultDataset([])).toBeNull();
expect(pickResultDataset(['root', 'marks', 'layout', 'brush_store'])).toBeNull();
});
});
describe('pickSourceDataset', () => {
it('prefers the most-upstream source — the input before transforms', () => {
expect(pickSourceDataset(['source_0', 'source_1', 'data_0', 'data_1'])).toBe('source_0');
});
it('picks the lowest source_<n> numerically', () => {
expect(pickSourceDataset(['source_2', 'source_10', 'source_1'])).toBe('source_1');
});
it('falls back to the earliest transform stage when there is no source_<n>', () => {
expect(pickSourceDataset(['data_0', 'data_1', 'marks'])).toBe('data_0');
});
it('equals the result when a spec has no transforms (only a source)', () => {
const names = ['source_0', 'marks', 'root'];
expect(pickSourceDataset(names)).toBe('source_0');
expect(pickResultDataset(names)).toBe('source_0');
});
it('returns null when nothing is inspectable', () => {
expect(pickSourceDataset(['root', 'marks', 'brush_store'])).toBeNull();
});
});
-95
View File
@@ -1,95 +0,0 @@
/**
* Picking the input and resolved datasets from a rendered Vega view (the data
* inspector — spec §04; arch 05 → "the data inspector rides the boundary").
*
* A compiled Vega dataflow holds many named datasets. Vega-Lite names the ones a
* spec produces by convention: `source_<n>` for each parsed data source, and
* `data_<n>` for each transform/aggregation stage. So a single view carries both
* ends of the pipeline:
*
* - the **input** is the most-upstream source (`source_0`) — the parsed rows
* before the spec's transforms;
* - the **resolved** output is the most-downstream `data_<n>` — the rows the marks
* of a single-view chart actually draw.
*
* Everything else in the dataflow (`root`, `marks`, layout/scale tables, selection
* `*_store`s, `_`-prefixed internals, faceted `a:b` names) is plumbing the user
* never authored. A spec with no transforms has only a source, so input and
* resolved resolve to the same table — correct: no transforms means output = input.
*
* This is the pure half of the inspector — given the view's dataset names, choose
* one per direction. The view access itself (listing names, reading rows) lives
* behind the renderer's `RenderHandle` boundary (services/chart-renderer; arch 05).
*
* Limitation: a single name can't represent a multi-view spec (layer/concat/facet
* produce several `source_<n>` / `data_<n>`, one per view). We return the
* most-upstream source and most-downstream output — both real, drawn tables — and
* leave a full dataset selector as a future option.
*/
/** Datasets that are dataflow plumbing, never a table the user would inspect. */
function isInternalDataset(name: string): boolean {
return (
name === 'root' ||
name === 'marks' ||
name === 'layout' ||
name === 'cell' ||
name.startsWith('_') ||
name.endsWith('_store') || // selection tuple stores
name.includes(':') // faceted / cross-context child sources
);
}
/** The trailing index of a `prefix_<n>` name (e.g. `data_12` → 12), or null. */
function suffixIndex(name: string, prefix: string): number | null {
if (!name.startsWith(`${prefix}_`)) return null;
const n = Number(name.slice(prefix.length + 1));
return Number.isInteger(n) ? n : null;
}
/** The `prefix_<n>` name with the highest (`'max'`) or lowest (`'min'`) suffix. */
function bySuffix(names: readonly string[], prefix: string, end: 'min' | 'max'): string | null {
let best: string | null = null;
let bestN = end === 'max' ? -Infinity : Infinity;
for (const name of names) {
const n = suffixIndex(name, prefix);
if (n === null) continue;
if (end === 'max' ? n > bestN : n < bestN) {
bestN = n;
best = name;
}
}
return best;
}
/**
* Choose the compiled dataset representing the rows the chart draws, given all of
* the view's dataset names. Prefers the most-downstream transform output
* (`data_<max n>`), then the most-downstream source (`source_<max n>`), then any
* remaining non-internal table. Returns null when nothing is inspectable.
*/
export function pickResultDataset(names: readonly string[]): string | null {
const candidates = names.filter((n) => !isInternalDataset(n));
return (
bySuffix(candidates, 'data', 'max') ??
bySuffix(candidates, 'source', 'max') ??
candidates[0] ??
null
);
}
/**
* Choose the compiled dataset representing the chart's input — the parsed rows
* before the spec's transforms. Prefers the most-upstream source (`source_<min n>`),
* then the earliest transform stage (`data_<min n>`) for the rare source-less spec,
* then any remaining non-internal table. Returns null when nothing is inspectable.
*/
export function pickSourceDataset(names: readonly string[]): string | null {
const candidates = names.filter((n) => !isInternalDataset(n));
return (
bySuffix(candidates, 'source', 'min') ??
bySuffix(candidates, 'data', 'min') ??
candidates[0] ??
null
);
}