mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
225 lines
8.8 KiB
TypeScript
225 lines
8.8 KiB
TypeScript
/**
|
||
* 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 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<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 view’s 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}
|
||
/>
|
||
);
|
||
}
|