mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
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:
@@ -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 spec’s 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 spec’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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user