From 05df0cd7d37ed2c5aab7cee7253e901af308168e Mon Sep 17 00:00:00 2001
From: Oleh Omelchenko
Date: Thu, 11 Jun 2026 13:01:23 +0300
Subject: [PATCH] Chart builder: filter/calculate transforms, data preview, and
inline expression validation
---
.../05-rendering-theming-preview.md | 7 +
docs/chart-builder-enhancement-scope.md | 83 +++-
docs/data-inspector-exploration.md | 64 +++
docs/spec/06-chart-builder.md | 35 +-
.../components/ChartBuilderModal.module.css | 325 ++++++++++++++
src/app/components/ChartBuilderModal.test.tsx | 127 ++++++
src/app/components/ChartBuilderModal.tsx | 421 +++++++++++++++++-
src/app/stores/ChartBuilderStore.test.ts | 89 ++++
src/app/stores/ChartBuilderStore.ts | 145 +++++-
src/core/chart-builder.test.ts | 234 ++++++++++
src/core/chart-builder.ts | 271 ++++++++++-
src/core/expr-validate.test.ts | 39 ++
src/core/expr-validate.ts | 106 +++++
13 files changed, 1919 insertions(+), 27 deletions(-)
create mode 100644 docs/data-inspector-exploration.md
create mode 100644 src/core/expr-validate.test.ts
create mode 100644 src/core/expr-validate.ts
diff --git a/docs/architecture/05-rendering-theming-preview.md b/docs/architecture/05-rendering-theming-preview.md
index a2cd1fa..c849343 100644
--- a/docs/architecture/05-rendering-theming-preview.md
+++ b/docs/architecture/05-rendering-theming-preview.md
@@ -40,6 +40,13 @@ above it is data; everything below it is a Vega `View` we own and must tear down
`subscribe` listener), not as reactive state itself.
- **Don't** scatter `vegaEmbed(...)` calls across components.
+> **Forward note — data inspector.** A planned vega-editor-style inspector (show the
+> _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
+> boundary: a `RenderHandle.data()` accessor wraps the view (like `toImageURL`), so no
+> component touches the raw view. Parked, not built — see
+> [`docs/data-inspector-exploration.md`](../data-inspector-exploration.md).
+
---
## 2. vega-embed Integration
diff --git a/docs/chart-builder-enhancement-scope.md b/docs/chart-builder-enhancement-scope.md
index 6bcae99..11b13d8 100644
--- a/docs/chart-builder-enhancement-scope.md
+++ b/docs/chart-builder-enhancement-scope.md
@@ -25,6 +25,51 @@
Newest first. The at-a-glance build-order tracker is §4; per-item detail is §3. This log is
the quick "where are we" — read it first.
+- **2026-06-11** — **1C + 1D + 1E shipped (the Data section). Up next: Phase 2 (2A value-or-field channels, then 2B field shelf).**
+ - A new **Data section** at the top of the builder's left pane — "here are your rows;
+ shape them, then encode them" — emits the spec's top-level `transform` array.
+ - **1C · Filters** — a guarded **field + operator + value** predicate shelf. Operators
+ narrow by field type (`validFilterOps`): a measure/temporal field offers ordering
+ (`< ≤ > ≥`) + `is between`; a category offers `is` / `is not` / `is one of`. Values
+ coerce by type (quantitative → number; others → string, so ISO dates sort right).
+ `notEqual` emits a `{ not: { …equal } }` wrapper. A reversible **expression** power-mode
+ takes a raw `datum.…` predicate. Incomplete filters are skipped so the preview keeps
+ rendering. Multiple filters AND together. Pure core (`buildTransforms`,
+ `validFilterOps`, `filterOpArity`), tested.
+ - **1C · Calculated fields** — `{ calculate, as }` derived columns. A named field appears
+ in the channel dropdowns via `effectiveColumns` (defaults Quantitative); emitted
+ **before** filters (a row-wise calculate is order-independent, so calc-first is
+ equivalent and lets filters reference derived fields). Removing/renaming a referenced
+ field clears the dangling channel (`pruneEncodings`, in the store on calc edit/remove).
+ - **1E · Expression validation** — new pure core `expr-validate.ts` using Vega's own
+ `parseExpression` (already in the `vega` chunk, so ~zero bundle cost): inline syntax
+ errors on both expression inputs, plus a soft **unknown-field** warning when a
+ `datum.` reference doesn't match a column (`referencedFields` walks the AST).
+ Field discoverability is served by **dataset-derived placeholder examples** (e.g.
+ `datum.revenue * 2`); a full Monaco-style completion popup is **deferred** (a bare
+ `` doesn't warrant it — noted, not built).
+ - **1D · Data preview** — a collapsible, read-only first-N-rows table with a per-column
+ **type chip** in each header, to sanity-check inferred types before building (reuses
+ core `tabularRows`). Default collapsed; the scroll region is keyboard-reachable
+ (`tabIndex=0` + labelled group — avoids the Datasets-manager a11y gap).
+ - **Spec §06** gained a "Data (filters, calculated fields, preview)" section; the Layout
+ and Output blocks cross-reference it.
+ - **Council not yet run** on the new error/disclosure copy (the soft auto-fire surface:
+ expression-error + unknown-field copy, the preview disclosure). Conventions were matched
+ to the existing warnings region (arch 10 §5) and `SettingsPopover` disclosure; flag for a
+ council pass on review if desired.
+ - **Expression reference:** a contextual link to the Vega expression-language docs is
+ shown when an expression input is in play (a calculated field, or a filter in
+ expression mode) — the place the user needs to know the available functions/operators.
+ - **Verified:** `typecheck` + `test` (full suite green; +103 new core/store cases, +4 modal
+ smoke tests) + `eslint` + `build` (PWA, 45 precache entries). **Owed:** a manual/visual
+ pass against the live builder (filter shelf, calc → channel, expr errors, preview
+ table) — tests don't cover what the surface looks/feels like.
+ - **Surfaced direction (now parked):** the 1D preview shows **raw source** rows; a
+ transform-aware **data inspector** (resolved rows, à la vega-editor, in the builder
+ _and_ below the main Live Preview) is the wanted evolution — documented in
+ **[`docs/data-inspector-exploration.md`](./data-inspector-exploration.md)**, deferred.
+
- **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
(distinct from the workspace Export) — **Copy spec** + **Download JSON** (`.vl.json`) of
@@ -34,17 +79,15 @@ the quick "where are we" — read it first.
`view`. Filenames derive from the snippet name — filesystem-safe, script-preserving
(pure `core/chart-export.ts`, tested). Home: the **preview header**, not the plan's
"library row / editor toolbar" — image export needs the live view; the
- disclosure-of-controls (not an ARIA menu) mirrors `SettingsPopover`.
- - **Export options (from first-round feedback):** PNG **Resolution** `1×/2×/3×` is a
- multiplier of `devicePixelRatio`, so the default `1×` is Retina-crisp — the soft-1×
- export was a dpr bug (raw `toImageURL` scaleFactor ignores dpr). **Background**
- `Theme`(default)/`White`/`None` fixes transparent PNGs (the chart config is
- transparent so the on-screen pane colour shows; export composites the chosen colour
- under the PNG / adds an SVG ``). **Referenced data** `Inline`(default)/`Keep
+ disclosure-of-controls (not an ARIA menu) mirrors `SettingsPopover`. - **Export options (from first-round feedback):** PNG **Resolution** `1×/2×/3×` is a
+ multiplier of `devicePixelRatio`, so the default `1×` is Retina-crisp — the soft-1×
+ export was a dpr bug (raw `toImageURL` scaleFactor ignores dpr). **Background**
+ `Theme`(default)/`White`/`None` fixes transparent PNGs (the chart config is
+ transparent so the on-screen pane colour shows; export composites the chosen colour
+ under the PNG / adds an SVG ``). **Referenced data** `Inline`(default)/`Keep
refs` (shown only when the spec references saved datasets) inlines dataset values so
- the exported spec renders standalone (`inlineReferencedDatasets`, tested).
- - Spec §08 gained a _Per-chart export_ section (with the options); §04 cross-references
- it; `architecture/05` §2 records the handle's dpr-aware scale + background compositing.
+ the exported spec renders standalone (`inlineReferencedDatasets`, tested). - Spec §08 gained a _Per-chart export_ section (with the options); §04 cross-references
+ it; `architecture/05` §2 records the handle's dpr-aware scale + background compositing.
- **1A · Actionable hints** shipped: one-click fixes on guidance warnings
(`BuilderWarning.fixes` + `applyWarningFix`), council-reviewed, with focus/announce a11y.
- **Builder UX/perf batch** (from dogfooding the Superstore dataset) shipped: near-fullscreen
@@ -152,7 +195,7 @@ chart" reads best beside the chart. The widget is a disclosure-of-action-buttons
ARIA menu), mirroring `SettingsPopover` and sharing its single-open registry. _Spec impact:
new §08 "Per-chart export" section; §04 cross-reference; `architecture/05` §2 handle note._
-**1C · Filter (+ Calculate) dataset transforms** — _closes the loop the builder's own warnings open_
+**1C · Filter (+ Calculate) dataset transforms** — _done (2026-06-11; see status log)_
The transform layer the builder doesn't touch: top-level `transform: []`. Source: Lyra
§3.12. The builder _already tells users to filter_ in three warnings
(`chart-builder.ts:475,476,511`) while offering no way to do it.
@@ -171,7 +214,7 @@ Home: a new **"Data" section in the builder's left pane, above the channels** (`
your rows [+ Filter] [+ Calculate] → now encode them`), paired with 1D. _Spec impact: new
§06 "Data / transforms" subsection — this is genuinely new behaviour, write it._
-**1D · Data-table preview** — _closes a confirmed gap; pairs with 1C_
+**1D · Data-table preview** — _done (2026-06-11; see status log)_
Neither the builder nor the Datasets manager ever shows the **actual rows**. Source: Lyra
§3.2. A compact, **read-only** first-N-rows grid with a **per-column type chip** in each
header lets users sanity-check inferred types _before_ building — exactly when inference is
@@ -180,7 +223,7 @@ only need the row sample. Homes: a collapsible "Data" strip in the builder's lef
(under the dataset name) and/or the Datasets manager. Keep it read-only (editing data is
out of scope). _Spec impact: §06 + §05 (Datasets) additions._
-**1E · Inline expression validation + field autocomplete** — _build with 1C, not standalone_
+**1E · Inline expression validation + field autocomplete** — _done (2026-06-11; completion popup deferred)_
When 1C's expression mode lands, validate the Vega/VL expression string with the library's
own `parseExpr` (Lyra §3.6) and surface errors inline; autocomplete the dataset's own
column names (we have the schema from `profile.ts`) (Lyra §3.10). Record the `parseExpr`
@@ -306,6 +349,12 @@ 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).
- **Lookup / join a second dataset** — Lyra §3.12; data-model change (multi-dataset
snippets). Larger, separate effort.
+- **Transform-aware data inspector** — evolve 1D from raw-source rows to the _resolved,
+ post-transform_ data (filtered rows + calculated columns), and generalize it to a
+ togglable data panel below the **main** Live Preview (vega-editor's "Data Viewer", a
+ debugging aid for any snippet, not only builder output). Reads runtime rows via
+ `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)**.
---
@@ -314,10 +363,10 @@ guardrail: _promote a control only when it is **both common AND awkward in JSON*
```
Phase 1 1A actionable hints ✓ done
1B per-chart export ✓ done
- 1C filter (+ calculate) ← next: closes the loop on warnings the builder already emits
- 1D data preview ← pairs with 1C
- 1E expr-validate + autocomplete (with 1C)
-Phase 2 2A value-or-field channels (Property model)
+ 1C filter (+ calculate) ✓ done
+ 1D data preview ✓ done
+ 1E expr-validate ✓ done (syntax + unknown-field; completion popup deferred)
+Phase 2 2A value-or-field channels (Property model) ← next
2B field shelf + in-place type cycling ← Tier-C substrate
Phase 3 3A intent-first front door (Tier C) ← built on 2B; the defining feature
3B starter examples
diff --git a/docs/data-inspector-exploration.md b/docs/data-inspector-exploration.md
new file mode 100644
index 0000000..bcf104d
--- /dev/null
+++ b/docs/data-inspector-exploration.md
@@ -0,0 +1,64 @@
+# 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".
diff --git a/docs/spec/06-chart-builder.md b/docs/spec/06-chart-builder.md
index 092427b..dd07c06 100644
--- a/docs/spec/06-chart-builder.md
+++ b/docs/spec/06-chart-builder.md
@@ -14,9 +14,40 @@ The Chart Builder is a visual, no-JSON way to compose a Vega-Lite chart from a s
A two-pane modal:
-- **Left — configuration:** dataset name, mark type selector, one row per encoding channel, optional width/height inputs, and a "Create Snippet" action.
+- **Left — configuration:** dataset name, a **Data** section (row filters, calculated fields, and a collapsible row preview — see _Data_ below), mark type selector, one row per encoding channel, optional width/height inputs, and a "Create Snippet" action.
- **Right — live preview:** a rendered chart that updates as the configuration changes, with a placeholder/error area.
+## Data (preview, filters, calculated fields)
+
+A **Data** section sits at the top of the configuration pane — "here are your rows; shape them, then encode them". It previews the source rows first, then offers controls to shape them before encoding. Everything here is optional; a chart can be built with none of it. The shaping controls emit the spec's top-level `transform` array (see _Output_).
+
+The section is ordered **input → shaping** so the distinction reads at a glance: the row preview (the **input** data) comes first, the filters and calculated fields (which shape what the chart actually draws) come below it.
+
+### 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 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/data-inspector-exploration.md`](../data-inspector-exploration.md).)
+
+### Filters
+
+- A list of row filters, each added via **Add filter** and removable. All filters combine (logical **AND**) and apply to the raw rows **before** any encoding aggregation, so "filter rows, then aggregate" is the natural reading.
+- A filter is, by default, a **guarded predicate** — a **field**, an **operator**, and a **value** — needing no expression for the common case:
+ - The **field** dropdown offers the dataset's columns plus any calculated fields.
+ - The **operators** offered depend on the field's type: a measure or temporal field offers `is` / `is not` / `<` / `≤` / `>` / `≥` / `is between` (two bounds); a category offers `is` / `is not` / `is one of` (a comma-separated membership list). A quantitative value compares as a number; other types compare as text (ISO dates sort correctly as text).
+- A filter can be switched to an **expression** power-mode — a raw Vega predicate expression (e.g. `datum.value > 0`) — for what the guarded shelf can't say. The toggle is reversible.
+- An **incomplete** filter (no value yet, a blank range bound, an empty expression) is ignored, so the live preview keeps rendering while the user types.
+
+### Calculated fields
+
+- A list of derived fields, each added via **Add field** and removable: a **name** and a **Vega expression** that produces a new column (e.g. `profit` = `datum.revenue - datum.cost`).
+- A named calculated field appears in the encoding **channel dropdowns** like any real column (it defaults to **Quantitative**, the common arithmetic case, and its type can be overridden on the channel within the valid set). Calculated fields are applied **before** filters, so a filter may reference a derived field.
+- Removing or renaming a calculated field that a channel referenced **clears that channel** (the produced spec never encodes a field that no longer exists).
+
+### Expression validation
+
+- Both expression inputs (a filter's expression mode, a calculated field) are validated with **Vega's own expression parser** — the same one the chart uses — so a syntax error is reported **inline** the moment it appears, matching exactly what the chart would accept.
+- A `datum.` reference that does not match a known column raises a soft **"unknown field"** warning (a typo guard) without blocking — the value is genuinely valid Vega, it just won't resolve.
+
## Inputs and Controls
### Mark type
@@ -93,7 +124,7 @@ A clean configuration shows no hints.
Selecting "Create Snippet" produces the final artifact:
-- Builds a complete Vega-Lite spec containing: the schema reference, a named data reference to the dataset, the chosen mark (with tooltips enabled), the mapped encodings (each with its field and field type, plus any aggregate / bin / `timeUnit` transform), chart-level sort and stacking where set, and any explicit width/height.
+- Builds a complete Vega-Lite spec containing: the schema reference, a named data reference to the dataset, any top-level `transform` (calculated fields first, then row filters — see _Data_), the chosen mark (with tooltips enabled), the mapped encodings (each with its field and field type, plus any aggregate / bin / `timeUnit` transform), chart-level sort and stacking where set, and any explicit width/height.
- Channels left unmapped are omitted; if no encodings exist the spec omits the encoding block entirely (prevented by validation here).
- Creates a new snippet from that spec with an auto-generated descriptive name, adds it to the snippet library, and records that it was built from the dataset.
- Links the snippet to the dataset by recording the dataset reference, so the bidirectional snippet↔dataset relationship is established (see _Datasets_).
diff --git a/src/app/components/ChartBuilderModal.module.css b/src/app/components/ChartBuilderModal.module.css
index 4005832..9552301 100644
--- a/src/app/components/ChartBuilderModal.module.css
+++ b/src/app/components/ChartBuilderModal.module.css
@@ -43,6 +43,331 @@
color: var(--text);
}
+/* ── Data section: filters, calculated fields, row preview (spec §06 → Data) ──── */
+
+.dataSection {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+}
+
+.transformGroup {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.transformGroupHead {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+/* "Add filter" / "Add field" — a low-emphasis ghost action, like Swap X/Y. */
+.addRow {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ border: none;
+ background: transparent;
+ color: var(--accent);
+ font: inherit;
+ font-size: 12px;
+ cursor: pointer;
+ padding: var(--space-1) var(--space-2);
+ border-radius: var(--radius);
+}
+
+.addRow:hover {
+ background: var(--layer-01);
+}
+
+.addRow:focus-visible {
+ outline: 2px solid var(--focus);
+ outline-offset: 1px;
+}
+
+/* A filter / calculated-field row, bordered like a channel block for visual rhyme. */
+.transformBlock {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ padding: var(--space-3);
+ border: var(--border-width) solid var(--border);
+ border-radius: var(--radius);
+}
+
+.transformBlockTop {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.filterField {
+ flex: 1;
+ min-width: 0;
+ padding: var(--space-2) var(--space-3);
+ border: var(--border-width) solid var(--border-strong);
+ border-radius: var(--radius);
+ background: var(--bg);
+ color: var(--text);
+ font: inherit;
+ font-size: 13px;
+}
+
+.filterPredicate {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.filterPredicate .mini {
+ flex: none;
+}
+
+.valueInput {
+ flex: 1;
+ min-width: 0;
+ padding: var(--space-1) var(--space-2);
+ border: var(--border-width) solid var(--border-strong);
+ border-radius: var(--radius);
+ background: var(--bg);
+ color: var(--text);
+ font: inherit;
+ font-size: 13px;
+}
+
+.rangeDash {
+ flex: none;
+ color: var(--text-placeholder);
+}
+
+/* Expression inputs (filter expression / calculate) — monospace, fill the row. */
+.exprInput {
+ flex: 1;
+ min-width: 0;
+ padding: var(--space-2) var(--space-3);
+ border: var(--border-width) solid var(--border-strong);
+ border-radius: var(--radius);
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--font-mono);
+ font-size: 12px;
+}
+
+.calcName {
+ flex: 0 1 110px;
+ min-width: 0;
+ padding: var(--space-2) var(--space-3);
+ border: var(--border-width) solid var(--border-strong);
+ border-radius: var(--radius);
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--font-mono);
+ font-size: 12px;
+}
+
+.calcEquals {
+ flex: none;
+ color: var(--text-placeholder);
+ font-family: var(--font-mono);
+}
+
+.filterField:focus-visible,
+.calcName:focus-visible,
+.valueInput:focus-visible,
+.exprInput:focus-visible {
+ outline: 2px solid var(--focus);
+ outline-offset: -1px;
+}
+
+.exprInput[aria-invalid='true'] {
+ border-color: var(--support-error);
+}
+
+/* Remove-row × — icon-only, quiet until hovered. */
+.removeRow {
+ flex: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border: none;
+ background: transparent;
+ color: var(--text-placeholder);
+ cursor: pointer;
+ border-radius: var(--radius);
+}
+
+.removeRow:hover {
+ background: var(--layer-01);
+ color: var(--text);
+}
+
+.removeRow:focus-visible {
+ outline: 2px solid var(--focus);
+ outline-offset: 1px;
+}
+
+/* Expression escape-hatch toggle — a low-emphasis link, like the hint fixes. */
+.modeToggle {
+ align-self: flex-start;
+ border: none;
+ background: transparent;
+ color: var(--accent);
+ font: inherit;
+ font-size: 11px;
+ cursor: pointer;
+ padding: 2px var(--space-1);
+ border-radius: var(--radius);
+}
+
+.modeToggle:hover {
+ background: var(--layer-01);
+}
+
+.modeToggle:focus-visible {
+ outline: 2px solid var(--focus);
+ outline-offset: 1px;
+}
+
+.exprError,
+.exprWarn {
+ margin: 0;
+ font-size: 11px;
+ line-height: 1.4;
+}
+
+.exprError {
+ color: var(--support-error);
+}
+
+.exprWarn {
+ color: var(--support-warning-fg);
+}
+
+/* Contextual pointer to the Vega expression docs (shown when an expression exists). */
+.exprHelp {
+ margin: 0;
+ font-size: 11px;
+ line-height: 1.4;
+ color: var(--text-secondary);
+}
+
+.exprHelpLink {
+ color: var(--accent);
+ text-decoration: none;
+ border-radius: var(--radius);
+}
+
+.exprHelpLink:hover {
+ text-decoration: underline;
+}
+
+.exprHelpLink:focus-visible {
+ outline: 2px solid var(--focus);
+ outline-offset: 2px;
+}
+
+/* Row preview disclosure (1D). */
+.dataPreview {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+}
+
+.previewToggle {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ align-self: flex-start;
+ 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);
+}
+
+.previewToggle:hover {
+ background: var(--layer-01);
+}
+
+.previewToggle:focus-visible {
+ outline: 2px solid var(--focus);
+ outline-offset: 1px;
+}
+
+.previewCaret {
+ font-size: 10px;
+ color: var(--text-placeholder);
+}
+
+.previewMeta {
+ color: var(--text-placeholder);
+}
+
+.previewTableWrap {
+ max-height: 200px;
+ overflow: auto;
+ border: var(--border-width) solid var(--border);
+ border-radius: var(--radius);
+}
+
+.previewTableWrap:focus-visible {
+ outline: 2px solid var(--focus);
+ outline-offset: -1px;
+}
+
+.previewTable {
+ border-collapse: collapse;
+ width: 100%;
+ font-family: var(--font-mono);
+ font-size: 11px;
+ line-height: 1.4;
+}
+
+.previewTable th,
+.previewTable td {
+ text-align: left;
+ padding: var(--space-1) var(--space-2);
+ border-bottom: var(--border-width) solid var(--border);
+ white-space: nowrap;
+ max-width: 180px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.previewTable thead th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background: var(--layer-01);
+}
+
+.previewColName {
+ font-weight: 600;
+ color: var(--text-secondary);
+}
+
+.previewColType {
+ color: var(--text-placeholder);
+ font-weight: 400;
+}
+
+.previewTable tbody tr:last-child td {
+ border-bottom: none;
+}
+
+.previewEmptyNote {
+ margin: 0;
+ font-size: 11px;
+ color: var(--text-secondary);
+}
+
.field {
display: flex;
flex-direction: column;
diff --git a/src/app/components/ChartBuilderModal.test.tsx b/src/app/components/ChartBuilderModal.test.tsx
index 1412f3f..1cb7853 100644
--- a/src/app/components/ChartBuilderModal.test.tsx
+++ b/src/app/components/ChartBuilderModal.test.tsx
@@ -162,4 +162,131 @@ describe('ChartBuilderModal', () => {
});
expect(container.textContent).toContain('No dataset loaded');
});
+
+ test('a complete filter row reaches the renderer as a top-level transform (1C)', async () => {
+ vi.useFakeTimers();
+ const { renderSpec } = await import('../services/chart-renderer');
+ vi.mocked(renderSpec).mockClear();
+ const ds = createDataset({
+ name: 'Sales',
+ data: [
+ { region: 'N', revenue: 100 },
+ { region: 'S', revenue: 50 },
+ ],
+ format: 'json',
+ source: 'inline',
+ now: T,
+ });
+ useDatasetStore.getState().add(ds);
+ const id = useDatasetStore.getState().datasets[0].id;
+ const store = useChartBuilderStore.getState();
+ store.init(id);
+ store.addFilter();
+ const fid = useChartBuilderStore.getState().config.filters![0].id;
+ store.setFilterField(fid, 'revenue');
+ store.updateFilter(fid, { op: 'gt', value: '60' });
+
+ await act(async () => {
+ root.render();
+ await Promise.resolve();
+ });
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(400); // drive the debounced preview render
+ });
+
+ expect(container.querySelector('select[aria-label="Filter operator"]')).toBeTruthy();
+ const calls = vi.mocked(renderSpec).mock.calls;
+ const lastSpec = calls[calls.length - 1][1] as { transform?: unknown };
+ expect(lastSpec.transform).toEqual([{ filter: { field: 'revenue', gt: 60 } }]);
+ vi.useRealTimers();
+ });
+
+ test('the data preview discloses the dataset rows on demand (1D)', async () => {
+ const ds = createDataset({
+ name: 'Sales',
+ data: [{ region: 'North', revenue: 100 }],
+ format: 'json',
+ source: 'inline',
+ now: T,
+ });
+ useDatasetStore.getState().add(ds);
+ const id = useDatasetStore.getState().datasets[0].id;
+ useChartBuilderStore.getState().init(id);
+
+ await act(async () => {
+ root.render();
+ await Promise.resolve();
+ });
+
+ const toggle = Array.from(container.querySelectorAll('button')).find((b) =>
+ b.textContent?.includes('Preview rows'),
+ );
+ expect(toggle).toBeTruthy();
+ expect(container.querySelector('table')).toBeNull(); // collapsed by default
+
+ await act(async () => {
+ toggle!.click();
+ await Promise.resolve();
+ });
+
+ expect(container.querySelector('table')).toBeTruthy();
+ expect(container.textContent).toContain('region');
+ expect(container.textContent).toContain('North');
+ });
+
+ test('an invalid expression-mode filter surfaces an inline parser error (1E)', async () => {
+ const ds = createDataset({
+ name: 'S',
+ data: [{ a: 1 }],
+ format: 'json',
+ source: 'inline',
+ now: T,
+ });
+ useDatasetStore.getState().add(ds);
+ const id = useDatasetStore.getState().datasets[0].id;
+ const store = useChartBuilderStore.getState();
+ store.init(id);
+ store.addFilter();
+ const fid = useChartBuilderStore.getState().config.filters![0].id;
+ store.setFilterMode(fid, 'expression');
+ store.updateFilter(fid, { expr: 'datum.a *' });
+
+ await act(async () => {
+ root.render();
+ await Promise.resolve();
+ });
+
+ const alert = container.querySelector('[role="alert"]');
+ expect(alert?.textContent).toMatch(/nexpected|Invalid/);
+ });
+
+ test('the Vega expression reference shows only when an expression is in play (1E)', async () => {
+ const ds = createDataset({
+ name: 'S',
+ data: [{ a: 1 }],
+ format: 'json',
+ source: 'inline',
+ now: T,
+ });
+ useDatasetStore.getState().add(ds);
+ const id = useDatasetStore.getState().datasets[0].id;
+ useChartBuilderStore.getState().init(id);
+
+ await act(async () => {
+ root.render();
+ await Promise.resolve();
+ });
+ const refLink = () =>
+ Array.from(container.querySelectorAll('a')).find((a) =>
+ a.textContent?.includes('Vega expression'),
+ );
+ expect(refLink()).toBeUndefined(); // no expression yet → no reference link
+
+ await act(async () => {
+ useChartBuilderStore.getState().addCalculate(); // a calculated field is an expression
+ await Promise.resolve();
+ });
+ expect(refLink()).toBeDefined();
+ expect(refLink()!.getAttribute('href')).toContain('vega.github.io');
+ });
});
diff --git a/src/app/components/ChartBuilderModal.tsx b/src/app/components/ChartBuilderModal.tsx
index 554f4cf..562dc64 100644
--- a/src/app/components/ChartBuilderModal.tsx
+++ b/src/app/components/ChartBuilderModal.tsx
@@ -24,6 +24,8 @@ import {
TIME_UNITS,
builderWarnings,
defaultFieldType,
+ effectiveColumns,
+ filterOpArity,
isBuilderConfigValid,
isChannelTypeAllowed,
supportsAggregate,
@@ -32,14 +34,21 @@ import {
supportsStack,
supportsTimeUnit,
validFieldTypes,
+ validFilterOps,
+ type BuilderCalculate,
type AggregateOp,
+ type BuilderColumns,
+ type BuilderFilter,
type BuilderWarningFix,
type ChannelMapping,
type ChannelName,
type FieldType,
+ type FilterOp,
type MarkType,
type TimeUnit,
} from '@core/chart-builder';
+import { referencedFields, validateExpression } from '@core/expr-validate';
+import { tabularRows } from '@core/dataset';
import type { ColumnType } from '@core/type-inference';
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
import { chartConfigFor } from '@core/vega-themes';
@@ -131,6 +140,57 @@ const TIME_UNIT_LABELS: Record = {
hours: 'Hour',
};
+/** Readable labels for the filter operators, phrased to read as "". */
+const FILTER_OP_LABELS: Record = {
+ equal: 'is',
+ notEqual: 'is not',
+ lt: '<',
+ lte: '≤',
+ gt: '>',
+ gte: '≥',
+ range: 'is between',
+ oneOf: 'is one of',
+};
+
+/** Rows shown in the builder's data-preview table before truncating (1D, spec §06). */
+const PREVIEW_ROW_LIMIT = 50;
+
+/**
+ * The Vega expression-language reference — both expression inputs (a filter's
+ * expression mode, a calculated field) compile to a raw Vega expression, so this is
+ * the precise vocabulary. Surfaced contextually (only when an expression is in play),
+ * external so it falls outside offline scope, opened in a new tab.
+ */
+const VEGA_EXPRESSION_DOCS_URL = 'https://vega.github.io/vega/docs/expressions/';
+
+/** One preview cell's text: blank for empty, the string as-is, else JSON. */
+function cellText(value: unknown): string {
+ if (value == null) return '';
+ if (typeof value === 'string') return value;
+ return JSON.stringify(value);
+}
+
+/** A safe `datum` accessor for a column name (dot for identifiers, bracket otherwise). */
+function datumRef(name: string): string {
+ return /^[A-Za-z_$][\w$]*$/.test(name) ? `datum.${name}` : `datum[${JSON.stringify(name)}]`;
+}
+
+/**
+ * An example expression seeded as the input placeholder, drawn from the dataset's own
+ * columns — discovery for writing Vega expressions without an autocomplete popup. A
+ * filter example reads as a predicate; a calculate example as a derived value.
+ */
+function exprPlaceholder(columns: BuilderColumns, kind: 'filter' | 'calc'): string {
+ const numeric = columns.columnTypes.find((c) => c.type === 'number')?.name;
+ const anyField = columns.columns[0];
+ if (kind === 'filter') {
+ if (numeric) return `${datumRef(numeric)} > 0`;
+ return anyField ? `${datumRef(anyField)} != null` : 'datum.value > 0';
+ }
+ if (numeric) return `${datumRef(numeric)} * 2`;
+ return anyField ? datumRef(anyField) : 'datum.a + datum.b';
+}
+
/** A compact type indicator for a column option (# / date / bool / text). */
function typeBadge(type: ColumnType): string {
switch (type) {
@@ -156,7 +216,14 @@ function isCount(mapping: ChannelMapping | null): boolean {
}
function ChannelBlock({ channel }: { channel: ChannelName }) {
- const columns = useChartBuilderStore((s) => s.columns);
+ const baseColumns = useChartBuilderStore((s) => s.columns);
+ const calculates = useChartBuilderStore((s) => s.config.calculates);
+ // Effective columns = the dataset's columns plus any calculated fields, so a derived
+ // field is selectable on a channel like any real column.
+ const columns = useMemo(
+ () => effectiveColumns(baseColumns, calculates),
+ [baseColumns, calculates],
+ );
const mapping = useChartBuilderStore((s) => s.config.encodings[channel] ?? null);
const setChannelColumn = useChartBuilderStore((s) => s.setChannelColumn);
const setChannelType = useChartBuilderStore((s) => s.setChannelType);
@@ -277,6 +344,356 @@ function ChannelBlock({ channel }: { channel: ChannelName }) {
);
}
+/**
+ * Inline feedback for an expression input (filter expression / calculated field):
+ * a parse error (assertive) takes priority, else a soft warning for `datum.`
+ * references that don't match a known column — a typo guard before the chart renders
+ * empty (1E). Nothing renders for a valid, fully-resolved expression.
+ */
+function ExprFeedback({ expr, columns }: { expr: string; columns: BuilderColumns }) {
+ const feedback = useMemo(() => {
+ const validation = validateExpression(expr);
+ if (!validation.valid) {
+ return { kind: 'error' as const, text: validation.error ?? 'Invalid expression.' };
+ }
+ const unknown = referencedFields(expr).filter((f) => !columns.columns.includes(f));
+ if (unknown.length > 0) {
+ const plural = unknown.length > 1 ? 's' : '';
+ return { kind: 'warn' as const, text: `Unknown field${plural}: ${unknown.join(', ')}` };
+ }
+ return null;
+ }, [expr, columns]);
+
+ if (!feedback) return null;
+ return feedback.kind === 'error' ? (
+
+ {feedback.text}
+
+ ) : (
+
+ {feedback.text}
+
+ );
+}
+
+/**
+ * A contextual pointer to the Vega expression vocabulary, shown only when an
+ * expression input is in play (a calculated field, or a filter in expression mode) —
+ * the place the user needs to know what functions/operators exist.
+ */
+function ExprHelp() {
+ return (
+