Data inspector: input/resolved rows below the chart, with a resizable divider

A collapsible Data panel under the Live Preview and Chart Builder charts shows the rows the chart actually uses, switching between Input (parsed source) and Resolved (post-transform) views read from the live Vega view. Collapsed by default; the open-state and a draggable height divider persist.

Rows come through a new RenderHandle.inspectData() accessor, so no component touches the view: core/result-data picks the most-upstream source and most-downstream result from the compiled dataflow, read lazily. The divider reuses the window-splitter pattern (horizontal variant).

Consolidations: a shared DataTable primitive replaces the inspector's and the builder's duplicate read-only tables; useResizeDrag merges the col/row drag-gesture twins.

Docs: spec 04/06 and arch 05/10 updated; the now-shipped exploration memo removed.
This commit is contained in:
2026-06-18 02:22:03 +03:00
parent 223646398e
commit efb5a9bbe0
35 changed files with 1210 additions and 184 deletions
+176
View File
@@ -0,0 +1,176 @@
/**
* Data inspector — input vs. resolved rows (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).
*
* `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.
*/
import { useMemo, useState } from 'react';
import type { InspectedData } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore';
import { DataTable } from './DataTable';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import styles from './DataInspector.module.css';
/** Rows shown before truncating — matches the builder's source-preview cap (spec §06). */
const ROW_LIMIT = 50;
type DataView = 'input' | 'resolved';
/** The two views, 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 specs transforms' },
{
value: 'resolved',
label: 'Resolved',
title: 'Resolved — the rows the chart draws, after its transforms',
},
];
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;
/** Bumps whenever a render settles, so the open table re-reads the new rows. */
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<DataView>('resolved');
// 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.
// eslint-disable-next-line react-hooks/exhaustive-deps
const data = useMemo(() => (open ? getData() : null), [open, renderEpoch, getData]);
const rows = data === null ? null : data[view];
return (
<div
id={id}
className={`${styles.inspector} ${heightPx !== undefined ? styles.fill : ''}`}
style={heightPx !== undefined ? { height: heightPx } : undefined}
>
<div className={styles.bar}>
<button
type="button"
className={styles.toggle}
aria-expanded={open}
onClick={() => onToggle(!open)}
>
<span className={styles.caret} aria-hidden="true">
{open ? '▾' : '▸'}
</span>
Data
</button>
{open && (
<>
<SegmentedControl
label="Data view"
options={VIEW_OPTIONS}
value={view}
onChange={setView}
/>
{rows !== null && rows.length > 0 && (
<span className={styles.meta}>
{rows.length.toLocaleString()} {rows.length === 1 ? 'row' : 'rows'}
</span>
)}
</>
)}
</div>
{open &&
(rows === null ? (
<p className={styles.stateNote}>Render a chart to inspect its data.</p>
) : rows.length === 0 ? (
<p className={styles.stateNote}>
{view === 'resolved'
? 'No rows — the specs filters or transforms left nothing to draw.'
: 'The source data has no rows.'}
</p>
) : (
<DataTable
columns={Object.keys(rows[0])}
rows={rows.slice(0, ROW_LIMIT)}
total={rows.length}
ariaLabel="Data rows"
fill={heightPx !== undefined}
/>
))}
</div>
);
}
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 (
<DataInspectorPanel
open={open}
onToggle={setOpen}
getData={getData}
renderEpoch={renderEpoch}
heightPx={heightPx}
id={id}
/>
);
}