diff --git a/docs/architecture/05-rendering-theming-preview.md b/docs/architecture/05-rendering-theming-preview.md index 432fd4e..b83eff8 100644 --- a/docs/architecture/05-rendering-theming-preview.md +++ b/docs/architecture/05-rendering-theming-preview.md @@ -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 (the Live Preview and Chart Builder panel showing the chart's input -vs. resolved rows — spec §04) reads runtime rows through the handle, never the raw view: -`RenderHandle.inspectData()` returns the input + resolved tables (`{ input, resolved }`, -or `null` when no chart is up), wrapping the view exactly like `toImageURL`. It works in -two layers: +The data inspector (the Live Preview and Chart Builder panel showing each drawn table's +input vs. resolved rows — spec §04) reads runtime rows through the handle, never the raw +view: `RenderHandle.inspectData()` returns the inspectable tables (`{ tables }`, or `null` +when no chart is up), wrapping the view exactly like `toImageURL`. It works in two layers: -- **Enumerate + pick (`view.getState` + `core/result-data`).** A compiled Vega dataflow - holds many named datasets; Vega-Lite names them by convention — `source_` per parsed - source, `data_` per transform stage. The pure `pickSourceDataset` / `pickResultDataset` - choose the **most-upstream source** (the input) and **most-downstream output** (what the - marks draw), skipping dataflow internals (`marks`, `root`, layout, selection `*_store`s). - A spec with no transforms resolves both to the same table. The picking is pure (in `core`, - unit-tested); only the enumeration touches the view. -- **Read lazily.** `getState` serializes the datasets it lists, so it is **only called while - the panel is open** — a collapsed inspector costs nothing, which is why the panel reads on - demand rather than on every render. - -Limitation: one name per direction can't represent a multi-view spec (layer/concat/facet -produce several `data_`); the most-downstream/upstream ones are returned, and a full -dataset selector is left as a future option. +- **Enumerate from the compiled spec (`core/inspect-views`).** A composed spec draws + several tables; `inspectableViews` walks the compiled Vega spec — the marks tree's + `from.data` (what each mark draws) and `data[].source` (the lineage, the documented Vega + format) — to list, in document order, one entry per **distinct drawn table** with its + `resolved` (post-transform, what the marks draw) and `input` (most-upstream source) ends. + Enumerating by drawn table, not by authored view, is forced by Vega-Lite desugaring (a + `point: true` line compiles to two layers — a compiled table can't be traced back to one + authored view). Selection `*_store`s and `facet_domain*` layout tables aren't drawn, so + they fall out for free. The walk is pure (in `core`, unit-tested); the boundary reads each + table's rows via `view.data(name)`. +- **Read lazily.** Reading serializes rows, so it happens **only while the panel is open** — + 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.) --- diff --git a/docs/exploration/multi-view-data-model-scope.md b/docs/exploration/multi-view-data-model-scope.md index 37f7d4a..c61464c 100644 --- a/docs/exploration/multi-view-data-model-scope.md +++ b/docs/exploration/multi-view-data-model-scope.md @@ -39,8 +39,8 @@ collection (`spec-fields`), config baking (`spec-config`), standalone export view's columns in a composition whose views bind different datasets. - **Extract inline data** (`app/stores/ExtractStore`) lifts only the top-level `data` block. -- **Data inspector** (`core/result-data`, `DataInspector`) surfaces one input + - one resolved table; a composition produces several `source_`/`data_`. +- **Data inspector** (`DataInspector`) surfaces one input + one resolved table; a + composition produces several `source_`/`data_`. - **`deriveSnippetName`** (`core/snippet`) reads top-level `mark`/`encoding` only, 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 @@ -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 `datasets`, url/generator → none); the three Monaco providers and the facet/repeat defaults pass the cursor offset. -- **M4 — multi-view inspection** (pulled ahead of M3) — group the dataflow's - datasets into per-view input/resolved pairs (`result-data`) and add a view - selector to `DataInspector` (new interactive widget → `/council`). The data - inspector executes the live pipeline, so it is the place to review post-transform - rows (melt/fold/pivot/aggregate); today it surfaces one heuristic input/resolved - pair across the whole composition, with no per-view choice. +- **M4 — multi-view inspection** ✅ (pulled ahead of M3) — `core/inspect-views` + enumerates the distinct tables the marks draw (from the compiled Vega spec's + `from.data` + `data[].source` lineage), each with its input + resolved ends; + `RenderHandle.inspectData()` returns those tables with rows; `DataInspector` adds + a `SelectControl` view picker (hidden for the single-table case), labels via + `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 (reusing the cursor-scope machinery) and rewrite that view's `data`. diff --git a/src/app/components/DataInspector.test.tsx b/src/app/components/DataInspector.test.tsx index 85b93c7..68d5142 100644 --- a/src/app/components/DataInspector.test.tsx +++ b/src/app/components/DataInspector.test.tsx @@ -32,12 +32,38 @@ afterEach(() => { vi.clearAllMocks(); }); -const toggle = () => container.querySelector('button[aria-expanded]')!; +const toggle = () => + Array.from(container.querySelectorAll('button[aria-expanded]')).find((b) => + b.textContent?.includes('Data'), + )!; const text = () => container.textContent ?? ''; const viewButton = (label: string) => Array.from(container.querySelectorAll('[role="radio"]')).find( (b) => b.textContent === label, )!; +/** The view picker's trigger, or null when it is not shown (single-table case). */ +const picker = () => container.querySelector('[aria-label^="Inspected view"]'); + +/** Build the inspector payload — one table per (label, input, resolved) entry. */ +const tablesOf = ( + ...entries: Array<{ + label: string; + input?: Record[]; + resolved: Record[]; + }> +): 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[], + resolved: Record[], +): InspectedData => tablesOf({ label: 'View 1', input, resolved }); const render = (props: Partial[0]> = {}) => act(() => { @@ -68,26 +94,33 @@ describe('DataInspectorPanel', () => { 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', () => { - const data: InspectedData = { - input: [{ region: 'West', sales: '1204' }], - resolved: [{ region: 'West', total: 1204 }], - }; - render({ getData: () => data }); + render({ + getData: () => + oneTable([{ region: 'West', sales: '1204' }], [{ region: 'West', total: 1204 }]), + }); // Resolved is the default — its column ("total"), not the input's ("sales"). const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent); expect(headers).toEqual(['region', 'total']); }); test('switching to Input shows the source rows', () => { - const data: InspectedData = { - input: [ - { region: 'West', sales: '1204' }, - { region: 'East', sales: '980' }, - ], - resolved: [{ region: 'West', total: 1204 }], - }; - render({ getData: () => data }); + render({ + getData: () => + oneTable( + [ + { region: 'West', sales: '1204' }, + { region: 'East', sales: '980' }, + ], + [{ region: 'West', total: 1204 }], + ), + }); act(() => viewButton('Input').click()); const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent); expect(headers).toEqual(['region', 'sales']); @@ -96,25 +129,25 @@ describe('DataInspectorPanel', () => { }); 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'); }); test('input empty: names the empty source', () => { - render({ getData: () => ({ input: [], resolved: [] }) }); + render({ getData: () => oneTable([], []) }); act(() => viewButton('Input').click()); expect(text()).toContain('source data has no rows'); }); test('caps the table and reports the total', () => { const resolved = Array.from({ length: 120 }, (_, i) => ({ i })); - render({ getData: () => ({ input: [], resolved }) }); + render({ getData: () => oneTable([], resolved) }); expect(container.querySelectorAll('tbody tr')).toHaveLength(50); expect(text()).toContain('first 50 of 120'); }); 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 }); const before = getData.mock.calls.length; render({ getData, renderEpoch: 1 }); @@ -122,7 +155,7 @@ describe('DataInspectorPanel', () => { }); 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; expect(panel.style.height).toBe('240px'); }); @@ -133,6 +166,44 @@ describe('DataInspectorPanel', () => { act(() => toggle().click()); 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('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', () => { diff --git a/src/app/components/DataInspector.tsx b/src/app/components/DataInspector.tsx index a2bcf4f..2f20b16 100644 --- a/src/app/components/DataInspector.tsx +++ b/src/app/components/DataInspector.tsx @@ -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 - * view: **Input** (the parsed source rows, before the spec's transforms) and - * **Resolved** (the rows the chart draws, after filters / calculated fields / - * aggregation). Seeing input → output side by side is how you answer "why is my - * chart empty/wrong" — look at what the transforms did to the data. Both tables - * 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 - * embedding boundary, arch 05). + * Shows the chart's data with a toggle between two ends of a table's pipeline: + * **Input** (the parsed source rows, before the view's transforms) and + * **Resolved** (the rows the marks draw, after filters / calculated fields / + * aggregation). Seeing input → output is how you answer "why is my chart + * empty/wrong" — look at what the transforms did to the data. + * + * A composed spec (layer/concat/facet/repeat) draws several tables, so a **view + * 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 * 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 — - * only while expanded — because listing the view's datasets serializes them (see - * `RenderHandle.inspectData`), so a collapsed inspector costs nothing. + * preview-pane open state for the live-preview pane. */ 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 { DataTable } from './DataTable'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; +import { SelectControl, type SelectControlOption } from './SelectControl'; import styles from './DataInspector.module.css'; /** Rows shown before truncating — matches the builder's source-preview cap (spec §06). */ const ROW_LIMIT = 50; +/** Columns named in a table's picker `detail` before eliding the rest. */ +const DETAIL_COLUMNS = 4; 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> = [ // `title` doubles as the accessible name (WCAG 2.5.3 label-in-name; leads with // the visible label). - { value: 'input', label: 'Input', title: 'Input — the source rows before the spec’s transforms' }, + { value: 'input', label: 'Input', title: 'Input — the source rows before the view’s transforms' }, { value: 'resolved', label: 'Resolved', - title: 'Resolved — the rows the chart draws, after its transforms', + title: 'Resolved — the rows the marks draw, after the view’s 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 { /** Whether the panel is expanded. */ open: boolean; @@ -83,13 +104,20 @@ export function DataInspectorPanel({ id, }: DataInspectorPanelProps) { const [view, setView] = useState('resolved'); + const [selectedId, setSelectedId] = useState(null); - // Read both tables only while open; re-read when a render settles. `renderEpoch` - // is an intentional refresh trigger — not read in the body (getData is stable and - // always reads the latest view), so exhaustive-deps sees it as unnecessary. + // Read tables only while open; re-read when a render settles. `renderEpoch` is an + // intentional refresh trigger — not read in the body (getData is stable and always + // reads the latest view), so exhaustive-deps sees it as unnecessary. // eslint-disable-next-line react-hooks/exhaustive-deps 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 (
{open && ( <> + {tables && tables.length > 1 && table && ( + >((t) => ({ + value: t.id, + label: t.label, + detail: tableDetail(t), + }))} + /> + )} {open && - (rows === null ? ( + (data === null ? (

Render a chart to inspect its data.

+ ) : table === null || rows === null ? ( +

This chart has no inspectable data.

) : rows.length === 0 ? (

{view === 'resolved' - ? 'No rows — the spec’s filters or transforms left nothing to draw.' + ? 'No rows — the view’s filters or transforms left nothing to draw.' : 'The source data has no rows.'}

) : ( diff --git a/src/app/components/LivePreview.test.tsx b/src/app/components/LivePreview.test.tsx index 35551d9..be75603 100644 --- a/src/app/components/LivePreview.test.tsx +++ b/src/app/components/LivePreview.test.tsx @@ -27,10 +27,6 @@ const H = vi.hoisted(() => ({ pending: [] as Array<() => void>, destroyed: [] as number[], configs: [] as unknown[], - inspected: null as { - input: ReadonlyArray>; - resolved: ReadonlyArray>; - } | null, })); vi.mock('../services/chart-renderer', () => ({ renderSpec: (node: HTMLElement, _spec: unknown, config: unknown) => { @@ -48,7 +44,7 @@ vi.mock('../services/chart-renderer', () => ({ H.destroyed.push(id); }, resize() {}, - inspectData: () => H.inspected, + inspectData: () => null, }); }); }); diff --git a/src/app/services/chart-renderer.ts b/src/app/services/chart-renderer.ts index f5a4108..3152f9e 100644 --- a/src/app/services/chart-renderer.ts +++ b/src/app/services/chart-renderer.ts @@ -12,7 +12,7 @@ import type { VisualizationSpec } from 'vega-embed'; import type { Config } from 'vega-lite'; import { collectFontFamilies } from '@core/custom-theme'; 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'; /** Options for `RenderHandle.toImageURL` (spec §08 → Per-chart export). */ @@ -44,15 +44,28 @@ interface ImageExportOptions { embedFonts?: ReadonlyArray; } -/** The two ends of the chart's data pipeline, for the data inspector (spec §04). */ -export interface InspectedData { - /** Parsed source rows, before the spec's transforms run — the input. */ +/** One inspectable drawn table — the two ends of its pipeline (spec §04). */ +export interface InspectableTable { + /** 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>; - /** Post-transform rows the chart draws — the output (equals `input` when the - * spec has no transforms). */ + /** Post-transform rows the marks draw — the output (equals `input` when the view + * has no transforms). */ resolved: ReadonlyArray>; } +/** + * 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 { /** Finalize the underlying Vega view and clear the node. */ destroy(): void; @@ -78,18 +91,17 @@ export interface RenderHandle { */ resize(): void; /** - * The chart's input and resolved (post-transform) rows — for the data inspector - * (spec §04). Reads the live view's compiled dataflow once: lists its datasets, - * picks the most-upstream - * source and most-downstream result (`@core/result-data`), and returns both - * tables' rows. This is the one place besides export that reaches into the view, - * so the embedding boundary holds (arch 05 §1–§2) — callers get rows, never the - * `view`. + * The chart's inspectable tables — for the data inspector (spec §04). Enumerates + * the tables the marks draw from the compiled Vega spec (`@core/inspect-views`), + * and for each reads its input + resolved rows from the live view. A multi-view + * spec yields several tables; a unit spec yields one. This is the one place + * besides export that reaches into the view, so the embedding boundary holds + * (arch 05 §1–§2) — callers get rows, never the `view`. * - * Returns `null` when there is nothing to inspect (the view was finalized, or - * the spec produced no inspectable table). Either side can be `[]` when its - * table is empty — a real signal (e.g. a filter removed every row on the - * resolved side), kept distinct from "no chart" so the inspector can say which. + * Returns `null` when the view was finalized (no chart). The result's `tables` + * is empty when a chart draws nothing inspectable, and any table's `input`/ + * `resolved` can be `[]` (e.g. a filter removed every row) — kept distinct from + * "no chart" so the inspector can say which. */ inspectData(): InspectedData | null; } @@ -297,22 +309,19 @@ export async function renderSpec( }, inspectData() { if (finalized) return null; - // Enumerate the dataflow's datasets once, then pick the input + result - // tables. getState with a truthy `data` filter is Vega's documented way to - // list datasets (vega/editor's Data Viewer does the same) — we read only the - // keys. Rows come from view.data(name), which hands back the live array (no copy). - const state = result.view.getState({ - data: () => true, - signals: () => false, - recurse: true, - }) as { data?: Record }; - const names = Object.keys(state.data ?? {}); - const sourceName = pickSourceDataset(names); - const resultName = pickResultDataset(names); - if (sourceName === null && resultName === null) return null; - const rows = (name: string | null): ReadonlyArray> => - name === null ? [] : ((result.view.data(name) ?? []) as Record[]); - return { input: rows(sourceName), resolved: rows(resultName) }; + // The tables the marks draw + their input lineage come from the compiled Vega + // spec (a byproduct of the embed, not recompiled); the rows come from + // view.data(name), which hands back the live array (no copy). + const views = inspectableViews(result.vgSpec); + const rows = (name: string): ReadonlyArray> => + (result.view.data(name) ?? []) as Record[]; + const tables = views.map((v, i) => ({ + id: v.resolved, + label: inspectViewLabel(v.input, i), + input: rows(v.input), + resolved: rows(v.resolved), + })); + return { tables }; }, }; } diff --git a/src/core/inspect-views.test.ts b/src/core/inspect-views.test.ts new file mode 100644 index 0000000..7d0b3b7 --- /dev/null +++ b/src/core/inspect-views.test.ts @@ -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 + }); +}); diff --git a/src/core/inspect-views.ts b/src/core/inspect-views.ts new file mode 100644 index 0000000..3be9da7 --- /dev/null +++ b/src/core/inspect-views.ts @@ -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 { + 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(); + 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(); + 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(); + 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; +} diff --git a/src/core/result-data.test.ts b/src/core/result-data.test.ts deleted file mode 100644 index 05b4077..0000000 --- a/src/core/result-data.test.ts +++ /dev/null @@ -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_ 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_', () => { - 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_ 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_', () => { - 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(); - }); -}); diff --git a/src/core/result-data.ts b/src/core/result-data.ts deleted file mode 100644 index 7284934..0000000 --- a/src/core/result-data.ts +++ /dev/null @@ -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_` for each parsed data source, and - * `data_` 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_` — 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_` / `data_`, 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_` 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_` 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_`), then the most-downstream source (`source_`), 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_`), - * then the earliest transform stage (`data_`) 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 - ); -}