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
@@ -355,44 +355,8 @@
color: var(--text-placeholder);
}
.previewTableWrap {
max-height: 200px;
overflow: auto;
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
}
.previewTableWrap:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -1px;
}
.previewTable {
border-collapse: collapse;
width: 100%;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.4;
}
.previewTable th,
.previewTable td {
text-align: left;
padding: var(--space-1) var(--space-2);
border-bottom: var(--border-width) solid var(--border);
white-space: nowrap;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
}
.previewTable thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--layer-01);
}
/* The per-column type chip in the source-preview header (the table structure and
cell styling now live in the shared DataTable). */
.previewColName {
font-weight: 600;
color: var(--text-secondary);
@@ -403,10 +367,6 @@
font-weight: 400;
}
.previewTable tbody tr:last-child td {
border-bottom: none;
}
.previewEmptyNote {
margin: 0;
font-size: 11px;
@@ -24,7 +24,9 @@ vi.mock('../services/chart-renderer', () => {
}
}
return {
renderSpec: vi.fn(() => Promise.resolve({ destroy() {}, resize() {} })),
renderSpec: vi.fn(() =>
Promise.resolve({ destroy() {}, resize() {}, inspectData: () => null }),
),
ChartTooLargeError,
};
});
+41 -36
View File
@@ -18,7 +18,7 @@
* `LivePreview`, which is bound to the snippet editor's stores.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useShallow } from 'zustand/react/shallow';
import type { VisualizationSpec } from 'vega-embed';
import {
@@ -60,7 +60,7 @@ import {
type TimeUnit,
} from '@core/chart-builder';
import { referencedFields, validateExpression } from '@core/expr-validate';
import { cellText, tabularRows } from '@core/dataset';
import { tabularRows } from '@core/dataset';
import type { ColumnType } from '@core/type-inference';
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
import { chartConfigFor } from '@core/vega-themes';
@@ -77,6 +77,8 @@ import {
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { Button } from './Button';
import { ColorField } from './ColorField';
import { DataInspectorPanel } from './DataInspector';
import { DataTable } from './DataTable';
import { IconButton } from './IconButton';
import { SelectControl } from './SelectControl';
import { Icon } from './Icon';
@@ -959,43 +961,21 @@ function DataPreview() {
{open &&
(rows ? (
<div
className={styles.previewTableWrap}
tabIndex={0}
role="group"
aria-label="Data preview"
>
<table className={styles.previewTable}>
<thead>
<tr>
{baseColumns.columns.map((col) => (
<th key={col} scope="col">
<span className={styles.previewColName}>{col}</span>{' '}
<span className={styles.previewColType}>{typeBadge(typeOf(col))}</span>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri}>
{baseColumns.columns.map((col) => (
<td key={col}>{cellText(row[col])}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<DataTable
columns={baseColumns.columns}
rows={rows}
total={dataset.rowCount ?? undefined}
ariaLabel="Data preview"
renderHeader={(col) => (
<>
<span className={styles.previewColName}>{col}</span>{' '}
<span className={styles.previewColType}>{typeBadge(typeOf(col))}</span>
</>
)}
/>
) : (
<p className={styles.previewEmptyNote}>This dataset has no tabular rows to preview.</p>
))}
{open && rows && dataset.rowCount != null && dataset.rowCount > rows.length && (
<p className={styles.previewEmptyNote}>
Showing the first {rows.length} of {dataset.rowCount.toLocaleString()} rows.
</p>
)}
</div>
);
}
@@ -1083,6 +1063,12 @@ function BuilderPreview() {
// Set when the chart resolves larger than the canvas backend can draw — a
// physical render-size limit, distinct from the readability cardinality warnings.
const [tooLarge, setTooLarge] = useState<{ heightPx: number; limitPx: number } | null>(null);
// Resolved-data disclosure: open state (modal-local, not persisted) + an epoch
// bumped on each settled render so the open table re-reads the post-transform rows
// the builder's filters/calculated fields produce (the output, beside the source
// rows in the config pane's preview — see DataInspector).
const [dataOpen, setDataOpen] = useState(false);
const [renderEpoch, setRenderEpoch] = useState(0);
const specText = useChartBuilderStore(selectBuilderSpecText);
const valid = useChartBuilderStore(selectBuilderValid);
@@ -1104,6 +1090,7 @@ function BuilderPreview() {
handleRef.current = null;
setError(null);
setTooLarge(null);
setRenderEpoch((e) => e + 1);
return;
}
if (!node) return;
@@ -1134,6 +1121,7 @@ function BuilderPreview() {
handleRef.current = handle;
setError(null);
setTooLarge(null);
setRenderEpoch((e) => e + 1);
const t4 = performance.now();
// The browser lays out/paints the (possibly huge) SVG after embed resolves;
// a double rAF lands just after that paint, capturing the freeze the user
@@ -1154,6 +1142,7 @@ function BuilderPreview() {
);
} catch (e) {
if (mine !== generationRef.current) return;
setRenderEpoch((epoch) => epoch + 1);
if (e instanceof ChartTooLargeError) {
// A physical render-size limit (canvas max dimension), not a data error.
setTooLarge({ heightPx: e.heightPx, limitPx: e.limitPx });
@@ -1188,6 +1177,11 @@ function BuilderPreview() {
[],
);
// The input + resolved rows the chart drew (the latter after the builder's
// filters/calculated fields). Reads the live view through the handle; null when
// no chart is up.
const getInspectData = useCallback(() => handleRef.current?.inspectData() ?? null, []);
return (
<div className={styles.previewPane}>
{!valid && (
@@ -1215,6 +1209,17 @@ function BuilderPreview() {
)}
</div>
)}
{/* Data inspector — input vs. resolved rows the chart drew (the latter after
the builder's transforms). Only with a live chart, so it never duplicates
the "map a channel" / error hints above. */}
{valid && tooLarge === null && error === null && (
<DataInspectorPanel
open={dataOpen}
onToggle={setDataOpen}
getData={getInspectData}
renderEpoch={renderEpoch}
/>
)}
</div>
);
}
+6 -1
View File
@@ -16,7 +16,12 @@ import { ThemeBuilderModal } from './ThemeBuilderModal';
vi.mock('../services/chart-renderer', () => ({
renderSpec: vi.fn(() =>
Promise.resolve({ destroy() {}, resize() {}, toImageURL: () => Promise.resolve('') }),
Promise.resolve({
destroy() {},
resize() {},
toImageURL: () => Promise.resolve(''),
inspectData: () => null,
}),
),
}));
@@ -0,0 +1,68 @@
/* Data inspector — the resolved-data disclosure below the chart (spec §04).
Mirrors the builder's source-preview styling (tokens only) so the two read as
one family. In the live preview it is the preview pane's last child, set off
from the chart body by a top border. */
.inspector {
flex: 0 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-2) var(--space-4);
border-top: var(--border-width) solid var(--border);
background: var(--bg);
}
/* Resizable-height mode (live preview): the inspector is given an explicit height
by the pane's divider; the DataTable's own fill mode makes its rows fill the
space left under the bar. */
.inspector.fill {
overflow: hidden;
}
/* The header row: disclosure toggle, the Input|Resolved switch, the row count. */
.bar {
display: flex;
align-items: center;
gap: var(--space-3);
flex: 0 0 auto;
}
.toggle {
display: inline-flex;
align-items: center;
gap: var(--space-2);
border: none;
background: transparent;
color: var(--text-secondary);
font: inherit;
font-size: 12px;
cursor: pointer;
padding: var(--space-1) var(--space-2);
border-radius: var(--radius);
}
.toggle:hover {
background: var(--layer-01);
}
.toggle:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
.caret {
font-size: 10px;
color: var(--text-placeholder);
}
.meta {
color: var(--text-placeholder);
}
.stateNote {
margin: 0;
font-size: 11px;
color: var(--text-secondary);
padding: var(--space-1) var(--space-2);
}
+152
View File
@@ -0,0 +1,152 @@
/**
* Data inspector — input vs. resolved views (spec §04).
*
* `DataInspectorPanel` is prop-driven (rows come via `getData`, not the live
* view), so these cover the behaviour without the render pipeline: the lazy read,
* the Input | Resolved switch, the per-view empty/guidance states, truncation, the
* re-read on `renderEpoch`, and the toggle. `DataInspector` is the thin
* AppStore-bound wrapper.
*/
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { InspectedData } from '../services/chart-renderer';
import { useAppStore } from '../stores/AppStore';
import { DataInspector, DataInspectorPanel } from './DataInspector';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.clearAllMocks();
});
const toggle = () => container.querySelector<HTMLButtonElement>('button[aria-expanded]')!;
const text = () => container.textContent ?? '';
const viewButton = (label: string) =>
Array.from(container.querySelectorAll<HTMLButtonElement>('[role="radio"]')).find(
(b) => b.textContent === label,
)!;
const render = (props: Partial<Parameters<typeof DataInspectorPanel>[0]> = {}) =>
act(() => {
root.render(
<DataInspectorPanel
open
onToggle={() => {}}
getData={() => null}
renderEpoch={0}
{...props}
/>,
);
});
describe('DataInspectorPanel', () => {
test('collapsed by default: no table, no view switch, and getData is not read (lazy)', () => {
const getData = vi.fn(() => null);
render({ open: false, getData });
expect(toggle().getAttribute('aria-expanded')).toBe('false');
expect(container.querySelector('table')).toBeNull();
expect(container.querySelector('[role="radiogroup"]')).toBeNull();
expect(getData).not.toHaveBeenCalled();
});
test('open with no live chart: guidance to render one', () => {
render({ getData: () => null });
expect(text()).toContain('Render a chart');
expect(container.querySelector('table')).toBeNull();
});
test('defaults to the Resolved view and renders its rows', () => {
const data: InspectedData = {
input: [{ region: 'West', sales: '1204' }],
resolved: [{ region: 'West', total: 1204 }],
};
render({ getData: () => data });
// Resolved is the default — its column ("total"), not the input's ("sales").
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
expect(headers).toEqual(['region', 'total']);
});
test('switching to Input shows the source rows', () => {
const data: InspectedData = {
input: [
{ region: 'West', sales: '1204' },
{ region: 'East', sales: '980' },
],
resolved: [{ region: 'West', total: 1204 }],
};
render({ getData: () => data });
act(() => viewButton('Input').click());
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
expect(headers).toEqual(['region', 'sales']);
expect(container.querySelectorAll('tbody tr')).toHaveLength(2);
expect(text()).toContain('2 rows');
});
test('resolved empty: names the empty-transform signal', () => {
render({ getData: () => ({ input: [{ a: 1 }], resolved: [] }) });
expect(text()).toContain('left nothing to draw');
});
test('input empty: names the empty source', () => {
render({ getData: () => ({ input: [], resolved: [] }) });
act(() => viewButton('Input').click());
expect(text()).toContain('source data has no rows');
});
test('caps the table and reports the total', () => {
const resolved = Array.from({ length: 120 }, (_, i) => ({ i }));
render({ getData: () => ({ input: [], resolved }) });
expect(container.querySelectorAll('tbody tr')).toHaveLength(50);
expect(text()).toContain('first 50 of 120');
});
test('re-reads getData when renderEpoch changes', () => {
const getData = vi.fn((): InspectedData => ({ input: [], resolved: [{ a: 1 }] }));
render({ getData, renderEpoch: 0 });
const before = getData.mock.calls.length;
render({ getData, renderEpoch: 1 });
expect(getData.mock.calls.length).toBeGreaterThan(before);
});
test('applies an explicit height when given (resizable mode)', () => {
render({ getData: () => ({ input: [], resolved: [{ a: 1 }] }), heightPx: 240 });
const panel = container.firstElementChild as HTMLElement;
expect(panel.style.height).toBe('240px');
});
test('toggle reports the next open state', () => {
const onToggle = vi.fn();
render({ open: false, onToggle });
act(() => toggle().click());
expect(onToggle).toHaveBeenCalledWith(true);
});
});
describe('DataInspector', () => {
test('binds the panel to the persisted AppStore open state', () => {
act(() => useAppStore.getState().setDataInspectorOpen(false));
act(() => {
root.render(<DataInspector getData={() => null} renderEpoch={0} />);
});
expect(toggle().getAttribute('aria-expanded')).toBe('false');
act(() => useAppStore.getState().setDataInspectorOpen(true));
expect(toggle().getAttribute('aria-expanded')).toBe('true');
act(() => toggle().click());
expect(useAppStore.getState().dataInspectorOpen).toBe(false);
});
});
+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}
/>
);
}
+62
View File
@@ -0,0 +1,62 @@
/* Shared read-only data table — the data inspector and the Chart Builder's
source-row preview both render through this, so the look stays in one place.
Tokens only. */
.wrap {
max-height: 200px;
overflow: auto;
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
}
.wrap:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -1px;
}
/* Resizable-height mode (the live-preview inspector): fill the allotted space
instead of the default cap, so the divider grows the visible table. */
.wrapFill {
flex: 1 1 auto;
max-height: none;
min-height: 0;
}
.table {
border-collapse: collapse;
width: 100%;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.4;
}
.table th,
.table td {
text-align: left;
padding: var(--space-1) var(--space-2);
border-bottom: var(--border-width) solid var(--border);
white-space: nowrap;
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
}
.table thead th {
position: sticky;
top: 0;
z-index: 1;
background: var(--layer-01);
font-weight: 600;
color: var(--text-secondary);
}
.table tbody tr:last-child td {
border-bottom: none;
}
.note {
margin: 0;
font-size: 11px;
color: var(--text-secondary);
padding: var(--space-1) var(--space-2);
}
+68
View File
@@ -0,0 +1,68 @@
/**
* DataTable — a read-only, scrollable table of rows, the shared shape behind the
* data inspector (input/resolved rows) and the Chart Builder's source-row preview.
*
* Columns and any per-column header adornment are the caller's: the builder passes
* its declared column list with a type chip in each header; the inspector passes the
* row keys with plain names. This owns the table structure, the "first N of M" note,
* and the styling, so the two surfaces never drift. Capping is the caller's policy —
* it passes the rows to show plus the true `total`.
*/
import type { ReactNode } from 'react';
import { cellText } from '@core/dataset';
import styles from './DataTable.module.css';
interface DataTableProps {
/** Column names, in display order. Cells read each row by these keys. */
columns: readonly string[];
/** The rows to render — already limited to what should be shown. */
rows: readonly Record<string, unknown>[];
/** The true row count; when it exceeds `rows`, a "first N of M" note is shown. */
total?: number;
/** Custom header content per column (e.g. a type chip); defaults to the name. */
renderHeader?: (column: string) => ReactNode;
/** Accessible name for the scroll region. */
ariaLabel: string;
/** Fill the available height instead of the default capped height (resizable panes). */
fill?: boolean;
}
export function DataTable({ columns, rows, total, renderHeader, ariaLabel, fill }: DataTableProps) {
return (
<>
<div
className={`${styles.wrap} ${fill ? styles.wrapFill : ''}`}
tabIndex={0}
role="group"
aria-label={ariaLabel}
>
<table className={styles.table}>
<thead>
<tr>
{columns.map((col) => (
<th key={col} scope="col">
{renderHeader ? renderHeader(col) : col}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri}>
{columns.map((col) => (
<td key={col}>{cellText(row[col])}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{total != null && total > rows.length && (
<p className={styles.note}>
Showing the first {rows.length} of {total.toLocaleString()} rows.
</p>
)}
</>
);
}
@@ -0,0 +1,36 @@
/*
* Horizontal splitter between the chart and the data inspector. The row-axis
* mirror of ResizeHandle: a 6px-tall full-width hit target with a thin centered
* grip, tracking the design tokens so it reads identically to the pane handles.
*/
.handle {
flex: 0 0 6px;
position: relative;
cursor: row-resize;
background: var(--border);
touch-action: none; /* let pointer drags own the gesture, not scroll */
transition: background var(--dur-fast) var(--ease);
}
.handle:hover,
.handle:focus-visible {
background: var(--accent);
outline: none;
}
/* A short centered grip line (horizontal), so the handle reads as a draggable divider. */
.grip {
position: absolute;
top: 50%;
left: 50%;
width: 24px;
height: 2px;
transform: translate(-50%, -50%);
background: var(--border-strong);
border-radius: var(--radius);
}
.handle:hover .grip,
.handle:focus-visible .grip {
background: var(--accent-contrast);
}
+118
View File
@@ -0,0 +1,118 @@
/**
* Horizontal drag handle between the live-preview chart and the data inspector
* below it (spec §04). The vertical-stacking twin of the pane ResizeHandle:
* dragging resizes the inspector's height while the chart above absorbs the
* change, each kept above its minimum by the pure `clampInspectorHeight`.
*
* The span is read from the live DOM — the chart (previous sibling) and inspector
* (next sibling) — so the divider tracks the pointer 1:1 at any pane height, the
* same technique as PaneSplitHandle.
*
* Accessibility follows WAI-ARIA APG → Window Splitter (docs/architecture/10 §5),
* the same contract as the pane handles with the axis flipped: a focusable
* `separator` with `aria-orientation="horizontal"` reporting the inspector's 0100
* position via `aria-valuenow`, driven by ↑/↓ to nudge and Home/End to jump to
* min/max. Rendered only while the inspector is open (there's nothing to resize
* when it's collapsed).
*/
import { useLayoutEffect, useRef, useState } from 'react';
import { useResizeDrag } from '../hooks/useResizeDrag';
import { clampInspectorHeight, inspectorHeightValue, useAppStore } from '../stores/AppStore';
import styles from './InspectorSplitHandle.module.css';
/** Keyboard nudge step (px) per arrow press — matches ResizeHandle. */
const KEY_STEP = 16;
interface InspectorSplitHandleProps {
/** Accessible label, e.g. "Resize data inspector". */
label: string;
/** id of the inspector region this divider sizes (APG aria-controls). */
controls: string;
}
export function InspectorSplitHandle({ label, controls }: InspectorSplitHandleProps) {
const ref = useRef<HTMLDivElement>(null);
const height = useAppStore((s) => s.dataInspectorHeight);
/** The two regions this handle sits between: chart above, inspector below. */
const flanks = () => ({
chart: ref.current?.previousElementSibling as HTMLElement | null,
inspector: ref.current?.nextElementSibling as HTMLElement | null,
});
// Observe the flanking regions so aria-valuenow stays correct across window and
// pane resizes, not only divider drags.
const [availForBoth, setAvailForBoth] = useState(0);
useLayoutEffect(() => {
const { chart, inspector } = flanks();
if (!chart || !inspector) return;
const measure = () => setAvailForBoth(chart.clientHeight + inspector.clientHeight);
measure();
if (typeof ResizeObserver === 'undefined') return;
const ro = new ResizeObserver(measure);
ro.observe(chart);
ro.observe(inspector);
return () => ro.disconnect();
}, []);
/** Apply a desired inspector height, clamped against the live span. */
const applyHeight = (desired: number) => {
const { chart, inspector } = flanks();
if (!chart || !inspector) return;
const span = chart.clientHeight + inspector.clientHeight;
useAppStore.getState().setDataInspectorHeight(clampInspectorHeight(desired, span));
};
// Drag up (negative delta) grows the inspector into the chart's space.
const onPointerDown = useResizeDrag(
'y',
() => flanks().inspector?.clientHeight ?? height,
(startHeight, delta) => applyHeight(startHeight - delta),
);
// Keyboard per WAI-ARIA APG → Window Splitter: ↑ grows the inspector, ↓ shrinks;
// Home/End jump to its smallest/largest allowed height.
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
const current = flanks().inspector?.clientHeight ?? height;
switch (e.key) {
case 'ArrowUp':
applyHeight(current + KEY_STEP);
break;
case 'ArrowDown':
applyHeight(current - KEY_STEP);
break;
case 'Home': // inspector at its minimum
applyHeight(0);
break;
case 'End': // inspector at its maximum (chart at its minimum)
applyHeight(Number.MAX_SAFE_INTEGER);
break;
default:
return; // not ours — let it bubble
}
e.preventDefault();
};
const valueNow = inspectorHeightValue(height, availForBoth);
return (
<div
ref={ref}
className={styles.handle}
role="separator"
aria-orientation="horizontal"
aria-label={label}
aria-controls={controls}
aria-valuenow={valueNow ?? undefined}
aria-valuemin={valueNow === null ? undefined : 0}
aria-valuemax={valueNow === null ? undefined : 100}
aria-valuetext={valueNow === null ? undefined : `${valueNow}%`}
tabIndex={0}
onPointerDown={onPointerDown}
onKeyDown={onKeyDown}
>
<span className={styles.grip} aria-hidden="true" />
</div>
);
}
+5
View File
@@ -27,6 +27,10 @@ const H = vi.hoisted(() => ({
pending: [] as Array<() => void>,
destroyed: [] as number[],
configs: [] as unknown[],
inspected: null as {
input: ReadonlyArray<Record<string, unknown>>;
resolved: ReadonlyArray<Record<string, unknown>>;
} | null,
}));
vi.mock('../services/chart-renderer', () => ({
renderSpec: (node: HTMLElement, _spec: unknown, config: unknown) => {
@@ -44,6 +48,7 @@ vi.mock('../services/chart-renderer', () => ({
H.destroyed.push(id);
},
resize() {},
inspectData: () => H.inspected,
});
});
});
+31
View File
@@ -37,6 +37,8 @@ import { usePreviewStore } from '../stores/PreviewStore';
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
import { useUserSettingsStore } from '../stores/UserSettingsStore';
import { ChartExport } from './ChartExport';
import { DataInspector } from './DataInspector';
import { InspectorSplitHandle } from './InspectorSplitHandle';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SelectControl, type SelectControlOption } from './SelectControl';
import { RangeControl, SettingRow, SettingsPopover } from './SettingsPopover';
@@ -176,6 +178,9 @@ export function LivePreview() {
const fitMode = useAppStore((s) => s.previewFitMode);
const uiTheme = useAppStore((s) => s.uiTheme);
const chartTheme = useAppStore((s) => s.chartTheme);
// Data inspector: open-state gates the divider + table; its height is the divider's.
const inspectorOpen = useAppStore((s) => s.dataInspectorOpen);
const inspectorHeight = useAppStore((s) => s.dataInspectorHeight);
// Custom themes feed `custom:<id>` selection resolution; re-rendering on a
// change keeps the chart live while a selected theme is edited in the builder.
const customThemes = useCustomThemeStore((s) => s.themes);
@@ -201,6 +206,11 @@ export function LivePreview() {
// a ref change alone wouldn't re-render. Set true on a successful render, false
// on clear/error/unmount.
const [chartReady, setChartReady] = useState(false);
// Bumped whenever a render settles and changes the live view (success, clear,
// error) so the data inspector re-reads the resolved rows. A monotonic counter,
// not `chartReady` — consecutive successful renders keep `chartReady` true, but
// each one is new data the inspector must pick up.
const [renderEpoch, setRenderEpoch] = useState(0);
// Busy-indication timer ref: if a render exceeds ~1s we surface a non-blocking
// overlay (arch §10.2 NN/g: >1s owes a busy indication; <1s shows nothing to
@@ -284,6 +294,7 @@ export function LivePreview() {
handleRef.current?.destroy();
handleRef.current = null;
setChartReady(false);
setRenderEpoch((e) => e + 1);
setError(null);
clearBusy();
return;
@@ -306,11 +317,13 @@ export function LivePreview() {
handleRef.current = handle;
configRef.current = config;
setChartReady(true);
setRenderEpoch((e) => e + 1);
setError(null);
clearBusy();
} catch (e) {
if (mine === generationRef.current) {
setChartReady(false);
setRenderEpoch((e) => e + 1);
clearBusy();
// A missing dataset reference is not a JSON/spec problem, so it gets a
// tailored, fixable message instead of the generic syntax hint (council:
@@ -379,6 +392,11 @@ export function LivePreview() {
[],
);
// The input + resolved rows for the data inspector — reads the live view through
// the handle (null when no chart is up). Stable identity; the inspector re-reads
// on `renderEpoch`, so this need not depend on it (it always reads the latest handle).
const getInspectData = useCallback(() => handleRef.current?.inspectData() ?? null, []);
// Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
// observe the element, so we do: one observer on the stable host node for the
// component's life. Only responsive fit modes depend on container size;
@@ -450,6 +468,19 @@ export function LivePreview() {
</div>
)}
</div>
{/* Data inspector — input vs. resolved rows, stacked below the chart (a
collapsed disclosure by default); reads the live view through getInspectData.
When open, a draggable divider sizes it (chart absorbs the change) and the
stored height fills the table; collapsed, it's just the disclosure bar. */}
{inspectorOpen && (
<InspectorSplitHandle label="Resize data inspector" controls="preview-data-inspector" />
)}
<DataInspector
id="preview-data-inspector"
getData={getInspectData}
renderEpoch={renderEpoch}
heightPx={inspectorOpen ? inspectorHeight : undefined}
/>
</div>
);
}
+1 -1
View File
@@ -18,7 +18,7 @@ import { Onboarding } from './Onboarding';
// never touches vega-embed. A resolved no-op handle is enough — Onboarding only
// finalizes it on unmount.
vi.mock('../services/chart-renderer', () => ({
renderSpec: () => Promise.resolve({ destroy() {}, resize() {} }),
renderSpec: () => Promise.resolve({ destroy() {}, resize() {}, inspectData: () => null }),
}));
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+3 -2
View File
@@ -17,7 +17,7 @@
*/
import { useRef } from 'react';
import { useColResizeDrag } from '../hooks/useColResizeDrag';
import { useResizeDrag } from '../hooks/useResizeDrag';
import { splitLibraryWidth, splitValue, usePanesStore } from '../stores/PanesStore';
import styles from './ResizeHandle.module.css';
@@ -57,7 +57,8 @@ export function PaneSplitHandle({ label }: PaneSplitHandleProps) {
usePanesStore.getState().setSplit(library, avail - library);
};
const onPointerDown = useColResizeDrag(
const onPointerDown = useResizeDrag(
'x',
() => flanks().lib?.clientWidth ?? 0,
(startLib, delta) => applySplit(startLib + delta),
);
+3 -2
View File
@@ -19,7 +19,7 @@
*/
import { useLayoutEffect, useRef, useState } from 'react';
import { useColResizeDrag } from '../hooks/useColResizeDrag';
import { useResizeDrag } from '../hooks/useResizeDrag';
import { clampSideWidth, sideWidthValue, usePanesStore, type PaneSide } from '../stores/PanesStore';
import styles from './ResizeHandle.module.css';
@@ -66,7 +66,8 @@ export function ResizeHandle({ side, label }: ResizeHandleProps) {
// The left handle grows its pane as it moves right; the right handle (left
// of the preview) shrinks the preview as it moves right.
const onPointerDown = useColResizeDrag(
const onPointerDown = useResizeDrag(
'x',
() =>
side === 'library'
? usePanesStore.getState().libraryWidth
@@ -16,7 +16,12 @@ import { ThemeBuilderModal } from './ThemeBuilderModal';
vi.mock('../services/chart-renderer', () => ({ renderSpec: vi.fn() }));
const okHandle = () => ({ destroy() {}, resize() {}, toImageURL: () => Promise.resolve('') });
const okHandle = () => ({
destroy() {},
resize() {},
toImageURL: () => Promise.resolve(''),
inspectData: () => null,
});
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
@@ -17,7 +17,12 @@ import { ThemeBuilderModal } from './ThemeBuilderModal';
vi.mock('../services/chart-renderer', () => ({
renderSpec: vi.fn(() =>
Promise.resolve({ destroy() {}, resize() {}, toImageURL: () => Promise.resolve('') }),
Promise.resolve({
destroy() {},
resize() {},
toImageURL: () => Promise.resolve(''),
inspectData: () => null,
}),
),
}));