mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Inspector: multi-view data inspection with a per-table view picker
This commit is contained in:
@@ -32,12 +32,38 @@ afterEach(() => {
|
||||
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 viewButton = (label: string) =>
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>('[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<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]> = {}) =>
|
||||
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<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', () => {
|
||||
|
||||
@@ -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<SegmentedOption<DataView>> = [
|
||||
// `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<DataView>('resolved');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(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 (
|
||||
<div
|
||||
@@ -111,8 +139,21 @@ export function DataInspectorPanel({
|
||||
</button>
|
||||
{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
|
||||
label="Data view"
|
||||
label="Pipeline stage"
|
||||
options={VIEW_OPTIONS}
|
||||
value={view}
|
||||
onChange={setView}
|
||||
@@ -127,12 +168,14 @@ export function DataInspectorPanel({
|
||||
</div>
|
||||
|
||||
{open &&
|
||||
(rows === null ? (
|
||||
(data === null ? (
|
||||
<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 ? (
|
||||
<p className={styles.stateNote}>
|
||||
{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.'}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -27,10 +27,6 @@ const H = vi.hoisted(() => ({
|
||||
pending: [] as Array<() => void>,
|
||||
destroyed: [] as number[],
|
||||
configs: [] as unknown[],
|
||||
inspected: null as {
|
||||
input: ReadonlyArray<Record<string, unknown>>;
|
||||
resolved: ReadonlyArray<Record<string, unknown>>;
|
||||
} | 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<FontAsset>;
|
||||
}
|
||||
|
||||
/** 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<Record<string, unknown>>;
|
||||
/** 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<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 {
|
||||
/** 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<string, unknown> };
|
||||
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<Record<string, unknown>> =>
|
||||
name === null ? [] : ((result.view.data(name) ?? []) as Record<string, unknown>[]);
|
||||
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<Record<string, unknown>> =>
|
||||
(result.view.data(name) ?? []) as Record<string, unknown>[];
|
||||
const tables = views.map((v, i) => ({
|
||||
id: v.resolved,
|
||||
label: inspectViewLabel(v.input, i),
|
||||
input: rows(v.input),
|
||||
resolved: rows(v.resolved),
|
||||
}));
|
||||
return { tables };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user