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,
}),
),
}));
@@ -1,29 +1,31 @@
/**
* useColResizeDrag the shared pointer gesture behind the pane drag handles
* (ResizeHandle, PaneSplitHandle).
* useResizeDrag the shared pointer gesture behind every drag handle
* (ResizeHandle, PaneSplitHandle, InspectorSplitHandle).
*
* Returns a `pointerdown` handler: on a primary-button press it measures the
* gesture's start value, then tracks the pointer on `window` (the pointer leaves
* the thin handle immediately) and feeds each horizontal delta to `apply`. While
* dragging, the body is forced to the `col-resize` cursor with text selection
* gesture's start value, then tracks the pointer on `window` (it leaves the thin
* handle immediately) and feeds each delta along `axis` to `apply`. While
* dragging, the body is forced to the matching resize cursor with text selection
* suppressed; release restores both and detaches the listeners.
*
* Only the gesture lives here what "the start value" means (a pane width, a
* split position) and how a delta becomes layout is the caller's, as is the
* region height) and how a delta becomes layout is the caller's, as is the
* keyboard/ARIA half of the splitter contract (it differs per handle).
*/
export function useColResizeDrag(
export function useResizeDrag(
axis: 'x' | 'y',
measureStart: () => number,
apply: (start: number, deltaX: number) => void,
apply: (start: number, delta: number) => void,
): (e: React.PointerEvent<HTMLElement>) => void {
const coord = (e: { clientX: number; clientY: number }) => (axis === 'x' ? e.clientX : e.clientY);
return (e) => {
if (e.button !== 0) return; // primary button only
e.preventDefault();
const startX = e.clientX;
const startPos = coord(e);
const start = measureStart();
const onMove = (ev: PointerEvent) => apply(start, ev.clientX - startX);
const onMove = (ev: PointerEvent) => apply(start, coord(ev) - startPos);
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
@@ -33,7 +35,7 @@ export function useColResizeDrag(
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
document.body.style.cursor = 'col-resize';
document.body.style.cursor = axis === 'x' ? 'col-resize' : 'row-resize';
document.body.style.userSelect = 'none';
};
}
+40 -1
View File
@@ -32,12 +32,22 @@ const DEFAULT_FIT_MODE: FitMode = 'default';
/** Spec §04 — the Chart theme picker defaults to the house style. */
const DEFAULT_CHART_THEME: ChartThemeSelection = 'astrolabe';
/** Spec §04 — the data inspector is a debugging aid, collapsed until asked for. */
const DEFAULT_DATA_INSPECTOR_OPEN = false;
/** The managed slice the per-pane settings clusters own (theme + fit live in their own slices). */
export type ManagedSettings = Pick<UserSettings, 'editor' | 'performance' | 'formatting'>;
/** Loose view of the stored record for the per-slice write-through merges. */
interface StoredSettings {
ui?: { theme?: unknown; previewFitMode?: unknown; chartTheme?: unknown; [k: string]: unknown };
ui?: {
theme?: unknown;
previewFitMode?: unknown;
chartTheme?: unknown;
dataInspectorOpen?: unknown;
dataInspectorHeight?: unknown;
[k: string]: unknown;
};
[k: string]: unknown;
}
@@ -143,3 +153,32 @@ export function savePreviewFitMode(fitMode: FitMode): void {
const current = readRaw();
writeRaw({ ...current, ui: { ...current.ui, previewFitMode: fitMode } });
}
/** Whether the live-preview data inspector is expanded, or the default (collapsed). */
export function loadDataInspectorOpen(): boolean {
const stored = readRaw().ui?.dataInspectorOpen;
return typeof stored === 'boolean' ? stored : DEFAULT_DATA_INSPECTOR_OPEN;
}
/** Persist the data inspector's open state, preserving every other key in the record. */
export function saveDataInspectorOpen(open: boolean): void {
const current = readRaw();
writeRaw({ ...current, ui: { ...current.ui, dataInspectorOpen: open } });
}
/**
* The persisted data-inspector height (px), or null when absent/invalid. Null —
* not a default — so the caller keeps the store's own default; the px default
* lives only in `AppStore` (DATA_INSPECTOR_DEFAULT_HEIGHT). Re-clamped to the live
* layout on drag, so a stale stored value can't wedge the pane.
*/
export function loadDataInspectorHeight(): number | null {
const stored = readRaw().ui?.dataInspectorHeight;
return typeof stored === 'number' && Number.isFinite(stored) && stored > 0 ? stored : null;
}
/** Persist the data inspector's height, preserving every other key in the record. */
export function saveDataInspectorHeight(height: number): void {
const current = readRaw();
writeRaw({ ...current, ui: { ...current.ui, dataInspectorHeight: height } });
}
+32
View File
@@ -11,8 +11,12 @@
import {
loadChartTheme,
loadDataInspectorHeight,
loadDataInspectorOpen,
loadPreviewFitMode,
saveChartTheme,
saveDataInspectorHeight,
saveDataInspectorOpen,
savePreviewFitMode,
} from '../infrastructure/settings-store';
import { useAppStore } from '../stores/AppStore';
@@ -42,3 +46,31 @@ export function wireChartTheme(): () => void {
saveChartTheme(state.chartTheme);
});
}
/** Hydrate the persisted data-inspector open state into the store. Call before render. */
export function initDataInspectorOpen(): void {
useAppStore.getState().setDataInspectorOpen(loadDataInspectorOpen());
}
/** Persist the data-inspector open state on change. Returns a detaching teardown. */
export function wireDataInspectorOpen(): () => void {
return useAppStore.subscribe((state, prev) => {
if (state.dataInspectorOpen === prev.dataInspectorOpen) return;
saveDataInspectorOpen(state.dataInspectorOpen);
});
}
/** Hydrate the persisted data-inspector height into the store (keep the store
* default when none is stored). Call before render. */
export function initDataInspectorHeight(): void {
const height = loadDataInspectorHeight();
if (height !== null) useAppStore.getState().setDataInspectorHeight(height);
}
/** Persist the data-inspector height on change. Returns a detaching teardown. */
export function wireDataInspectorHeight(): () => void {
return useAppStore.subscribe((state, prev) => {
if (state.dataInspectorHeight === prev.dataInspectorHeight) return;
saveDataInspectorHeight(state.dataInspectorHeight);
});
}
+49
View File
@@ -12,6 +12,7 @@ import type { VisualizationSpec } from 'vega-embed';
import type { Config } from 'vega-lite';
import { collectFontFamilies } from '@core/custom-theme';
import { embedFontsInSvg } from '@core/chart-export';
import { pickResultDataset, pickSourceDataset } from '@core/result-data';
import type { FontAsset } from '@core/font-asset';
/** Options for `RenderHandle.toImageURL` (spec §08 → Per-chart export). */
@@ -43,6 +44,15 @@ interface ImageExportOptions {
embedFonts?: ReadonlyArray<FontAsset>;
}
/** The two ends of the chart's data pipeline, for the data inspector (spec §04). */
export interface InspectedData {
/** Parsed source rows, before the spec's transforms run — the input. */
input: ReadonlyArray<Record<string, unknown>>;
/** Post-transform rows the chart draws — the output (equals `input` when the
* spec has no transforms). */
resolved: ReadonlyArray<Record<string, unknown>>;
}
export interface RenderHandle {
/** Finalize the underlying Vega view and clear the node. */
destroy(): void;
@@ -67,6 +77,21 @@ export interface RenderHandle {
* (fixed ones have no such handler), which is exactly right for Width/Height.
*/
resize(): void;
/**
* The chart's input and resolved (post-transform) rows — for the data inspector
* (spec §04). Reads the live view's compiled dataflow once: lists its datasets,
* picks the most-upstream
* source and most-downstream result (`@core/result-data`), and returns both
* tables' rows. This is the one place besides export that reaches into the view,
* so the embedding boundary holds (arch 05 §1–§2) — callers get rows, never the
* `view`.
*
* Returns `null` when there is nothing to inspect (the view was finalized, or
* the spec produced no inspectable table). Either side can be `[]` when its
* table is empty — a real signal (e.g. a filter removed every row on the
* resolved side), kept distinct from "no chart" so the inspector can say which.
*/
inspectData(): InspectedData | null;
}
export interface RenderOptions {
@@ -235,8 +260,13 @@ export async function renderSpec(
tooltip: { disableDefaultStyle: true },
});
// Reads of a finalized view throw; `inspectData` checks this to no-op safely
// after destroy() (the inspector may read on a render that resolved late).
let finalized = false;
return {
destroy() {
finalized = true;
result.view.finalize();
node.replaceChildren();
},
@@ -265,5 +295,24 @@ export async function renderSpec(
// no-op after destroy().
if (typeof window !== 'undefined') window.dispatchEvent(new Event('resize'));
},
inspectData() {
if (finalized) return null;
// Enumerate the dataflow's datasets once, then pick the input + result
// tables. getState with a truthy `data` filter is Vega's documented way to
// list datasets (vega/editor's Data Viewer does the same) — we read only the
// keys. Rows come from view.data(name), which hands back the live array (no copy).
const state = result.view.getState({
data: () => true,
signals: () => false,
recurse: true,
}) as { data?: Record<string, unknown> };
const names = Object.keys(state.data ?? {});
const sourceName = pickSourceDataset(names);
const resultName = pickResultDataset(names);
if (sourceName === null && resultName === null) return null;
const rows = (name: string | null): ReadonlyArray<Record<string, unknown>> =>
name === null ? [] : ((result.view.data(name) ?? []) as Record<string, unknown>[]);
return { input: rows(sourceName), resolved: rows(resultName) };
},
};
}
+25 -1
View File
@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, test } from 'vitest';
import { useAppStore } from './AppStore';
import { clampInspectorHeight, inspectorHeightValue, useAppStore } from './AppStore';
const store = () => useAppStore.getState();
beforeEach(() => store().setTheme('light'));
@@ -18,3 +18,27 @@ describe('theme', () => {
expect(store().uiTheme).toBe('light');
});
});
describe('data inspector height (divider math)', () => {
// availForBoth = 600 → inspector max = 600 - 120 (chart min) = 480; min = 96.
test('clamps within [inspector min, availForBoth chart min]', () => {
expect(clampInspectorHeight(300, 600)).toBe(300);
expect(clampInspectorHeight(10, 600)).toBe(96); // floor at inspector min
expect(clampInspectorHeight(5000, 600)).toBe(480); // leave the chart its minimum
});
test('never returns below the inspector minimum even in a tiny pane', () => {
// Span too small for both minimums → keep the inspector minimum.
expect(clampInspectorHeight(300, 150)).toBe(96);
});
test('aria-valuenow maps min→0, max→100', () => {
expect(inspectorHeightValue(96, 600)).toBe(0);
expect(inspectorHeightValue(480, 600)).toBe(100);
expect(inspectorHeightValue(288, 600)).toBe(50); // midpoint of [96, 480]
});
test('aria-valuenow is null when the span has no range', () => {
expect(inspectorHeightValue(96, 150)).toBeNull();
});
});
+50
View File
@@ -4,6 +4,38 @@ import type { UiTheme } from '@core/theme';
import type { ChartThemeSelection } from '@core/vega-themes';
import type { ModalName } from '../modals/types';
/** Default data-inspector height (px) when first expanded, before any drag. */
const DATA_INSPECTOR_DEFAULT_HEIGHT = 220;
/** Floor (px) for the inspector and for the chart above it while the divider drags. */
const DATA_INSPECTOR_MIN_HEIGHT = 96;
const CHART_MIN_HEIGHT = 120;
/**
* Clamp a desired inspector height so it keeps its own minimum and leaves the
* chart above it at least its minimum. `availForBoth` is the height the chart and
* inspector share (the preview body, minus the divider). Pure — the single place
* the divider constraint lives, unit-tested without a DOM. Mirrors PanesStore's
* `clampSideWidth`.
*/
export function clampInspectorHeight(desired: number, availForBoth: number): number {
const max = Math.max(DATA_INSPECTOR_MIN_HEIGHT, availForBoth - CHART_MIN_HEIGHT);
return Math.max(DATA_INSPECTOR_MIN_HEIGHT, Math.min(desired, max));
}
/**
* The inspector's 0100 position for the divider's `aria-valuenow` (WAI-ARIA APG →
* Window Splitter): 0 = inspector at its minimum, 100 = at its maximum (chart at
* its minimum). Null when the span has no range, so the caller omits the attribute.
*/
export function inspectorHeightValue(height: number, availForBoth: number): number | null {
const min = DATA_INSPECTOR_MIN_HEIGHT;
const max = Math.max(min, availForBoth - CHART_MIN_HEIGHT);
if (max <= min) return null;
const pct = ((height - min) / (max - min)) * 100;
return Math.round(Math.min(100, Math.max(0, pct)));
}
/**
* Centralized cross-cutting application state, as a Zustand store. Keep this
* lean — durable, feature-specific state (snippets, datasets, settings) lands
@@ -21,6 +53,16 @@ export interface AppState {
previewFitMode: FitMode;
/** Chart theme selection (spec §04); persisted to Settings as `chartTheme`. */
chartTheme: ChartThemeSelection;
/**
* Whether the live-preview data inspector is expanded (spec §04). A preview-pane
* preference like `previewFitMode`; persisted to Settings as `dataInspectorOpen`.
*/
dataInspectorOpen: boolean;
/**
* Height (px) of the expanded data inspector — set by its resizable divider, so
* the chart above keeps the rest of the pane. Persisted as `dataInspectorHeight`.
*/
dataInspectorHeight: number;
/** The currently open modal, or null. */
activeModal: ModalName | null;
@@ -31,6 +73,10 @@ export interface AppState {
setPreviewFitMode: (mode: FitMode) => void;
/** Set the chart theme — the Live Preview settings cluster's action. */
setChartTheme: (theme: ChartThemeSelection) => void;
/** Show/hide the data inspector — its disclosure toggle. */
setDataInspectorOpen: (open: boolean) => void;
/** Set the data inspector's height (caller clamps via `clampInspectorHeight`). */
setDataInspectorHeight: (height: number) => void;
/**
* Low-level modal setter — the single primitive that mutates `activeModal`.
* High-level open/close (snapshot for unsaved-change detection, URL sync,
@@ -44,11 +90,15 @@ export const useAppStore = create<AppState>((set) => ({
uiTheme: 'light',
previewFitMode: 'default',
chartTheme: 'astrolabe',
dataInspectorOpen: false,
dataInspectorHeight: DATA_INSPECTOR_DEFAULT_HEIGHT,
activeModal: null,
setTheme: (uiTheme) => set({ uiTheme }),
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
setPreviewFitMode: (previewFitMode) => set({ previewFitMode }),
setChartTheme: (chartTheme) => set({ chartTheme }),
setDataInspectorOpen: (dataInspectorOpen) => set({ dataInspectorOpen }),
setDataInspectorHeight: (dataInspectorHeight) => set({ dataInspectorHeight }),
setActiveModal: (activeModal) => set({ activeModal }),
}));
+56
View File
@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest';
import { pickResultDataset, pickSourceDataset } from './result-data';
describe('pickResultDataset', () => {
it('prefers the most-downstream transform output over the source', () => {
expect(pickResultDataset(['source_0', 'data_0', 'marks', 'root'])).toBe('data_0');
});
it('picks the highest data_<n> numerically, not lexically', () => {
expect(pickResultDataset(['source_0', 'data_0', 'data_1', 'data_2'])).toBe('data_2');
// 10 must beat 2 — guards against string ordering ('data_10' < 'data_2').
expect(pickResultDataset(['data_2', 'data_10', 'data_9'])).toBe('data_10');
});
it('falls back to the most-downstream source when there is no data_<n>', () => {
expect(pickResultDataset(['source_0', 'source_1', 'marks'])).toBe('source_1');
});
it('ignores dataflow internals when choosing', () => {
expect(pickResultDataset(['root', 'marks', 'layout', 'cell', 'source_0'])).toBe('source_0');
expect(pickResultDataset(['brush_store', '_facet', 'a:b', 'data_0'])).toBe('data_0');
});
it('returns a remaining non-internal table when no source/data convention matches', () => {
expect(pickResultDataset(['root', 'marks', 'my_named_source'])).toBe('my_named_source');
});
it('returns null when nothing is inspectable', () => {
expect(pickResultDataset([])).toBeNull();
expect(pickResultDataset(['root', 'marks', 'layout', 'brush_store'])).toBeNull();
});
});
describe('pickSourceDataset', () => {
it('prefers the most-upstream source — the input before transforms', () => {
expect(pickSourceDataset(['source_0', 'source_1', 'data_0', 'data_1'])).toBe('source_0');
});
it('picks the lowest source_<n> numerically', () => {
expect(pickSourceDataset(['source_2', 'source_10', 'source_1'])).toBe('source_1');
});
it('falls back to the earliest transform stage when there is no source_<n>', () => {
expect(pickSourceDataset(['data_0', 'data_1', 'marks'])).toBe('data_0');
});
it('equals the result when a spec has no transforms (only a source)', () => {
const names = ['source_0', 'marks', 'root'];
expect(pickSourceDataset(names)).toBe('source_0');
expect(pickResultDataset(names)).toBe('source_0');
});
it('returns null when nothing is inspectable', () => {
expect(pickSourceDataset(['root', 'marks', 'brush_store'])).toBeNull();
});
});
+95
View File
@@ -0,0 +1,95 @@
/**
* Picking the input and resolved datasets from a rendered Vega view (the data
* inspector — spec §04; arch 05 → "the data inspector rides the boundary").
*
* A compiled Vega dataflow holds many named datasets. Vega-Lite names the ones a
* spec produces by convention: `source_<n>` for each parsed data source, and
* `data_<n>` for each transform/aggregation stage. So a single view carries both
* ends of the pipeline:
*
* - the **input** is the most-upstream source (`source_0`) — the parsed rows
* before the spec's transforms;
* - the **resolved** output is the most-downstream `data_<n>` — the rows the marks
* of a single-view chart actually draw.
*
* Everything else in the dataflow (`root`, `marks`, layout/scale tables, selection
* `*_store`s, `_`-prefixed internals, faceted `a:b` names) is plumbing the user
* never authored. A spec with no transforms has only a source, so input and
* resolved resolve to the same table — correct: no transforms means output = input.
*
* This is the pure half of the inspector — given the view's dataset names, choose
* one per direction. The view access itself (listing names, reading rows) lives
* behind the renderer's `RenderHandle` boundary (services/chart-renderer; arch 05).
*
* Limitation: a single name can't represent a multi-view spec (layer/concat/facet
* produce several `source_<n>` / `data_<n>`, one per view). We return the
* most-upstream source and most-downstream output — both real, drawn tables — and
* leave a full dataset selector as a future option.
*/
/** Datasets that are dataflow plumbing, never a table the user would inspect. */
function isInternalDataset(name: string): boolean {
return (
name === 'root' ||
name === 'marks' ||
name === 'layout' ||
name === 'cell' ||
name.startsWith('_') ||
name.endsWith('_store') || // selection tuple stores
name.includes(':') // faceted / cross-context child sources
);
}
/** The trailing index of a `prefix_<n>` name (e.g. `data_12` → 12), or null. */
function suffixIndex(name: string, prefix: string): number | null {
if (!name.startsWith(`${prefix}_`)) return null;
const n = Number(name.slice(prefix.length + 1));
return Number.isInteger(n) ? n : null;
}
/** The `prefix_<n>` name with the highest (`'max'`) or lowest (`'min'`) suffix. */
function bySuffix(names: readonly string[], prefix: string, end: 'min' | 'max'): string | null {
let best: string | null = null;
let bestN = end === 'max' ? -Infinity : Infinity;
for (const name of names) {
const n = suffixIndex(name, prefix);
if (n === null) continue;
if (end === 'max' ? n > bestN : n < bestN) {
bestN = n;
best = name;
}
}
return best;
}
/**
* Choose the compiled dataset representing the rows the chart draws, given all of
* the view's dataset names. Prefers the most-downstream transform output
* (`data_<max n>`), then the most-downstream source (`source_<max n>`), then any
* remaining non-internal table. Returns null when nothing is inspectable.
*/
export function pickResultDataset(names: readonly string[]): string | null {
const candidates = names.filter((n) => !isInternalDataset(n));
return (
bySuffix(candidates, 'data', 'max') ??
bySuffix(candidates, 'source', 'max') ??
candidates[0] ??
null
);
}
/**
* Choose the compiled dataset representing the chart's input — the parsed rows
* before the spec's transforms. Prefers the most-upstream source (`source_<min n>`),
* then the earliest transform stage (`data_<min n>`) for the rare source-less spec,
* then any remaining non-internal table. Returns null when nothing is inspectable.
*/
export function pickSourceDataset(names: readonly string[]): string | null {
const candidates = names.filter((n) => !isInternalDataset(n));
return (
bySuffix(candidates, 'source', 'min') ??
bySuffix(candidates, 'data', 'min') ??
candidates[0] ??
null
);
}
+8
View File
@@ -3,8 +3,12 @@ import { App } from './app/App';
import { initPanes, wirePanes } from './app/orchestration/panes';
import {
initChartTheme,
initDataInspectorHeight,
initDataInspectorOpen,
initPreviewFitMode,
wireChartTheme,
wireDataInspectorHeight,
wireDataInspectorOpen,
wirePreviewFitMode,
} from './app/orchestration/preferences';
import { initSettings, wireSettings } from './app/orchestration/settings';
@@ -30,6 +34,10 @@ initPreviewFitMode();
wirePreviewFitMode();
initChartTheme();
wireChartTheme();
initDataInspectorOpen();
wireDataInspectorOpen();
initDataInspectorHeight();
wireDataInspectorHeight();
initPanes();
wirePanes();