/** * Data inspector — input vs. resolved rows, per drawn table (spec §04). * * 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. */ import { useMemo, useState } from 'react'; 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 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 view’s transforms' }, { value: 'resolved', label: 'Resolved', 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; /** Toggle the expanded state. */ onToggle: (open: boolean) => void; /** * Reads the input + resolved rows from the live view, or null when there is no * chart to inspect. Must be stable across renders (the read is memoized on * `renderEpoch`). */ getData: () => InspectedData | null; /** * Refresh trigger: bumps whenever the data to show may have changed, so the open * table re-reads. The live-preview pane bumps it on each settled render *and* on * an interactive selection that changes the inspected rows (live mode, M5); the * builder bumps it on render only. */ renderEpoch: number; /** * Explicit panel height (px) — the live-preview pane sets this from its * resizable divider so the table fills the allotted space. Omitted (the builder) * leaves the table at its default capped height. */ heightPx?: number; /** id of the panel root, for a splitter's `aria-controls` to point at. */ id?: string; } /** * The reusable disclosure: a toggle bar (APG disclosure — `aria-expanded`, * conditional content, matching the builder's source-rows preview) over an * Input | Resolved view switch and the chosen table. Expanded states, each named * (council: GOV.UK / NN/g — say what happened and the next step): * - no live chart → guidance to render one; * - the chosen view's table is empty → say which side and why (the resolved side's * transforms produced nothing; the input side's source is empty); * - rows → the grid. */ export function DataInspectorPanel({ open, onToggle, getData, renderEpoch, heightPx, id, }: DataInspectorPanelProps) { const [view, setView] = useState('resolved'); const [selectedId, setSelectedId] = useState(null); // 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 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), }))} /> )} {rows !== null && rows.length > 0 && ( {rows.length.toLocaleString()} {rows.length === 1 ? 'row' : 'rows'} )} )}
{open && (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 view’s filters or transforms left nothing to draw.' : 'The source data has no rows.'}

) : ( ))}
); } interface DataInspectorProps { /** Reads the input + resolved rows from the live preview's view (stable). */ getData: () => InspectedData | null; /** Bumps on each settled render so the open table refreshes. */ renderEpoch: number; /** Panel height (px) from the preview pane's resizable divider (only when open). */ heightPx?: number; /** id of the panel root, for the divider's `aria-controls`. */ id?: string; } /** The live-preview data inspector: the panel bound to the persisted open state. */ export function DataInspector({ getData, renderEpoch, heightPx, id }: DataInspectorProps) { const open = useAppStore((s) => s.dataInspectorOpen); const setOpen = useAppStore((s) => s.setDataInspectorOpen); return ( ); }