mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Data inspector: input/resolved rows below the chart, with a resizable divider
A collapsible Data panel under the Live Preview and Chart Builder charts shows the rows the chart actually uses, switching between Input (parsed source) and Resolved (post-transform) views read from the live Vega view. Collapsed by default; the open-state and a draggable height divider persist. Rows come through a new RenderHandle.inspectData() accessor, so no component touches the view: core/result-data picks the most-upstream source and most-downstream result from the compiled dataflow, read lazily. The divider reuses the window-splitter pattern (horizontal variant). Consolidations: a shared DataTable primitive replaces the inspector's and the builder's duplicate read-only tables; useResizeDrag merges the col/row drag-gesture twins. Docs: spec 04/06 and arch 05/10 updated; the now-shipped exploration memo removed.
This commit is contained in:
@@ -520,8 +520,9 @@ is in the maintained plan, not the archive.
|
|||||||
a starter-example front door. Distinct from the shipped onboarding gallery
|
a starter-example front door. Distinct from the shipped onboarding gallery
|
||||||
(`core/examples.ts` → `Onboarding.tsx`), which seeds a snippet straight into the editor, not
|
(`core/examples.ts` → `Onboarding.tsx`), which seeds a snippet straight into the editor, not
|
||||||
the builder — there is no spec→builder-config path yet.
|
the builder — there is no spec→builder-config path yet.
|
||||||
- **Transform-aware data inspector** — show resolved post-transform rows
|
- ~~**Transform-aware data inspector** — show resolved post-transform rows~~ ✅ shipped: an
|
||||||
(`exploration/data-inspector-exploration.md`).
|
Input | Resolved data inspector below the Live Preview and Chart Builder charts, with a
|
||||||
|
draggable height divider (spec §04; arch 05 → "data inspector rides the boundary").
|
||||||
- **Field-chip drag-and-drop** — click/keyboard-first shipped; drag deferred.
|
- **Field-chip drag-and-drop** — click/keyboard-first shipped; drag deferred.
|
||||||
- **Calculated-field autocomplete popup** — Monaco-style completion for expressions.
|
- **Calculated-field autocomplete popup** — Monaco-style completion for expressions.
|
||||||
|
|
||||||
|
|||||||
@@ -40,12 +40,28 @@ above it is data; everything below it is a Vega `View` we own and must tear down
|
|||||||
`subscribe` listener), not as reactive state itself.
|
`subscribe` listener), not as reactive state itself.
|
||||||
- **Don't** scatter `vegaEmbed(...)` calls across components.
|
- **Don't** scatter `vegaEmbed(...)` calls across components.
|
||||||
|
|
||||||
> **Forward note — data inspector.** A planned vega-editor-style inspector (show the
|
### The data inspector rides the boundary too
|
||||||
> _resolved, post-transform_ data tables, in the builder and as a togglable panel below
|
|
||||||
> the main Live Preview) reads runtime rows via `view.data(name)`. It rides this same
|
The data inspector (the Live Preview and Chart Builder panel showing the chart's input
|
||||||
> boundary: a `RenderHandle.data()` accessor wraps the view (like `toImageURL`), so no
|
vs. resolved rows — spec §04) reads runtime rows through the handle, never the raw view:
|
||||||
> component touches the raw view. Parked, not built — see
|
`RenderHandle.inspectData()` returns the input + resolved tables (`{ input, resolved }`,
|
||||||
> [`docs/exploration/data-inspector-exploration.md`](../exploration/data-inspector-exploration.md).
|
or `null` when no chart is up), wrapping the view exactly like `toImageURL`. It works in
|
||||||
|
two layers:
|
||||||
|
|
||||||
|
- **Enumerate + pick (`view.getState` + `core/result-data`).** A compiled Vega dataflow
|
||||||
|
holds many named datasets; Vega-Lite names them by convention — `source_<n>` per parsed
|
||||||
|
source, `data_<n>` per transform stage. The pure `pickSourceDataset` / `pickResultDataset`
|
||||||
|
choose the **most-upstream source** (the input) and **most-downstream output** (what the
|
||||||
|
marks draw), skipping dataflow internals (`marks`, `root`, layout, selection `*_store`s).
|
||||||
|
A spec with no transforms resolves both to the same table. The picking is pure (in `core`,
|
||||||
|
unit-tested); only the enumeration touches the view.
|
||||||
|
- **Read lazily.** `getState` serializes the datasets it lists, so it is **only called while
|
||||||
|
the panel is open** — a collapsed inspector costs nothing, which is why the panel reads on
|
||||||
|
demand rather than on every render.
|
||||||
|
|
||||||
|
Limitation: one name per direction can't represent a multi-view spec (layer/concat/facet
|
||||||
|
produce several `data_<n>`); the most-downstream/upstream ones are returned, and a full
|
||||||
|
dataset selector is left as a future option.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -210,6 +210,14 @@ each above its min. **valuenow trade-off:** it reports the library's 0–100 pos
|
|||||||
rendered position after a window enlargement (worst near the extremes). Drag/keyboard read live
|
rendered position after a window enlargement (worst near the extremes). Drag/keyboard read live
|
||||||
`clientWidth`, so resizing itself stays accurate; only the announced value drifts.
|
`clientWidth`, so resizing itself stays accurate; only the announced value drifts.
|
||||||
|
|
||||||
|
**Horizontal variant — the data-inspector divider.** The chart ↔ data-inspector divider in
|
||||||
|
the Live Preview (`InspectorSplitHandle`, spec §04) is the same window-splitter contract with
|
||||||
|
the axis flipped: `aria-orientation="horizontal"`, ↑/↓ + Home/End, sizing a stacked region's
|
||||||
|
**height** instead of a pane's width. It reads its flanking siblings (chart above, inspector
|
||||||
|
below) like `PaneSplitHandle`, with the gesture in `useRowResizeDrag` (the row twin of
|
||||||
|
`useColResizeDrag`) and the clamp/value math pure in `AppStore` (`clampInspectorHeight` /
|
||||||
|
`inspectorHeightValue`). Rendered only while the inspector is open.
|
||||||
|
|
||||||
_(Consulted via `/council` → WAI-ARIA APG `windowsplitter`. This bullet is the contract;
|
_(Consulted via `/council` → WAI-ARIA APG `windowsplitter`. This bullet is the contract;
|
||||||
cite it, not the APG file.)_
|
cite it, not the APG file.)_
|
||||||
|
|
||||||
|
|||||||
@@ -15,4 +15,3 @@ snapshots, not live numbers.
|
|||||||
- `chart-builder-enhancement-scope.md` — consolidated Tier-B → Tier-C forward plan for the Chart Builder.
|
- `chart-builder-enhancement-scope.md` — consolidated Tier-B → Tier-C forward plan for the Chart Builder.
|
||||||
- `chart-theming-scope.md` — chart theming plan and slice breakdown.
|
- `chart-theming-scope.md` — chart theming plan and slice breakdown.
|
||||||
- `monetization-and-sync-exploration.md` — monetization + BYO-cloud-sync direction memo.
|
- `monetization-and-sync-exploration.md` — monetization + BYO-cloud-sync direction memo.
|
||||||
- `data-inspector-exploration.md` — transform-aware data-inspector direction.
|
|
||||||
|
|||||||
@@ -223,10 +223,10 @@ the quick "where are we" — read it first.
|
|||||||
smoke tests) + `eslint` + `build` (PWA, 45 precache entries). **Owed:** a manual/visual
|
smoke tests) + `eslint` + `build` (PWA, 45 precache entries). **Owed:** a manual/visual
|
||||||
pass against the live builder (filter shelf, calc → channel, expr errors, preview
|
pass against the live builder (filter shelf, calc → channel, expr errors, preview
|
||||||
table) — tests don't cover what the surface looks/feels like.
|
table) — tests don't cover what the surface looks/feels like.
|
||||||
- **Surfaced direction (now parked):** the 1D preview shows **raw source** rows; a
|
- **Surfaced direction (since shipped):** the 1D preview shows **raw source** rows; a
|
||||||
transform-aware **data inspector** (resolved rows, à la vega-editor, in the builder
|
transform-aware **data inspector** (input vs. resolved rows, à la vega-editor, in the
|
||||||
_and_ below the main Live Preview) is the wanted evolution — documented in
|
builder _and_ below the main Live Preview) was the wanted evolution — now shipped
|
||||||
**[`docs/data-inspector-exploration.md`](./data-inspector-exploration.md)**, deferred.
|
(spec §04 → _Data Inspector_).
|
||||||
|
|
||||||
- **2026-06-10** — **Up next: 1C (filter + calculate transforms), paired with 1D (data preview).**
|
- **2026-06-10** — **Up next: 1C (filter + calculate transforms), paired with 1D (data preview).**
|
||||||
- **1B · Per-chart export** shipped: an **Export** disclosure in the Live Preview header
|
- **1B · Per-chart export** shipped: an **Export** disclosure in the Live Preview header
|
||||||
@@ -580,12 +580,10 @@ guardrail: _promote a control only when it is **both common AND awkward in JSON*
|
|||||||
is short-lived; the smart default already gives a sane start).
|
is short-lived; the smart default already gives a sane start).
|
||||||
- **Lookup / join a second dataset** — Lyra §3.12; data-model change (multi-dataset
|
- **Lookup / join a second dataset** — Lyra §3.12; data-model change (multi-dataset
|
||||||
snippets). Larger, separate effort.
|
snippets). Larger, separate effort.
|
||||||
- **Transform-aware data inspector** — evolve 1D from raw-source rows to the _resolved,
|
- ~~**Transform-aware data inspector**~~ ✅ shipped — an Input | Resolved data panel below
|
||||||
post-transform_ data (filtered rows + calculated columns), and generalize it to a
|
the **main** Live Preview and the builder preview (vega-editor's "Data Viewer", a
|
||||||
togglable data panel below the **main** Live Preview (vega-editor's "Data Viewer", a
|
debugging aid for any snippet), reading runtime rows through the `RenderHandle`. Spec §04
|
||||||
debugging aid for any snippet, not only builder output). Reads runtime rows via
|
→ _Data Inspector_; arch 05 → "the data inspector rides the boundary".
|
||||||
`view.data(name)` through the `RenderHandle`. Cross-cutting (editor + builder), so it
|
|
||||||
has its own home: **[`docs/data-inspector-exploration.md`](./data-inspector-exploration.md)**.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
# Data Inspector — exploration (future direction)
|
|
||||||
|
|
||||||
> **Status:** parked idea (2026-06-11). A deliberate divergence from the Phase-1
|
|
||||||
> chart-builder scope, recorded here so it isn't lost. **Not built.** The shipped
|
|
||||||
> seed is the builder's 1D data preview (raw source rows); this is its transform-aware
|
|
||||||
> evolution, generalized to the main editor.
|
|
||||||
|
|
||||||
## What
|
|
||||||
|
|
||||||
A vega-editor-style **data inspector**: show the data tables the chart actually uses at
|
|
||||||
runtime — _after_ the spec's transforms run — not just the raw source rows. Two homes:
|
|
||||||
|
|
||||||
1. **Chart Builder** — make the existing data preview (1D) transform-aware: show the
|
|
||||||
filtered rows and any calculated columns, so the preview reflects the `transform`
|
|
||||||
block the builder emits. (Today 1D shows the **raw dataset rows**, pre-transform — an
|
|
||||||
"inspect the source" view. This adds the "inspect the result" view alongside it.)
|
|
||||||
2. **Main window** — a **togglable data panel below the Live Preview** in the editor,
|
|
||||||
for any snippet (not only builder-made ones): inspect each named dataset / the
|
|
||||||
post-transform rows while authoring a spec by hand.
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
vega-editor surfaces every internal data table below the chart (its "Data Viewer"), and
|
|
||||||
it is the primary way to debug "why is my chart empty / wrong" — you look at what the
|
|
||||||
transforms produced. Astrolabe has no equivalent today: the only data views are of the
|
|
||||||
**raw source** (the Datasets manager, and builder 1D). Seeing the **resolved** data
|
|
||||||
closes that debugging gap and is useful well beyond the builder.
|
|
||||||
|
|
||||||
## Technique
|
|
||||||
|
|
||||||
Vega exposes runtime datasets via `view.data(name)` — the rows of any named dataset in
|
|
||||||
the compiled dataflow, including the root data _after_ transforms. The renderer already
|
|
||||||
owns the only `view` reference behind `RenderHandle` (`src/app/services/chart-renderer.ts`);
|
|
||||||
add a `RenderHandle.listData()` / `data(name)` accessor that wraps `view.data(...)`,
|
|
||||||
mirroring how `toImageURL` wraps `view.toImageURL` — so no component touches the raw
|
|
||||||
view and the embedding boundary holds (architecture/05 §1–§2).
|
|
||||||
|
|
||||||
Vega-Lite compiles user transforms into named datasets (e.g. `source_0`, `data_0`);
|
|
||||||
mapping those to friendly labels and choosing which to expose is the main design work.
|
|
||||||
|
|
||||||
## Scope notes / open questions
|
|
||||||
|
|
||||||
- **Read-only**, like 1D — it inspects, never mutates (the "moderately capable" ceiling:
|
|
||||||
a debugging aid, not a data editor).
|
|
||||||
- **Big datasets** — cap the rows shown (reuse the 50-row preview cap). The view holds
|
|
||||||
every row; the table must not.
|
|
||||||
- **Which tables to list** — just the post-transform root/result, or every internal
|
|
||||||
dataset (the vega-editor dropdown)? Start with the result; expose the rest behind a
|
|
||||||
selector if it earns its place.
|
|
||||||
- **Main-window placement** — a third stacked region inside the preview pane (toggle +
|
|
||||||
persisted visibility, like the existing pane toggles) vs. a tab over the chart. Wants a
|
|
||||||
small interaction pass (architecture/10) before building.
|
|
||||||
- **Refresh timing** — the data is only available once a view has rendered; the panel
|
|
||||||
reads from the live `RenderHandle` and updates when a new render resolves (it must not
|
|
||||||
read a finalized view — same generation discipline as the preview).
|
|
||||||
|
|
||||||
## Relationship to shipped work
|
|
||||||
|
|
||||||
- Seed: builder **1D** data preview — see
|
|
||||||
[`chart-builder-enhancement-scope.md`](./chart-builder-enhancement-scope.md) (§3 1D).
|
|
||||||
- Renderer/preview home: [`architecture/05`](../architecture/05-rendering-theming-preview.md)
|
|
||||||
(the `RenderHandle` boundary the `view.data` accessor extends).
|
|
||||||
- Behaviour, once built, is specified in `docs/spec/04` (Live Preview) and `docs/spec/06`
|
|
||||||
(the builder preview), per "spec follows code".
|
|
||||||
@@ -71,6 +71,18 @@ Layout: a saved-theme list on the left; the open theme's editor on the right.
|
|||||||
|
|
||||||
The preview pane header also carries a per-chart **Export** control — a disclosure for copying or downloading the current chart's spec, or downloading its rendered image (PNG/SVG). It exports what the preview shows. The behavior is specified in _Import & Export → Per-chart export_; it lives in this header because the image formats are produced from the live rendered view.
|
The preview pane header also carries a per-chart **Export** control — a disclosure for copying or downloading the current chart's spec, or downloading its rendered image (PNG/SVG). It exports what the preview shows. The behavior is specified in _Import & Export → Per-chart export_; it lives in this header because the image formats are produced from the live rendered view.
|
||||||
|
|
||||||
|
## Data Inspector
|
||||||
|
|
||||||
|
Below the chart, a collapsible **Data** panel shows the rows behind the chart — the answer to "why is my chart empty or wrong" is to look at the data the spec produced. It reads the live rendered view, so it reflects exactly what was drawn.
|
||||||
|
|
||||||
|
- A **disclosure** at the bottom of the pane, collapsed by default (a debugging aid, not the default view); the open state persists across sessions, stored in _Settings_ as `ui.dataInspectorOpen`.
|
||||||
|
- An **Input | Resolved** switch chooses which rows to show:
|
||||||
|
- **Resolved** (default) — the rows the chart draws, _after_ the spec's transforms (filters, calculated fields, aggregation).
|
||||||
|
- **Input** — the parsed source rows, _before_ those transforms. A spec with no transforms shows the same rows for both.
|
||||||
|
- The table is read-only and capped (the first rows, with a "first N of M" note), like the dataset and builder previews, and updates whenever the chart re-renders.
|
||||||
|
- States: when nothing has rendered, the panel guides the user to render a chart; when the chosen view's table is empty, it names which side is empty and why (the resolved side's transforms produced nothing to draw; the input side's source has no rows).
|
||||||
|
- A **draggable divider** between the chart and the panel sizes the panel's height (the chart above absorbs the change) — the same window-splitter interaction as the layout panes (see _Application Shell & Navigation_). The height persists, stored in _Settings_ as `ui.dataInspectorHeight`.
|
||||||
|
|
||||||
## Rendering Contract
|
## Rendering Contract
|
||||||
|
|
||||||
Before the chart is drawn, the spec shown in the editor is transformed into the spec actually rendered. Two deterministic transforms are applied in order. They are specified here because reproducing them faithfully is what makes references and fit modes behave correctly; the result is observable as the rendered chart.
|
Before the chart is drawn, the spec shown in the editor is transformed into the spec actually rendered. Two deterministic transforms are applied in order. They are specified here because reproducing them faithfully is what makes references and fit modes behave correctly; the result is observable as the rendered chart.
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ The section is ordered **input → shaping** so the distinction reads at a glanc
|
|||||||
### Data preview
|
### Data preview
|
||||||
|
|
||||||
- The first item in the section: a collapsible, **read-only** sample of the dataset's first rows (capped), with a per-column **type chip** in each header. It lets the user sanity-check inferred types against the actual values _before_ building — exactly when type inference is most likely to surprise. Editing the data is out of scope. A non-tabular payload (a single JSON object, TopoJSON, an unfetched URL) has no rows to show.
|
- The first item in the section: a collapsible, **read-only** sample of the dataset's first rows (capped), with a per-column **type chip** in each header. It lets the user sanity-check inferred types against the actual values _before_ building — exactly when type inference is most likely to surprise. Editing the data is out of scope. A non-tabular payload (a single JSON object, TopoJSON, an unfetched URL) has no rows to show.
|
||||||
- The preview shows the dataset's **raw source rows** — _before_ the filters and calculated fields below are applied; it does not show derived columns. Its position above those controls makes that explicit: it is the input, not the result. (The transformed result is what the right-hand chart renders. A transform-aware "data inspector" that shows the resolved rows is a planned future direction — see [`docs/exploration/data-inspector-exploration.md`](../exploration/data-inspector-exploration.md).)
|
- The preview shows the dataset's **raw source rows** — _before_ the filters and calculated fields below are applied; it does not show derived columns. Its position above those controls makes that explicit: it is the input, not the result. The transformed result is shown by the **data inspector** under the chart on the preview side (below), which switches between input and resolved rows read from the rendered view.
|
||||||
|
|
||||||
### Filters
|
### Filters
|
||||||
|
|
||||||
@@ -147,6 +147,7 @@ A slim strip pinned **under the live preview** — these describe the chart itse
|
|||||||
- Updates are debounced: changes to mark, encodings, or dimensions trigger a re-render after a short pause rather than on every keystroke.
|
- Updates are debounced: changes to mark, encodings, or dimensions trigger a re-render after a short pause rather than on every keystroke.
|
||||||
- While no encoding is mapped, the pane shows a placeholder instructing the user to configure at least one encoding.
|
- While no encoding is mapped, the pane shows a placeholder instructing the user to configure at least one encoding.
|
||||||
- If the spec fails to render, the pane shows an inline error message describing the problem instead of a chart.
|
- If the spec fails to render, the pane shows an inline error message describing the problem instead of a chart.
|
||||||
|
- Under the chart, the same **data inspector** as _Live Preview_ (Input | Resolved rows, collapsed by default) lets the user compare the source rows against what the builder's filters and calculated fields produced. It appears only with a live chart, so it never doubles the placeholder/error.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
|
|||||||
@@ -355,44 +355,8 @@
|
|||||||
color: var(--text-placeholder);
|
color: var(--text-placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewTableWrap {
|
/* The per-column type chip in the source-preview header (the table structure and
|
||||||
max-height: 200px;
|
cell styling now live in the shared DataTable). */
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.previewColName {
|
.previewColName {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
@@ -403,10 +367,6 @@
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewTable tbody tr:last-child td {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.previewEmptyNote {
|
.previewEmptyNote {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ vi.mock('../services/chart-renderer', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
renderSpec: vi.fn(() => Promise.resolve({ destroy() {}, resize() {} })),
|
renderSpec: vi.fn(() =>
|
||||||
|
Promise.resolve({ destroy() {}, resize() {}, inspectData: () => null }),
|
||||||
|
),
|
||||||
ChartTooLargeError,
|
ChartTooLargeError,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
* `LivePreview`, which is bound to the snippet editor's stores.
|
* `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 { useShallow } from 'zustand/react/shallow';
|
||||||
import type { VisualizationSpec } from 'vega-embed';
|
import type { VisualizationSpec } from 'vega-embed';
|
||||||
import {
|
import {
|
||||||
@@ -60,7 +60,7 @@ import {
|
|||||||
type TimeUnit,
|
type TimeUnit,
|
||||||
} from '@core/chart-builder';
|
} from '@core/chart-builder';
|
||||||
import { referencedFields, validateExpression } from '@core/expr-validate';
|
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 type { ColumnType } from '@core/type-inference';
|
||||||
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
|
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
|
||||||
import { chartConfigFor } from '@core/vega-themes';
|
import { chartConfigFor } from '@core/vega-themes';
|
||||||
@@ -77,6 +77,8 @@ import {
|
|||||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||||
import { Button } from './Button';
|
import { Button } from './Button';
|
||||||
import { ColorField } from './ColorField';
|
import { ColorField } from './ColorField';
|
||||||
|
import { DataInspectorPanel } from './DataInspector';
|
||||||
|
import { DataTable } from './DataTable';
|
||||||
import { IconButton } from './IconButton';
|
import { IconButton } from './IconButton';
|
||||||
import { SelectControl } from './SelectControl';
|
import { SelectControl } from './SelectControl';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
@@ -959,43 +961,21 @@ function DataPreview() {
|
|||||||
|
|
||||||
{open &&
|
{open &&
|
||||||
(rows ? (
|
(rows ? (
|
||||||
<div
|
<DataTable
|
||||||
className={styles.previewTableWrap}
|
columns={baseColumns.columns}
|
||||||
tabIndex={0}
|
rows={rows}
|
||||||
role="group"
|
total={dataset.rowCount ?? undefined}
|
||||||
aria-label="Data preview"
|
ariaLabel="Data preview"
|
||||||
>
|
renderHeader={(col) => (
|
||||||
<table className={styles.previewTable}>
|
<>
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
{baseColumns.columns.map((col) => (
|
|
||||||
<th key={col} scope="col">
|
|
||||||
<span className={styles.previewColName}>{col}</span>{' '}
|
<span className={styles.previewColName}>{col}</span>{' '}
|
||||||
<span className={styles.previewColType}>{typeBadge(typeOf(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>
|
|
||||||
) : (
|
) : (
|
||||||
<p className={styles.previewEmptyNote}>This dataset has no tabular rows to preview.</p>
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1083,6 +1063,12 @@ function BuilderPreview() {
|
|||||||
// Set when the chart resolves larger than the canvas backend can draw — a
|
// Set when the chart resolves larger than the canvas backend can draw — a
|
||||||
// physical render-size limit, distinct from the readability cardinality warnings.
|
// physical render-size limit, distinct from the readability cardinality warnings.
|
||||||
const [tooLarge, setTooLarge] = useState<{ heightPx: number; limitPx: number } | null>(null);
|
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 specText = useChartBuilderStore(selectBuilderSpecText);
|
||||||
const valid = useChartBuilderStore(selectBuilderValid);
|
const valid = useChartBuilderStore(selectBuilderValid);
|
||||||
@@ -1104,6 +1090,7 @@ function BuilderPreview() {
|
|||||||
handleRef.current = null;
|
handleRef.current = null;
|
||||||
setError(null);
|
setError(null);
|
||||||
setTooLarge(null);
|
setTooLarge(null);
|
||||||
|
setRenderEpoch((e) => e + 1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
@@ -1134,6 +1121,7 @@ function BuilderPreview() {
|
|||||||
handleRef.current = handle;
|
handleRef.current = handle;
|
||||||
setError(null);
|
setError(null);
|
||||||
setTooLarge(null);
|
setTooLarge(null);
|
||||||
|
setRenderEpoch((e) => e + 1);
|
||||||
const t4 = performance.now();
|
const t4 = performance.now();
|
||||||
// The browser lays out/paints the (possibly huge) SVG after embed resolves;
|
// 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
|
// a double rAF lands just after that paint, capturing the freeze the user
|
||||||
@@ -1154,6 +1142,7 @@ function BuilderPreview() {
|
|||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mine !== generationRef.current) return;
|
if (mine !== generationRef.current) return;
|
||||||
|
setRenderEpoch((epoch) => epoch + 1);
|
||||||
if (e instanceof ChartTooLargeError) {
|
if (e instanceof ChartTooLargeError) {
|
||||||
// A physical render-size limit (canvas max dimension), not a data error.
|
// A physical render-size limit (canvas max dimension), not a data error.
|
||||||
setTooLarge({ heightPx: e.heightPx, limitPx: e.limitPx });
|
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 (
|
return (
|
||||||
<div className={styles.previewPane}>
|
<div className={styles.previewPane}>
|
||||||
{!valid && (
|
{!valid && (
|
||||||
@@ -1215,6 +1209,17 @@ function BuilderPreview() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ import { ThemeBuilderModal } from './ThemeBuilderModal';
|
|||||||
|
|
||||||
vi.mock('../services/chart-renderer', () => ({
|
vi.mock('../services/chart-renderer', () => ({
|
||||||
renderSpec: vi.fn(() =>
|
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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
/**
|
||||||
|
* Data inspector — input vs. resolved rows (spec §04).
|
||||||
|
*
|
||||||
|
* Shows the chart's data with a toggle between two views of the same rendered
|
||||||
|
* view: **Input** (the parsed source rows, before the spec's transforms) and
|
||||||
|
* **Resolved** (the rows the chart draws, after filters / calculated fields /
|
||||||
|
* aggregation). Seeing input → output side by side is how you answer "why is my
|
||||||
|
* chart empty/wrong" — look at what the transforms did to the data. Both tables
|
||||||
|
* come from the live Vega view via the renderer's `RenderHandle.inspectData()`
|
||||||
|
* accessor, passed in as `getData` so this component never touches the view (the
|
||||||
|
* embedding boundary, arch 05).
|
||||||
|
*
|
||||||
|
* `DataInspectorPanel` is the reusable shape (an APG disclosure, mirroring the
|
||||||
|
* builder's source-rows preview); `DataInspector` binds it to the persisted
|
||||||
|
* preview-pane open state for the live-preview pane. The data is read lazily —
|
||||||
|
* only while expanded — because listing the view's datasets serializes them (see
|
||||||
|
* `RenderHandle.inspectData`), so a collapsed inspector costs nothing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import type { InspectedData } from '../services/chart-renderer';
|
||||||
|
import { useAppStore } from '../stores/AppStore';
|
||||||
|
import { DataTable } from './DataTable';
|
||||||
|
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||||
|
import styles from './DataInspector.module.css';
|
||||||
|
|
||||||
|
/** Rows shown before truncating — matches the builder's source-preview cap (spec §06). */
|
||||||
|
const ROW_LIMIT = 50;
|
||||||
|
|
||||||
|
type DataView = 'input' | 'resolved';
|
||||||
|
|
||||||
|
/** The two views, in input → output order (the natural reading direction). */
|
||||||
|
const VIEW_OPTIONS: ReadonlyArray<SegmentedOption<DataView>> = [
|
||||||
|
// `title` doubles as the accessible name (WCAG 2.5.3 label-in-name; leads with
|
||||||
|
// the visible label).
|
||||||
|
{ value: 'input', label: 'Input', title: 'Input — the source rows before the spec’s transforms' },
|
||||||
|
{
|
||||||
|
value: 'resolved',
|
||||||
|
label: 'Resolved',
|
||||||
|
title: 'Resolved — the rows the chart draws, after its transforms',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface DataInspectorPanelProps {
|
||||||
|
/** Whether the panel is expanded. */
|
||||||
|
open: boolean;
|
||||||
|
/** Toggle the expanded state. */
|
||||||
|
onToggle: (open: boolean) => void;
|
||||||
|
/**
|
||||||
|
* Reads the input + resolved rows from the live view, or null when there is no
|
||||||
|
* chart to inspect. Must be stable across renders (the read is memoized on
|
||||||
|
* `renderEpoch`).
|
||||||
|
*/
|
||||||
|
getData: () => InspectedData | null;
|
||||||
|
/** Bumps whenever a render settles, so the open table re-reads the new rows. */
|
||||||
|
renderEpoch: number;
|
||||||
|
/**
|
||||||
|
* Explicit panel height (px) — the live-preview pane sets this from its
|
||||||
|
* resizable divider so the table fills the allotted space. Omitted (the builder)
|
||||||
|
* leaves the table at its default capped height.
|
||||||
|
*/
|
||||||
|
heightPx?: number;
|
||||||
|
/** id of the panel root, for a splitter's `aria-controls` to point at. */
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The reusable disclosure: a toggle bar (APG disclosure — `aria-expanded`,
|
||||||
|
* conditional content, matching the builder's source-rows preview) over an
|
||||||
|
* Input | Resolved view switch and the chosen table. Expanded states, each named
|
||||||
|
* (council: GOV.UK / NN/g — say what happened and the next step):
|
||||||
|
* - no live chart → guidance to render one;
|
||||||
|
* - the chosen view's table is empty → say which side and why (the resolved side's
|
||||||
|
* transforms produced nothing; the input side's source is empty);
|
||||||
|
* - rows → the grid.
|
||||||
|
*/
|
||||||
|
export function DataInspectorPanel({
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
getData,
|
||||||
|
renderEpoch,
|
||||||
|
heightPx,
|
||||||
|
id,
|
||||||
|
}: DataInspectorPanelProps) {
|
||||||
|
const [view, setView] = useState<DataView>('resolved');
|
||||||
|
|
||||||
|
// Read both tables only while open; re-read when a render settles. `renderEpoch`
|
||||||
|
// is an intentional refresh trigger — not read in the body (getData is stable and
|
||||||
|
// always reads the latest view), so exhaustive-deps sees it as unnecessary.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
const data = useMemo(() => (open ? getData() : null), [open, renderEpoch, getData]);
|
||||||
|
const rows = data === null ? null : data[view];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
id={id}
|
||||||
|
className={`${styles.inspector} ${heightPx !== undefined ? styles.fill : ''}`}
|
||||||
|
style={heightPx !== undefined ? { height: heightPx } : undefined}
|
||||||
|
>
|
||||||
|
<div className={styles.bar}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.toggle}
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={() => onToggle(!open)}
|
||||||
|
>
|
||||||
|
<span className={styles.caret} aria-hidden="true">
|
||||||
|
{open ? '▾' : '▸'}
|
||||||
|
</span>
|
||||||
|
Data
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<>
|
||||||
|
<SegmentedControl
|
||||||
|
label="Data view"
|
||||||
|
options={VIEW_OPTIONS}
|
||||||
|
value={view}
|
||||||
|
onChange={setView}
|
||||||
|
/>
|
||||||
|
{rows !== null && rows.length > 0 && (
|
||||||
|
<span className={styles.meta}>
|
||||||
|
{rows.length.toLocaleString()} {rows.length === 1 ? 'row' : 'rows'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open &&
|
||||||
|
(rows === null ? (
|
||||||
|
<p className={styles.stateNote}>Render a chart to inspect its data.</p>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<p className={styles.stateNote}>
|
||||||
|
{view === 'resolved'
|
||||||
|
? 'No rows — the spec’s filters or transforms left nothing to draw.'
|
||||||
|
: 'The source data has no rows.'}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
columns={Object.keys(rows[0])}
|
||||||
|
rows={rows.slice(0, ROW_LIMIT)}
|
||||||
|
total={rows.length}
|
||||||
|
ariaLabel="Data rows"
|
||||||
|
fill={heightPx !== undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataInspectorProps {
|
||||||
|
/** Reads the input + resolved rows from the live preview's view (stable). */
|
||||||
|
getData: () => InspectedData | null;
|
||||||
|
/** Bumps on each settled render so the open table refreshes. */
|
||||||
|
renderEpoch: number;
|
||||||
|
/** Panel height (px) from the preview pane's resizable divider (only when open). */
|
||||||
|
heightPx?: number;
|
||||||
|
/** id of the panel root, for the divider's `aria-controls`. */
|
||||||
|
id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The live-preview data inspector: the panel bound to the persisted open state. */
|
||||||
|
export function DataInspector({ getData, renderEpoch, heightPx, id }: DataInspectorProps) {
|
||||||
|
const open = useAppStore((s) => s.dataInspectorOpen);
|
||||||
|
const setOpen = useAppStore((s) => s.setDataInspectorOpen);
|
||||||
|
return (
|
||||||
|
<DataInspectorPanel
|
||||||
|
open={open}
|
||||||
|
onToggle={setOpen}
|
||||||
|
getData={getData}
|
||||||
|
renderEpoch={renderEpoch}
|
||||||
|
heightPx={heightPx}
|
||||||
|
id={id}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 0–100
|
||||||
|
* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -27,6 +27,10 @@ const H = vi.hoisted(() => ({
|
|||||||
pending: [] as Array<() => void>,
|
pending: [] as Array<() => void>,
|
||||||
destroyed: [] as number[],
|
destroyed: [] as number[],
|
||||||
configs: [] as unknown[],
|
configs: [] as unknown[],
|
||||||
|
inspected: null as {
|
||||||
|
input: ReadonlyArray<Record<string, unknown>>;
|
||||||
|
resolved: ReadonlyArray<Record<string, unknown>>;
|
||||||
|
} | null,
|
||||||
}));
|
}));
|
||||||
vi.mock('../services/chart-renderer', () => ({
|
vi.mock('../services/chart-renderer', () => ({
|
||||||
renderSpec: (node: HTMLElement, _spec: unknown, config: unknown) => {
|
renderSpec: (node: HTMLElement, _spec: unknown, config: unknown) => {
|
||||||
@@ -44,6 +48,7 @@ vi.mock('../services/chart-renderer', () => ({
|
|||||||
H.destroyed.push(id);
|
H.destroyed.push(id);
|
||||||
},
|
},
|
||||||
resize() {},
|
resize() {},
|
||||||
|
inspectData: () => H.inspected,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import { usePreviewStore } from '../stores/PreviewStore';
|
|||||||
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||||
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||||
import { ChartExport } from './ChartExport';
|
import { ChartExport } from './ChartExport';
|
||||||
|
import { DataInspector } from './DataInspector';
|
||||||
|
import { InspectorSplitHandle } from './InspectorSplitHandle';
|
||||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||||
import { SelectControl, type SelectControlOption } from './SelectControl';
|
import { SelectControl, type SelectControlOption } from './SelectControl';
|
||||||
import { RangeControl, SettingRow, SettingsPopover } from './SettingsPopover';
|
import { RangeControl, SettingRow, SettingsPopover } from './SettingsPopover';
|
||||||
@@ -176,6 +178,9 @@ export function LivePreview() {
|
|||||||
const fitMode = useAppStore((s) => s.previewFitMode);
|
const fitMode = useAppStore((s) => s.previewFitMode);
|
||||||
const uiTheme = useAppStore((s) => s.uiTheme);
|
const uiTheme = useAppStore((s) => s.uiTheme);
|
||||||
const chartTheme = useAppStore((s) => s.chartTheme);
|
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
|
// 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.
|
// change keeps the chart live while a selected theme is edited in the builder.
|
||||||
const customThemes = useCustomThemeStore((s) => s.themes);
|
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
|
// a ref change alone wouldn't re-render. Set true on a successful render, false
|
||||||
// on clear/error/unmount.
|
// on clear/error/unmount.
|
||||||
const [chartReady, setChartReady] = useState(false);
|
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
|
// 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
|
// 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?.destroy();
|
||||||
handleRef.current = null;
|
handleRef.current = null;
|
||||||
setChartReady(false);
|
setChartReady(false);
|
||||||
|
setRenderEpoch((e) => e + 1);
|
||||||
setError(null);
|
setError(null);
|
||||||
clearBusy();
|
clearBusy();
|
||||||
return;
|
return;
|
||||||
@@ -306,11 +317,13 @@ export function LivePreview() {
|
|||||||
handleRef.current = handle;
|
handleRef.current = handle;
|
||||||
configRef.current = config;
|
configRef.current = config;
|
||||||
setChartReady(true);
|
setChartReady(true);
|
||||||
|
setRenderEpoch((e) => e + 1);
|
||||||
setError(null);
|
setError(null);
|
||||||
clearBusy();
|
clearBusy();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mine === generationRef.current) {
|
if (mine === generationRef.current) {
|
||||||
setChartReady(false);
|
setChartReady(false);
|
||||||
|
setRenderEpoch((e) => e + 1);
|
||||||
clearBusy();
|
clearBusy();
|
||||||
// A missing dataset reference is not a JSON/spec problem, so it gets a
|
// A missing dataset reference is not a JSON/spec problem, so it gets a
|
||||||
// tailored, fixable message instead of the generic syntax hint (council:
|
// 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
|
// 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
|
// 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;
|
// component's life. Only responsive fit modes depend on container size;
|
||||||
@@ -450,6 +468,19 @@ export function LivePreview() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { Onboarding } from './Onboarding';
|
|||||||
// never touches vega-embed. A resolved no-op handle is enough — Onboarding only
|
// never touches vega-embed. A resolved no-op handle is enough — Onboarding only
|
||||||
// finalizes it on unmount.
|
// finalizes it on unmount.
|
||||||
vi.mock('../services/chart-renderer', () => ({
|
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;
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { useColResizeDrag } from '../hooks/useColResizeDrag';
|
import { useResizeDrag } from '../hooks/useResizeDrag';
|
||||||
import { splitLibraryWidth, splitValue, usePanesStore } from '../stores/PanesStore';
|
import { splitLibraryWidth, splitValue, usePanesStore } from '../stores/PanesStore';
|
||||||
import styles from './ResizeHandle.module.css';
|
import styles from './ResizeHandle.module.css';
|
||||||
|
|
||||||
@@ -57,7 +57,8 @@ export function PaneSplitHandle({ label }: PaneSplitHandleProps) {
|
|||||||
usePanesStore.getState().setSplit(library, avail - library);
|
usePanesStore.getState().setSplit(library, avail - library);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPointerDown = useColResizeDrag(
|
const onPointerDown = useResizeDrag(
|
||||||
|
'x',
|
||||||
() => flanks().lib?.clientWidth ?? 0,
|
() => flanks().lib?.clientWidth ?? 0,
|
||||||
(startLib, delta) => applySplit(startLib + delta),
|
(startLib, delta) => applySplit(startLib + delta),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useLayoutEffect, useRef, useState } from 'react';
|
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 { clampSideWidth, sideWidthValue, usePanesStore, type PaneSide } from '../stores/PanesStore';
|
||||||
import styles from './ResizeHandle.module.css';
|
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
|
// The left handle grows its pane as it moves right; the right handle (left
|
||||||
// of the preview) shrinks the preview as it moves right.
|
// of the preview) shrinks the preview as it moves right.
|
||||||
const onPointerDown = useColResizeDrag(
|
const onPointerDown = useResizeDrag(
|
||||||
|
'x',
|
||||||
() =>
|
() =>
|
||||||
side === 'library'
|
side === 'library'
|
||||||
? usePanesStore.getState().libraryWidth
|
? usePanesStore.getState().libraryWidth
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ import { ThemeBuilderModal } from './ThemeBuilderModal';
|
|||||||
|
|
||||||
vi.mock('../services/chart-renderer', () => ({ renderSpec: vi.fn() }));
|
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;
|
(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', () => ({
|
vi.mock('../services/chart-renderer', () => ({
|
||||||
renderSpec: vi.fn(() =>
|
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
|
* useResizeDrag — the shared pointer gesture behind every drag handle
|
||||||
* (ResizeHandle, PaneSplitHandle).
|
* (ResizeHandle, PaneSplitHandle, InspectorSplitHandle).
|
||||||
*
|
*
|
||||||
* Returns a `pointerdown` handler: on a primary-button press it measures the
|
* 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
|
* gesture's start value, then tracks the pointer on `window` (it leaves the thin
|
||||||
* the thin handle immediately) and feeds each horizontal delta to `apply`. While
|
* handle immediately) and feeds each delta along `axis` to `apply`. While
|
||||||
* dragging, the body is forced to the `col-resize` cursor with text selection
|
* dragging, the body is forced to the matching resize cursor with text selection
|
||||||
* suppressed; release restores both and detaches the listeners.
|
* suppressed; release restores both and detaches the listeners.
|
||||||
*
|
*
|
||||||
* Only the gesture lives here — what "the start value" means (a pane width, a
|
* 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).
|
* keyboard/ARIA half of the splitter contract (it differs per handle).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function useColResizeDrag(
|
export function useResizeDrag(
|
||||||
|
axis: 'x' | 'y',
|
||||||
measureStart: () => number,
|
measureStart: () => number,
|
||||||
apply: (start: number, deltaX: number) => void,
|
apply: (start: number, delta: number) => void,
|
||||||
): (e: React.PointerEvent<HTMLElement>) => void {
|
): (e: React.PointerEvent<HTMLElement>) => void {
|
||||||
|
const coord = (e: { clientX: number; clientY: number }) => (axis === 'x' ? e.clientX : e.clientY);
|
||||||
return (e) => {
|
return (e) => {
|
||||||
if (e.button !== 0) return; // primary button only
|
if (e.button !== 0) return; // primary button only
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const startX = e.clientX;
|
const startPos = coord(e);
|
||||||
const start = measureStart();
|
const start = measureStart();
|
||||||
|
|
||||||
const onMove = (ev: PointerEvent) => apply(start, ev.clientX - startX);
|
const onMove = (ev: PointerEvent) => apply(start, coord(ev) - startPos);
|
||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
window.removeEventListener('pointermove', onMove);
|
window.removeEventListener('pointermove', onMove);
|
||||||
window.removeEventListener('pointerup', onUp);
|
window.removeEventListener('pointerup', onUp);
|
||||||
@@ -33,7 +35,7 @@ export function useColResizeDrag(
|
|||||||
|
|
||||||
window.addEventListener('pointermove', onMove);
|
window.addEventListener('pointermove', onMove);
|
||||||
window.addEventListener('pointerup', onUp);
|
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';
|
document.body.style.userSelect = 'none';
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -32,12 +32,22 @@ const DEFAULT_FIT_MODE: FitMode = 'default';
|
|||||||
/** Spec §04 — the Chart theme picker defaults to the house style. */
|
/** Spec §04 — the Chart theme picker defaults to the house style. */
|
||||||
const DEFAULT_CHART_THEME: ChartThemeSelection = 'astrolabe';
|
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). */
|
/** The managed slice the per-pane settings clusters own (theme + fit live in their own slices). */
|
||||||
export type ManagedSettings = Pick<UserSettings, 'editor' | 'performance' | 'formatting'>;
|
export type ManagedSettings = Pick<UserSettings, 'editor' | 'performance' | 'formatting'>;
|
||||||
|
|
||||||
/** Loose view of the stored record for the per-slice write-through merges. */
|
/** Loose view of the stored record for the per-slice write-through merges. */
|
||||||
interface StoredSettings {
|
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;
|
[k: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,3 +153,32 @@ export function savePreviewFitMode(fitMode: FitMode): void {
|
|||||||
const current = readRaw();
|
const current = readRaw();
|
||||||
writeRaw({ ...current, ui: { ...current.ui, previewFitMode: fitMode } });
|
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 } });
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,8 +11,12 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
loadChartTheme,
|
loadChartTheme,
|
||||||
|
loadDataInspectorHeight,
|
||||||
|
loadDataInspectorOpen,
|
||||||
loadPreviewFitMode,
|
loadPreviewFitMode,
|
||||||
saveChartTheme,
|
saveChartTheme,
|
||||||
|
saveDataInspectorHeight,
|
||||||
|
saveDataInspectorOpen,
|
||||||
savePreviewFitMode,
|
savePreviewFitMode,
|
||||||
} from '../infrastructure/settings-store';
|
} from '../infrastructure/settings-store';
|
||||||
import { useAppStore } from '../stores/AppStore';
|
import { useAppStore } from '../stores/AppStore';
|
||||||
@@ -42,3 +46,31 @@ export function wireChartTheme(): () => void {
|
|||||||
saveChartTheme(state.chartTheme);
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type { VisualizationSpec } from 'vega-embed';
|
|||||||
import type { Config } from 'vega-lite';
|
import type { Config } from 'vega-lite';
|
||||||
import { collectFontFamilies } from '@core/custom-theme';
|
import { collectFontFamilies } from '@core/custom-theme';
|
||||||
import { embedFontsInSvg } from '@core/chart-export';
|
import { embedFontsInSvg } from '@core/chart-export';
|
||||||
|
import { pickResultDataset, pickSourceDataset } from '@core/result-data';
|
||||||
import type { FontAsset } from '@core/font-asset';
|
import type { FontAsset } from '@core/font-asset';
|
||||||
|
|
||||||
/** Options for `RenderHandle.toImageURL` (spec §08 → Per-chart export). */
|
/** Options for `RenderHandle.toImageURL` (spec §08 → Per-chart export). */
|
||||||
@@ -43,6 +44,15 @@ interface ImageExportOptions {
|
|||||||
embedFonts?: ReadonlyArray<FontAsset>;
|
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 {
|
export interface RenderHandle {
|
||||||
/** Finalize the underlying Vega view and clear the node. */
|
/** Finalize the underlying Vega view and clear the node. */
|
||||||
destroy(): void;
|
destroy(): void;
|
||||||
@@ -67,6 +77,21 @@ export interface RenderHandle {
|
|||||||
* (fixed ones have no such handler), which is exactly right for Width/Height.
|
* (fixed ones have no such handler), which is exactly right for Width/Height.
|
||||||
*/
|
*/
|
||||||
resize(): void;
|
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 {
|
export interface RenderOptions {
|
||||||
@@ -235,8 +260,13 @@ export async function renderSpec(
|
|||||||
tooltip: { disableDefaultStyle: true },
|
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 {
|
return {
|
||||||
destroy() {
|
destroy() {
|
||||||
|
finalized = true;
|
||||||
result.view.finalize();
|
result.view.finalize();
|
||||||
node.replaceChildren();
|
node.replaceChildren();
|
||||||
},
|
},
|
||||||
@@ -265,5 +295,24 @@ export async function renderSpec(
|
|||||||
// no-op after destroy().
|
// no-op after destroy().
|
||||||
if (typeof window !== 'undefined') window.dispatchEvent(new Event('resize'));
|
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) };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { beforeEach, describe, expect, test } from 'vitest';
|
import { beforeEach, describe, expect, test } from 'vitest';
|
||||||
import { useAppStore } from './AppStore';
|
import { clampInspectorHeight, inspectorHeightValue, useAppStore } from './AppStore';
|
||||||
|
|
||||||
const store = () => useAppStore.getState();
|
const store = () => useAppStore.getState();
|
||||||
beforeEach(() => store().setTheme('light'));
|
beforeEach(() => store().setTheme('light'));
|
||||||
@@ -18,3 +18,27 @@ describe('theme', () => {
|
|||||||
expect(store().uiTheme).toBe('light');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,6 +4,38 @@ import type { UiTheme } from '@core/theme';
|
|||||||
import type { ChartThemeSelection } from '@core/vega-themes';
|
import type { ChartThemeSelection } from '@core/vega-themes';
|
||||||
import type { ModalName } from '../modals/types';
|
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 0–100 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
|
* Centralized cross-cutting application state, as a Zustand store. Keep this
|
||||||
* lean — durable, feature-specific state (snippets, datasets, settings) lands
|
* lean — durable, feature-specific state (snippets, datasets, settings) lands
|
||||||
@@ -21,6 +53,16 @@ export interface AppState {
|
|||||||
previewFitMode: FitMode;
|
previewFitMode: FitMode;
|
||||||
/** Chart theme selection (spec §04); persisted to Settings as `chartTheme`. */
|
/** Chart theme selection (spec §04); persisted to Settings as `chartTheme`. */
|
||||||
chartTheme: ChartThemeSelection;
|
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. */
|
/** The currently open modal, or null. */
|
||||||
activeModal: ModalName | null;
|
activeModal: ModalName | null;
|
||||||
|
|
||||||
@@ -31,6 +73,10 @@ export interface AppState {
|
|||||||
setPreviewFitMode: (mode: FitMode) => void;
|
setPreviewFitMode: (mode: FitMode) => void;
|
||||||
/** Set the chart theme — the Live Preview settings cluster's action. */
|
/** Set the chart theme — the Live Preview settings cluster's action. */
|
||||||
setChartTheme: (theme: ChartThemeSelection) => void;
|
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`.
|
* Low-level modal setter — the single primitive that mutates `activeModal`.
|
||||||
* High-level open/close (snapshot for unsaved-change detection, URL sync,
|
* High-level open/close (snapshot for unsaved-change detection, URL sync,
|
||||||
@@ -44,11 +90,15 @@ export const useAppStore = create<AppState>((set) => ({
|
|||||||
uiTheme: 'light',
|
uiTheme: 'light',
|
||||||
previewFitMode: 'default',
|
previewFitMode: 'default',
|
||||||
chartTheme: 'astrolabe',
|
chartTheme: 'astrolabe',
|
||||||
|
dataInspectorOpen: false,
|
||||||
|
dataInspectorHeight: DATA_INSPECTOR_DEFAULT_HEIGHT,
|
||||||
activeModal: null,
|
activeModal: null,
|
||||||
|
|
||||||
setTheme: (uiTheme) => set({ uiTheme }),
|
setTheme: (uiTheme) => set({ uiTheme }),
|
||||||
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
|
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
|
||||||
setPreviewFitMode: (previewFitMode) => set({ previewFitMode }),
|
setPreviewFitMode: (previewFitMode) => set({ previewFitMode }),
|
||||||
setChartTheme: (chartTheme) => set({ chartTheme }),
|
setChartTheme: (chartTheme) => set({ chartTheme }),
|
||||||
|
setDataInspectorOpen: (dataInspectorOpen) => set({ dataInspectorOpen }),
|
||||||
|
setDataInspectorHeight: (dataInspectorHeight) => set({ dataInspectorHeight }),
|
||||||
setActiveModal: (activeModal) => set({ activeModal }),
|
setActiveModal: (activeModal) => set({ activeModal }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,8 +3,12 @@ import { App } from './app/App';
|
|||||||
import { initPanes, wirePanes } from './app/orchestration/panes';
|
import { initPanes, wirePanes } from './app/orchestration/panes';
|
||||||
import {
|
import {
|
||||||
initChartTheme,
|
initChartTheme,
|
||||||
|
initDataInspectorHeight,
|
||||||
|
initDataInspectorOpen,
|
||||||
initPreviewFitMode,
|
initPreviewFitMode,
|
||||||
wireChartTheme,
|
wireChartTheme,
|
||||||
|
wireDataInspectorHeight,
|
||||||
|
wireDataInspectorOpen,
|
||||||
wirePreviewFitMode,
|
wirePreviewFitMode,
|
||||||
} from './app/orchestration/preferences';
|
} from './app/orchestration/preferences';
|
||||||
import { initSettings, wireSettings } from './app/orchestration/settings';
|
import { initSettings, wireSettings } from './app/orchestration/settings';
|
||||||
@@ -30,6 +34,10 @@ initPreviewFitMode();
|
|||||||
wirePreviewFitMode();
|
wirePreviewFitMode();
|
||||||
initChartTheme();
|
initChartTheme();
|
||||||
wireChartTheme();
|
wireChartTheme();
|
||||||
|
initDataInspectorOpen();
|
||||||
|
wireDataInspectorOpen();
|
||||||
|
initDataInspectorHeight();
|
||||||
|
wireDataInspectorHeight();
|
||||||
initPanes();
|
initPanes();
|
||||||
wirePanes();
|
wirePanes();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user