Files
astrolabe/src/app/components/DataInspector.tsx
T

225 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<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 views transforms' },
{
value: 'resolved',
label: 'Resolved',
title: 'Resolved — the rows the marks draw, after the views transforms',
},
];
/** A recognition cue for the view picker: row count + the first few columns. */
function tableDetail(table: InspectableTable): string {
const rows = table.resolved;
const count = `${rows.length.toLocaleString()} ${rows.length === 1 ? 'row' : 'rows'}`;
const columns = rows[0] ? Object.keys(rows[0]) : [];
if (columns.length === 0) return count;
const shown = columns.slice(0, DETAIL_COLUMNS).join(', ');
const more = columns.length > DETAIL_COLUMNS ? `, +${columns.length - DETAIL_COLUMNS}` : '';
return `${count} · ${shown}${more}`;
}
interface DataInspectorPanelProps {
/** 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<DataView>('resolved');
const [selectedId, setSelectedId] = useState<string | null>(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 (
<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 && (
<>
{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="Pipeline stage"
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 &&
(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 views 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}
/>
);
}