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
+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>
);
}