Files
astrolabe/src/app/components/DataInspector.tsx
T
oleh efb5a9bbe0 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.
2026-06-18 02:22:03 +03:00

177 lines
6.5 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 (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}
/>
);
}