Chart builder: filter/calculate transforms, data preview, and inline expression validation

This commit is contained in:
2026-06-11 13:01:23 +03:00
parent 1791ee9f8d
commit 05df0cd7d3
13 changed files with 1919 additions and 27 deletions
@@ -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. `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
> _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 ## 2. vega-embed Integration
+60 -11
View File
@@ -25,6 +25,51 @@
Newest first. The at-a-glance build-order tracker is §4; per-item detail is §3. This log is 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. 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.<field>` 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
`<input>` 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).** - **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
(distinct from the workspace Export) — **Copy spec** + **Download JSON** (`.vl.json`) of (distinct from the workspace Export) — **Copy spec** + **Download JSON** (`.vl.json`) of
@@ -34,16 +79,14 @@ the quick "where are we" — read it first.
`view`. Filenames derive from the snippet name — filesystem-safe, script-preserving `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 (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 "library row / editor toolbar" — image export needs the live view; the
disclosure-of-controls (not an ARIA menu) mirrors `SettingsPopover`. disclosure-of-controls (not an ARIA menu) mirrors `SettingsPopover`. - **Export options (from first-round feedback):** PNG **Resolution** `1×/2×/3×` is a
- **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× multiplier of `devicePixelRatio`, so the default `1×` is Retina-crisp — the soft-1×
export was a dpr bug (raw `toImageURL` scaleFactor ignores dpr). **Background** export was a dpr bug (raw `toImageURL` scaleFactor ignores dpr). **Background**
`Theme`(default)/`White`/`None` fixes transparent PNGs (the chart config is `Theme`(default)/`White`/`None` fixes transparent PNGs (the chart config is
transparent so the on-screen pane colour shows; export composites the chosen colour transparent so the on-screen pane colour shows; export composites the chosen colour
under the PNG / adds an SVG `<rect>`). **Referenced data** `Inline`(default)/`Keep under the PNG / adds an SVG `<rect>`). **Referenced data** `Inline`(default)/`Keep
refs` (shown only when the spec references saved datasets) inlines dataset values so refs` (shown only when the spec references saved datasets) inlines dataset values so
the exported spec renders standalone (`inlineReferencedDatasets`, tested). the exported spec renders standalone (`inlineReferencedDatasets`, tested). - Spec §08 gained a _Per-chart export_ section (with the options); §04 cross-references
- 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. it; `architecture/05` §2 records the handle's dpr-aware scale + background compositing.
- **1A · Actionable hints** shipped: one-click fixes on guidance warnings - **1A · Actionable hints** shipped: one-click fixes on guidance warnings
(`BuilderWarning.fixes` + `applyWarningFix`), council-reviewed, with focus/announce a11y. (`BuilderWarning.fixes` + `applyWarningFix`), council-reviewed, with focus/announce a11y.
@@ -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: 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._ 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 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 §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. (`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 your rows [+ Filter] [+ Calculate] → now encode them`), paired with 1D. _Spec impact: new
§06 "Data / transforms" subsection — this is genuinely new behaviour, write it._ §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 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 §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 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 (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._ 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 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 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` 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). 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,
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 Phase 1 1A actionable hints ✓ done
1B per-chart export ✓ done 1B per-chart export ✓ done
1C filter (+ calculate) ← next: closes the loop on warnings the builder already emits 1C filter (+ calculate) ✓ done
1D data preview ← pairs with 1C 1D data preview ✓ done
1E expr-validate + autocomplete (with 1C) 1E expr-validate ✓ done (syntax + unknown-field; completion popup deferred)
Phase 2 2A value-or-field channels (Property model) Phase 2 2A value-or-field channels (Property model) ← next
2B field shelf + in-place type cycling ← Tier-C substrate 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 Phase 3 3A intent-first front door (Tier C) ← built on 2B; the defining feature
3B starter examples 3B starter examples
+64
View File
@@ -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".
+33 -2
View File
@@ -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: 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. - **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.<field>` 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 ## Inputs and Controls
### Mark type ### Mark type
@@ -93,7 +124,7 @@ A clean configuration shows no hints.
Selecting "Create Snippet" produces the final artifact: 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). - 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. - 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_). - Links the snippet to the dataset by recording the dataset reference, so the bidirectional snippet↔dataset relationship is established (see _Datasets_).
@@ -43,6 +43,331 @@
color: var(--text); 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 { .field {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -162,4 +162,131 @@ describe('ChartBuilderModal', () => {
}); });
expect(container.textContent).toContain('No dataset loaded'); 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(<ChartBuilderModal />);
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(<ChartBuilderModal />);
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(<ChartBuilderModal />);
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(<ChartBuilderModal />);
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');
});
}); });
+420 -1
View File
@@ -24,6 +24,8 @@ import {
TIME_UNITS, TIME_UNITS,
builderWarnings, builderWarnings,
defaultFieldType, defaultFieldType,
effectiveColumns,
filterOpArity,
isBuilderConfigValid, isBuilderConfigValid,
isChannelTypeAllowed, isChannelTypeAllowed,
supportsAggregate, supportsAggregate,
@@ -32,14 +34,21 @@ import {
supportsStack, supportsStack,
supportsTimeUnit, supportsTimeUnit,
validFieldTypes, validFieldTypes,
validFilterOps,
type BuilderCalculate,
type AggregateOp, type AggregateOp,
type BuilderColumns,
type BuilderFilter,
type BuilderWarningFix, type BuilderWarningFix,
type ChannelMapping, type ChannelMapping,
type ChannelName, type ChannelName,
type FieldType, type FieldType,
type FilterOp,
type MarkType, type MarkType,
type TimeUnit, type TimeUnit,
} from '@core/chart-builder'; } from '@core/chart-builder';
import { referencedFields, validateExpression } from '@core/expr-validate';
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';
@@ -131,6 +140,57 @@ const TIME_UNIT_LABELS: Record<TimeUnit, string> = {
hours: 'Hour', hours: 'Hour',
}; };
/** Readable labels for the filter operators, phrased to read as "<field> <op> <value>". */
const FILTER_OP_LABELS: Record<FilterOp, string> = {
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). */ /** A compact type indicator for a column option (# / date / bool / text). */
function typeBadge(type: ColumnType): string { function typeBadge(type: ColumnType): string {
switch (type) { switch (type) {
@@ -156,7 +216,14 @@ function isCount(mapping: ChannelMapping | null): boolean {
} }
function ChannelBlock({ channel }: { channel: ChannelName }) { 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 mapping = useChartBuilderStore((s) => s.config.encodings[channel] ?? null);
const setChannelColumn = useChartBuilderStore((s) => s.setChannelColumn); const setChannelColumn = useChartBuilderStore((s) => s.setChannelColumn);
const setChannelType = useChartBuilderStore((s) => s.setChannelType); 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.<field>`
* 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' ? (
<p className={styles.exprError} role="alert">
{feedback.text}
</p>
) : (
<p className={styles.exprWarn} role="status">
{feedback.text}
</p>
);
}
/**
* 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 (
<p className={styles.exprHelp}>
Filter expressions and calculated fields use the{' '}
<a
className={styles.exprHelpLink}
href={VEGA_EXPRESSION_DOCS_URL}
target="_blank"
rel="noreferrer"
>
Vega expression language
<span aria-hidden="true"> </span>
<span className="visually-hidden"> (opens in a new tab)</span>
</a>
.
</p>
);
}
/** One filter row — a guarded `field op value` predicate, or a raw expression. */
function FilterRow({ filter, columns }: { filter: BuilderFilter; columns: BuilderColumns }) {
const setFilterField = useChartBuilderStore((s) => s.setFilterField);
const updateFilter = useChartBuilderStore((s) => s.updateFilter);
const setFilterMode = useChartBuilderStore((s) => s.setFilterMode);
const removeFilter = useChartBuilderStore((s) => s.removeFilter);
const expressionMode = filter.mode === 'expression';
const fieldType = filter.fieldType ?? 'nominal';
const op = filter.op ?? 'equal';
const arity = filterOpArity(op);
const hasColumns = columns.columns.length > 0;
return (
<div className={styles.transformBlock}>
<div className={styles.transformBlockTop}>
{expressionMode ? (
<input
className={styles.exprInput}
aria-label="Filter expression"
placeholder={exprPlaceholder(columns, 'filter')}
value={filter.expr ?? ''}
aria-invalid={!validateExpression(filter.expr ?? '').valid || undefined}
onChange={(e) => updateFilter(filter.id, { expr: e.target.value })}
/>
) : (
<select
className={styles.filterField}
aria-label="Filter field"
value={filter.field ?? ''}
onChange={(e) => setFilterField(filter.id, e.target.value)}
>
{!filter.field && <option value="">Choose a field</option>}
{columns.columns.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
)}
<button
type="button"
className={styles.removeRow}
aria-label="Remove filter"
onClick={() => removeFilter(filter.id)}
>
<Icon name="close" />
</button>
</div>
{!expressionMode && (
<div className={styles.filterPredicate}>
<select
className={styles.mini}
aria-label="Filter operator"
value={op}
onChange={(e) => updateFilter(filter.id, { op: e.target.value as FilterOp })}
>
{validFilterOps(fieldType).map((o) => (
<option key={o} value={o}>
{FILTER_OP_LABELS[o]}
</option>
))}
</select>
{arity === 'range' ? (
<>
<input
className={styles.valueInput}
aria-label="Lower bound"
placeholder="min"
value={filter.value ?? ''}
onChange={(e) => updateFilter(filter.id, { value: e.target.value })}
/>
<span className={styles.rangeDash} aria-hidden="true">
</span>
<input
className={styles.valueInput}
aria-label="Upper bound"
placeholder="max"
value={filter.value2 ?? ''}
onChange={(e) => updateFilter(filter.id, { value2: e.target.value })}
/>
</>
) : (
<input
className={styles.valueInput}
aria-label={arity === 'list' ? 'Values, comma-separated' : 'Filter value'}
placeholder={arity === 'list' ? 'A, B, C' : 'value'}
value={filter.value ?? ''}
onChange={(e) => updateFilter(filter.id, { value: e.target.value })}
/>
)}
</div>
)}
{expressionMode && <ExprFeedback expr={filter.expr ?? ''} columns={columns} />}
{hasColumns && (
<button
type="button"
className={styles.modeToggle}
onClick={() => setFilterMode(filter.id, expressionMode ? 'predicate' : 'expression')}
>
{expressionMode ? 'Use the field picker' : 'Write an expression'}
</button>
)}
</div>
);
}
/** One calculated-field row — a name and a Vega expression producing a new column. */
function CalculateRow({ calc, columns }: { calc: BuilderCalculate; columns: BuilderColumns }) {
const updateCalculate = useChartBuilderStore((s) => s.updateCalculate);
const removeCalculate = useChartBuilderStore((s) => s.removeCalculate);
return (
<div className={styles.transformBlock}>
<div className={styles.transformBlockTop}>
<input
className={styles.calcName}
aria-label="New field name"
placeholder="new field"
value={calc.as}
onChange={(e) => updateCalculate(calc.id, { as: e.target.value })}
/>
<span className={styles.calcEquals} aria-hidden="true">
=
</span>
<input
className={styles.exprInput}
aria-label="Calculated field expression"
placeholder={exprPlaceholder(columns, 'calc')}
value={calc.expr}
aria-invalid={!validateExpression(calc.expr).valid || undefined}
onChange={(e) => updateCalculate(calc.id, { expr: e.target.value })}
/>
<button
type="button"
className={styles.removeRow}
aria-label="Remove calculated field"
onClick={() => removeCalculate(calc.id)}
>
<Icon name="close" />
</button>
</div>
<ExprFeedback expr={calc.expr} columns={columns} />
</div>
);
}
/**
* A collapsible, read-only sample of the dataset's actual rows with a per-column type
* chip in each header (1D) — lets the user sanity-check inferred types before building,
* exactly when inference is most likely to surprise. Shows the base dataset columns
* (calculated fields don't exist in the raw rows). Non-tabular payloads have no rows.
*/
function DataPreview() {
const datasetId = useChartBuilderStore((s) => s.datasetId);
const baseColumns = useChartBuilderStore((s) => s.columns);
const dataset = useDatasetStore((s) => s.datasets.find((d) => d.id === datasetId) ?? null);
const [open, setOpen] = useState(false);
const rows = useMemo(
() => (dataset ? tabularRows(dataset.data, dataset.format, PREVIEW_ROW_LIMIT) : null),
[dataset],
);
if (!dataset) return null;
const typeOf = (name: string): ColumnType =>
baseColumns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
return (
<div className={styles.dataPreview}>
<button
type="button"
className={styles.previewToggle}
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
<span className={styles.previewCaret} aria-hidden="true">
{open ? '▾' : '▸'}
</span>
Preview rows
{dataset.rowCount != null && (
<span className={styles.previewMeta}>
{dataset.rowCount.toLocaleString()} rows · {dataset.columnCount} cols
</span>
)}
</button>
{open &&
(rows ? (
<div
className={styles.previewTableWrap}
tabIndex={0}
role="group"
aria-label="Data preview"
>
<table className={styles.previewTable}>
<thead>
<tr>
{baseColumns.columns.map((col) => (
<th key={col} scope="col">
<span className={styles.previewColName}>{col}</span>{' '}
<span className={styles.previewColType}>{typeBadge(typeOf(col))}</span>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri}>
{baseColumns.columns.map((col) => (
<td key={col}>{cellText(row[col])}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : (
<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>
);
}
/**
* The "Data" section at the top of the config pane: row filters and calculated fields
* (the top-level transforms, 1C) plus a collapsible row preview (1D) — "here are your
* rows, shape them, then encode them". Calculated fields appear in the channel
* dropdowns below via the effective-columns derivation.
*/
function DataSection() {
const baseColumns = useChartBuilderStore((s) => s.columns);
const calculates = useChartBuilderStore((s) => s.config.calculates);
const filters = useChartBuilderStore((s) => s.config.filters);
const addFilter = useChartBuilderStore((s) => s.addFilter);
const addCalculate = useChartBuilderStore((s) => s.addCalculate);
const columns = useMemo(
() => effectiveColumns(baseColumns, calculates),
[baseColumns, calculates],
);
// The expression reference is shown only when an expression input exists — a
// calculated field, or a filter switched to expression mode.
const hasExpression =
(filters ?? []).some((f) => f.mode === 'expression') || (calculates ?? []).length > 0;
return (
<section className={styles.dataSection} aria-label="Data">
<span className={styles.fieldLabel}>Data</span>
{/* The source rows come first, so they read as the input — distinct from the
filters/calculated fields below, which shape what the chart actually draws. */}
<DataPreview />
<div className={styles.transformGroup}>
<div className={styles.transformGroupHead}>
<span className={styles.miniLabel}>Filters</span>
<button type="button" className={styles.addRow} onClick={addFilter}>
<Icon name="add" /> Add filter
</button>
</div>
{(filters ?? []).map((f) => (
<FilterRow key={f.id} filter={f} columns={columns} />
))}
</div>
<div className={styles.transformGroup}>
<div className={styles.transformGroupHead}>
<span className={styles.miniLabel}>Calculated fields</span>
<button type="button" className={styles.addRow} onClick={addCalculate}>
<Icon name="add" /> Add field
</button>
</div>
{(calculates ?? []).map((c) => (
<CalculateRow key={c.id} calc={c} columns={columns} />
))}
</div>
{hasExpression && <ExprHelp />}
</section>
);
}
/** Sort control values: 'none' maps to an unsorted config. */ /** Sort control values: 'none' maps to an unsorted config. */
const SORT_OPTIONS: ReadonlyArray<SegmentedOption<'none' | 'ascending' | 'descending'>> = [ const SORT_OPTIONS: ReadonlyArray<SegmentedOption<'none' | 'ascending' | 'descending'>> = [
{ value: 'none', label: 'None' }, { value: 'none', label: 'None' },
@@ -488,6 +905,8 @@ export function ChartBuilderModal() {
Building from <strong>{datasetName}</strong> Building from <strong>{datasetName}</strong>
</p> </p>
<DataSection />
<div className={styles.field}> <div className={styles.field}>
<span className={styles.fieldLabel}>Mark</span> <span className={styles.fieldLabel}>Mark</span>
<SegmentedControl <SegmentedControl
+89
View File
@@ -140,6 +140,95 @@ describe('applyWarningFix', () => {
}); });
}); });
describe('filters (1C)', () => {
test('addFilter seeds a predicate on the first column with its derived type', () => {
const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
cb().init(id);
cb().addFilter();
const f = cb().config.filters?.[0];
expect(f).toMatchObject({
mode: 'predicate',
field: 'region',
fieldType: 'nominal',
op: 'equal',
});
});
test('setFilterField re-derives the field type and clamps an out-of-range operator', () => {
const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
cb().init(id);
cb().addFilter();
const fid = cb().config.filters![0].id;
// Point at the measure, choose an ordering op, then point back at the category:
cb().setFilterField(fid, 'revenue');
cb().updateFilter(fid, { op: 'gt', value: '3' });
expect(cb().config.filters![0]).toMatchObject({ fieldType: 'quantitative', op: 'gt' });
cb().setFilterField(fid, 'region'); // gt is invalid on a category → resets to equal
expect(cb().config.filters![0]).toMatchObject({
field: 'region',
fieldType: 'nominal',
op: 'equal',
});
});
test('removeFilter drops the row', () => {
const id = seedDataset('S', [{ region: 'N' }]);
cb().init(id);
cb().addFilter();
cb().addFilter();
const first = cb().config.filters![0].id;
cb().removeFilter(first);
expect(cb().config.filters).toHaveLength(1);
expect(cb().config.filters![0].id).not.toBe(first);
});
test('setFilterMode toggles to expression and back, seeding a field on return', () => {
const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
cb().init(id);
cb().addFilter();
const fid = cb().config.filters![0].id;
cb().setFilterMode(fid, 'expression');
expect(cb().config.filters![0].mode).toBe('expression');
cb().setFilterMode(fid, 'predicate');
expect(cb().config.filters![0]).toMatchObject({ mode: 'predicate', field: 'region' });
});
});
describe('calculated fields (1C)', () => {
test('a calculated field becomes selectable on a channel, defaulting to quantitative', () => {
const id = seedDataset('S', [{ a: 1, b: 2 }]);
cb().init(id);
cb().addCalculate();
const cid = cb().config.calculates![0].id;
cb().updateCalculate(cid, { as: 'total', expr: 'datum.a + datum.b' });
cb().setChannelColumn('y', 'total');
expect(cb().config.encodings.y).toEqual({ field: 'total', type: 'quantitative' });
});
test('removing a calculated field clears any channel that referenced it', () => {
const id = seedDataset('S', [{ a: 1, b: 2 }]);
cb().init(id);
cb().addCalculate();
const cid = cb().config.calculates![0].id;
cb().updateCalculate(cid, { as: 'total', expr: 'datum.a + datum.b' });
cb().setChannelColumn('y', 'total');
cb().removeCalculate(cid);
expect(cb().config.calculates).toHaveLength(0);
expect(cb().config.encodings.y).toBeNull(); // pruned — its field no longer exists
});
test('renaming a referenced calculated field prunes the now-dangling channel', () => {
const id = seedDataset('S', [{ a: 1 }]);
cb().init(id);
cb().addCalculate();
const cid = cb().config.calculates![0].id;
cb().updateCalculate(cid, { as: 'doubled', expr: 'datum.a * 2' });
cb().setChannelColumn('x', 'doubled');
cb().updateCalculate(cid, { as: 'tripled' }); // the channel still points at "doubled"
expect(cb().config.encodings.x).toBeNull();
});
});
describe('createSnippet', () => { describe('createSnippet', () => {
test('builds a linked snippet, activates it, and resets the builder', () => { test('builds a linked snippet, activates it, and resets the builder', () => {
const id = seedDataset('Sales', [ const id = seedDataset('Sales', [
+142 -3
View File
@@ -18,20 +18,26 @@ import {
buildSnippetSpecText, buildSnippetSpecText,
defaultBuilderConfig, defaultBuilderConfig,
defaultFieldType, defaultFieldType,
effectiveColumns,
generateChartName, generateChartName,
isBuilderConfigValid, isBuilderConfigValid,
isChannelTypeAllowed, isChannelTypeAllowed,
pruneEncodings,
supportsAggregate, supportsAggregate,
supportsBin, supportsBin,
supportsTimeUnit, supportsTimeUnit,
validFieldTypes, validFieldTypes,
validFilterOps,
type AggregateOp, type AggregateOp,
type BuilderCalculate,
type BuilderColumns, type BuilderColumns,
type BuilderConfig, type BuilderConfig,
type BuilderFilter,
type BuilderWarningFix, type BuilderWarningFix,
type ChannelMapping, type ChannelMapping,
type ChannelName, type ChannelName,
type FieldType, type FieldType,
type FilterMode,
type MarkType, type MarkType,
type SortOrder, type SortOrder,
type StackMode, type StackMode,
@@ -54,6 +60,15 @@ const EMPTY_COLUMNS: BuilderColumns = { columns: [], columnTypes: [] };
*/ */
export const COUNT_FIELD = '\u0000count'; export const COUNT_FIELD = '\u0000count';
/**
* Monotonic id source for filter / calculated-field list rows. Ids are stable React
* keys and edit handles only — they never reach the produced spec — so a plain
* session counter is enough (no need for crypto/uuid), and it keeps the rows
* order-stable as the user adds and removes them.
*/
let transformSeq = 0;
const nextTransformId = (prefix: 'f' | 'c'): string => `${prefix}${++transformSeq}`;
export interface ChartBuilderState { export interface ChartBuilderState {
/** The dataset being built from, or null when none is loaded. */ /** The dataset being built from, or null when none is loaded. */
datasetId: number | null; datasetId: number | null;
@@ -87,6 +102,24 @@ export interface ChartBuilderState {
setSort: (sort: SortOrder | undefined) => void; setSort: (sort: SortOrder | undefined) => void;
/** Stacking mode for bar/area + a colour series; `undefined` = Vega-Lite default. */ /** Stacking mode for bar/area + a colour series; `undefined` = Vega-Lite default. */
setStack: (stack: StackMode | undefined) => void; setStack: (stack: StackMode | undefined) => void;
/** Append a new, empty predicate filter (defaults to the first column, equals). */
addFilter: () => void;
/** Patch one filter row by id (op/value/value2/expr/mode). */
updateFilter: (id: string, patch: Partial<Omit<BuilderFilter, 'id'>>) => void;
/** Re-point a filter to a column: derives its field type and clamps the operator. */
setFilterField: (id: string, field: string) => void;
/** Switch a filter between the guarded predicate shelf and a raw expression. */
setFilterMode: (id: string, mode: FilterMode) => void;
/** Remove a filter row by id. */
removeFilter: (id: string) => void;
/** Append a new, empty calculated field. */
addCalculate: () => void;
/** Patch one calculated field by id (expr / as); prunes any now-dangling encoding. */
updateCalculate: (id: string, patch: Partial<Omit<BuilderCalculate, 'id'>>) => void;
/** Remove a calculated field by id; clears any channel that referenced it. */
removeCalculate: (id: string) => void;
setWidth: (width: number | undefined) => void; setWidth: (width: number | undefined) => void;
setHeight: (height: number | undefined) => void; setHeight: (height: number | undefined) => void;
/** Build the spec, create + activate a linked snippet, toast, and close. */ /** Build the spec, create + activate a linked snippet, toast, and close. */
@@ -99,6 +132,11 @@ function columnType(columns: BuilderColumns, name: string): ColumnType {
return columns.columnTypes.find((c) => c.name === name)?.type ?? 'string'; return columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
} }
/** The dataset columns plus the config's calculated fields (what the dropdowns offer). */
function effCols(s: ChartBuilderState): BuilderColumns {
return effectiveColumns(s.columns, s.config.calculates);
}
/** Replace one channel's mapping, returning the new `{ config }` state slice. */ /** Replace one channel's mapping, returning the new `{ config }` state slice. */
function updateEncoding( function updateEncoding(
s: ChartBuilderState, s: ChartBuilderState,
@@ -150,10 +188,11 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
// Default to the column's natural type, but if that type isn't allowed on // Default to the column's natural type, but if that type isn't allowed on
// this channel (e.g. a category on Size), fall back to the first valid type // this channel (e.g. a category on Size), fall back to the first valid type
// that is — the UI also disables unsuitable columns, this is the guard. // that is — the UI also disables unsuitable columns, this is the guard.
const valid = validFieldTypes(columnType(s.columns, columnName)); // Effective columns include calculated fields (which default to numeric).
const colType = columnType(effCols(s), columnName);
const valid = validFieldTypes(colType);
const type = const type =
valid.find((t) => isChannelTypeAllowed(channel, t)) ?? valid.find((t) => isChannelTypeAllowed(channel, t)) ?? defaultFieldType(colType);
defaultFieldType(columnType(s.columns, columnName));
mapping = { field: columnName, type }; // a fresh mapping clears prior transforms mapping = { field: columnName, type }; // a fresh mapping clears prior transforms
} }
return { config: { ...s.config, encodings: { ...s.config.encodings, [channel]: mapping } } }; return { config: { ...s.config, encodings: { ...s.config.encodings, [channel]: mapping } } };
@@ -209,6 +248,106 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
setSort: (sort) => set((s) => ({ config: { ...s.config, sort } })), setSort: (sort) => set((s) => ({ config: { ...s.config, sort } })),
setStack: (stack) => set((s) => ({ config: { ...s.config, stack } })), setStack: (stack) => set((s) => ({ config: { ...s.config, stack } })),
addFilter: () =>
set((s) => {
// Seed the new row on the first available column so it is immediately usable;
// an unmapped dataset (no columns) yields an expression-mode row instead.
const first = effCols(s).columns[0];
const filter: BuilderFilter = first
? {
id: nextTransformId('f'),
mode: 'predicate',
field: first,
fieldType: defaultFieldType(columnType(effCols(s), first)),
op: 'equal',
value: '',
}
: { id: nextTransformId('f'), mode: 'expression', expr: '' };
return { config: { ...s.config, filters: [...(s.config.filters ?? []), filter] } };
}),
updateFilter: (id, patch) =>
set((s) => ({
config: {
...s.config,
filters: (s.config.filters ?? []).map((f) => (f.id === id ? { ...f, ...patch } : f)),
},
})),
setFilterField: (id, field) =>
set((s) => {
const fieldType = defaultFieldType(columnType(effCols(s), field));
return {
config: {
...s.config,
filters: (s.config.filters ?? []).map((f) => {
if (f.id !== id) return f;
// Re-point the field and its type; keep the operator only if it is still
// valid for the new type (a measure op on a category resets to equals).
const op = f.op && validFilterOps(fieldType).includes(f.op) ? f.op : 'equal';
return { ...f, field, fieldType, op };
}),
},
};
}),
setFilterMode: (id, mode) =>
set((s) => {
const first = effCols(s).columns[0];
return {
config: {
...s.config,
filters: (s.config.filters ?? []).map((f) => {
if (f.id !== id) return f;
// Switching to the predicate shelf without a field yet (e.g. the row was
// born in expression mode) seeds the first column so it's usable at once.
if (mode === 'predicate' && !f.field && first) {
return {
...f,
mode,
field: first,
fieldType: defaultFieldType(columnType(effCols(s), first)),
op: f.op ?? 'equal',
};
}
return { ...f, mode };
}),
},
};
}),
removeFilter: (id) =>
set((s) => ({
config: { ...s.config, filters: (s.config.filters ?? []).filter((f) => f.id !== id) },
})),
addCalculate: () =>
set((s) => ({
config: {
...s.config,
calculates: [
...(s.config.calculates ?? []),
{ id: nextTransformId('c'), expr: '', as: '' },
],
},
})),
updateCalculate: (id, patch) =>
set((s) => {
const calculates = (s.config.calculates ?? []).map((c) =>
c.id === id ? { ...c, ...patch } : c,
);
// A rename (changed `as`) can orphan a channel that mapped the old name; prune
// any encoding whose field no longer exists among the effective columns.
return { config: pruneEncodings({ ...s.config, calculates }, s.columns) };
}),
removeCalculate: (id) =>
set((s) => {
const calculates = (s.config.calculates ?? []).filter((c) => c.id !== id);
return { config: pruneEncodings({ ...s.config, calculates }, s.columns) };
}),
swapXY: () => swapXY: () =>
set((s) => ({ set((s) => ({
config: { config: {
+234
View File
@@ -16,8 +16,16 @@ import {
buildChartSpec, buildChartSpec,
buildSnippetSpecText, buildSnippetSpecText,
generateChartName, generateChartName,
validFilterOps,
filterOpArity,
buildTransforms,
calculatedFieldNames,
effectiveColumns,
pruneEncodings,
type BuilderCalculate,
type BuilderColumns, type BuilderColumns,
type BuilderConfig, type BuilderConfig,
type BuilderFilter,
type ChannelMapping, type ChannelMapping,
} from './chart-builder'; } from './chart-builder';
import { VEGA_LITE_SCHEMA_URL } from './snippet'; import { VEGA_LITE_SCHEMA_URL } from './snippet';
@@ -831,3 +839,229 @@ describe('generateChartName', () => {
expect(generateChartName(config)).toBe('Circle chart of Sales'); expect(generateChartName(config)).toBe('Circle chart of Sales');
}); });
}); });
// --- Data transforms: filters + calculated fields (spec §06 → Data) -------------
const filter = (over: Partial<BuilderFilter>): BuilderFilter => ({
id: 'f1',
mode: 'predicate',
...over,
});
const calc = (over: Partial<BuilderCalculate>): BuilderCalculate => ({
id: 'c1',
expr: '',
as: '',
...over,
});
const withFilters = (...filters: BuilderFilter[]): BuilderConfig => ({
datasetName: 'D',
mark: 'bar',
encodings: {},
filters,
});
describe('validFilterOps (guarded operators per field type)', () => {
it('offers ordering + range for measures and temporal fields', () => {
const ordered = ['equal', 'notEqual', 'lt', 'lte', 'gt', 'gte', 'range'];
expect(validFilterOps('quantitative')).toEqual(ordered);
expect(validFilterOps('temporal')).toEqual(ordered);
});
it('offers only equality + membership for categories (no ordering)', () => {
expect(validFilterOps('nominal')).toEqual(['equal', 'notEqual', 'oneOf']);
expect(validFilterOps('ordinal')).toEqual(['equal', 'notEqual', 'oneOf']);
});
});
describe('filterOpArity', () => {
it('classifies single / range / list operators', () => {
expect(filterOpArity('equal')).toBe('single');
expect(filterOpArity('gte')).toBe('single');
expect(filterOpArity('range')).toBe('range');
expect(filterOpArity('oneOf')).toBe('list');
});
});
describe('buildTransforms (predicate coercion + shape)', () => {
it('coerces a quantitative predicate value to a number', () => {
const t = buildTransforms(
withFilters(filter({ field: 'value', fieldType: 'quantitative', op: 'gt', value: '10' })),
);
expect(t).toEqual([{ filter: { field: 'value', gt: 10 } }]);
});
it('keeps a categorical value a string', () => {
const t = buildTransforms(
withFilters(filter({ field: 'category', fieldType: 'nominal', op: 'equal', value: 'East' })),
);
expect(t).toEqual([{ filter: { field: 'category', equal: 'East' } }]);
});
it('expresses notEqual as a {not} wrapper (no bare inequality predicate)', () => {
const t = buildTransforms(
withFilters(
filter({ field: 'category', fieldType: 'nominal', op: 'notEqual', value: 'East' }),
),
);
expect(t).toEqual([{ filter: { not: { field: 'category', equal: 'East' } } }]);
});
it('builds a two-bound range with coerced numbers', () => {
const t = buildTransforms(
withFilters(
filter({
field: 'value',
fieldType: 'quantitative',
op: 'range',
value: '0',
value2: '100',
}),
),
);
expect(t).toEqual([{ filter: { field: 'value', range: [0, 100] } }]);
});
it('splits and trims a oneOf membership list', () => {
const t = buildTransforms(
withFilters(
filter({
field: 'category',
fieldType: 'nominal',
op: 'oneOf',
value: 'East, West ,North',
}),
),
);
expect(t).toEqual([{ filter: { field: 'category', oneOf: ['East', 'West', 'North'] } }]);
});
it('passes an expression-mode filter through verbatim', () => {
const t = buildTransforms(withFilters(filter({ mode: 'expression', expr: 'datum.value > 0' })));
expect(t).toEqual([{ filter: 'datum.value > 0' }]);
});
it('skips incomplete entries (blank value, blank range bound, blank expression)', () => {
const t = buildTransforms(
withFilters(
filter({ field: 'value', fieldType: 'quantitative', op: 'gt', value: '' }),
filter({ id: 'f2', field: 'value', fieldType: 'quantitative', op: 'range', value: '1' }),
filter({ id: 'f3', mode: 'expression', expr: ' ' }),
),
);
expect(t).toEqual([]);
});
it('emits calculated fields before filters, in list order', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {},
calculates: [calc({ as: 'total', expr: 'datum.a + datum.b' })],
filters: [filter({ field: 'total', fieldType: 'quantitative', op: 'gt', value: '5' })],
};
expect(buildTransforms(config)).toEqual([
{ calculate: 'datum.a + datum.b', as: 'total' },
{ filter: { field: 'total', gt: 5 } },
]);
});
it('skips an unnamed or empty calculate', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {},
calculates: [calc({ as: '', expr: 'datum.a' }), calc({ id: 'c2', as: 'x', expr: '' })],
};
expect(buildTransforms(config)).toEqual([]);
});
});
describe('buildChartSpec — transform integration', () => {
it('places transform between data and mark, only when something resolves', () => {
const spec = buildChartSpec({
datasetName: 'D',
mark: 'bar',
encodings: { x: { field: 'category', type: 'nominal' } },
filters: [filter({ field: 'value', fieldType: 'quantitative', op: 'gte', value: '0' })],
});
expect(spec.transform).toEqual([{ filter: { field: 'value', gte: 0 } }]);
expect(Object.keys(spec)).toEqual(['$schema', 'data', 'transform', 'mark', 'encoding']);
});
it('omits the transform key when no filter/calculate resolves', () => {
const spec = buildChartSpec({
datasetName: 'D',
mark: 'bar',
encodings: { x: { field: 'category', type: 'nominal' } },
filters: [filter({ field: 'value', fieldType: 'quantitative', op: 'gt', value: '' })],
});
expect(spec).not.toHaveProperty('transform');
});
});
describe('calculatedFieldNames', () => {
it('returns named (as) fields in order, trimmed, dropping empties', () => {
expect(
calculatedFieldNames([
calc({ as: ' total ', expr: '1' }),
calc({ id: 'c2', as: '', expr: '2' }),
]),
).toEqual(['total']);
});
it('handles an absent list', () => {
expect(calculatedFieldNames(undefined)).toEqual([]);
});
});
describe('effectiveColumns', () => {
it('appends calculated fields as numeric columns the channels can use', () => {
const eff = effectiveColumns(columns, [calc({ as: 'ratio', expr: 'datum.value / 2' })]);
expect(eff.columns).toContain('ratio');
expect(eff.columnTypes).toContainEqual({ name: 'ratio', type: 'number' });
});
it('does not shadow a real column with a same-named calculate', () => {
const eff = effectiveColumns(columns, [calc({ as: 'value', expr: '1' })]);
expect(eff.columns.filter((c) => c === 'value')).toHaveLength(1);
});
it('returns the base columns unchanged (same ref) when there are no calculates', () => {
expect(effectiveColumns(columns, undefined)).toBe(columns);
expect(effectiveColumns(columns, [])).toBe(columns);
});
});
describe('pruneEncodings', () => {
it('clears a channel mapped to a now-missing field', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: { y: { field: 'gone', type: 'quantitative' } },
calculates: [],
};
expect(pruneEncodings(config, columns).encodings.y).toBeNull();
});
it('keeps channels on real or still-present calculated fields (same ref)', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'category', type: 'nominal' },
y: { field: 'ratio', type: 'quantitative' },
},
calculates: [calc({ as: 'ratio', expr: '1' })],
};
expect(pruneEncodings(config, columns)).toBe(config);
});
it('leaves a field-less count mapping alone', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: { y: { type: 'quantitative', aggregate: 'count' } },
};
expect(pruneEncodings(config, columns)).toBe(config);
});
});
+267 -4
View File
@@ -73,6 +73,82 @@ export type SortOrder = (typeof SORT_ORDERS)[number];
export const STACK_MODES = ['zero', 'normalize'] as const; export const STACK_MODES = ['zero', 'normalize'] as const;
export type StackMode = (typeof STACK_MODES)[number]; export type StackMode = (typeof STACK_MODES)[number];
/**
* Comparison operators a guarded filter predicate offers (Vega-Lite field
* predicates), in menu order. The set a given field admits is narrowed by
* `validFilterOps` — only a measure/temporal field offers ordering (`lt`…`gt`) and
* `range`; a category offers membership (`oneOf`). `notEqual` is expressed as a
* `{ not: { …equal } }` logical wrapper (Vega-Lite has no bare `!=` predicate).
*/
export const FILTER_OPS = [
'equal',
'notEqual',
'lt',
'lte',
'gt',
'gte',
'range',
'oneOf',
] as const;
export type FilterOp = (typeof FILTER_OPS)[number];
/** How a filter expresses its predicate: a guarded shelf, or a raw expression. */
export type FilterMode = 'predicate' | 'expression';
/**
* One top-level data filter (Vega-Lite `transform: [{ filter }]`), applied to the
* raw rows **before** encoding aggregation — so "filter rows, then aggregate" is the
* natural reading. Two modes share one shape (the UI toggles between them on a single
* row):
*
* - **predicate** — a guarded `field op value` triple (Voyager-style, no expression
* to write for the common case). `range` carries a second bound (`value2`);
* `oneOf` reads `value` as a comma-separated membership list. Values are coerced
* by `fieldType` (a quantitative field compares as a number).
* - **expression** — a raw Vega expression string (`datum.x > 0`), the power-form
* for what the guarded shelf can't say (validated in the UI via
* `expr-validate.ts`).
*
* `id` is a stable key for list editing only; it never reaches the produced spec.
* A partially-filled filter (no value yet, empty expression) is simply skipped by
* the assembler, so the live preview keeps rendering while the user types.
*/
export interface BuilderFilter {
/** Stable list key (UI-assigned); not serialized into the spec. */
id: string;
/** Which form this filter takes. */
mode: FilterMode;
/** Predicate mode: the column being filtered (a dataset or calculated field). */
field?: string;
/** Predicate mode: the field's Vega-Lite type — drives the op menu + coercion. */
fieldType?: FieldType;
/** Predicate mode: the comparison operator. */
op?: FilterOp;
/** Predicate mode: the comparison value (`range` lower bound; `oneOf` CSV list). */
value?: string;
/** Predicate mode: the upper bound, for `range` only. */
value2?: string;
/** Expression mode: a raw Vega predicate expression. */
expr?: string;
}
/**
* One derived field (Vega-Lite `transform: [{ calculate, as }]`): a row-wise Vega
* expression producing a new column the rest of the builder can encode like any
* other. Calculates are emitted **before** filters (a row-wise calculate is
* order-independent — it computes the same per-row value regardless of which rows
* survive — so running it first is equivalent and lets a filter reference the
* derived field). `id` is a UI list key only.
*/
export interface BuilderCalculate {
/** Stable list key (UI-assigned); not serialized into the spec. */
id: string;
/** The Vega expression, e.g. `datum.price * datum.quantity`. */
expr: string;
/** The new field's name (appears in the channel dropdowns once non-empty). */
as: string;
}
/** /**
* One channel's mapping: a dataset column `field` plus its `type`, with optional * One channel's mapping: a dataset column `field` plus its `type`, with optional
* transforms. `field` is omitted only for a `count` aggregate (which counts records * transforms. `field` is omitted only for a `count` aggregate (which counts records
@@ -108,6 +184,10 @@ export interface BuilderConfig {
sort?: SortOrder; sort?: SortOrder;
/** Stacking for bar/area + a colour series (part-to-whole). */ /** Stacking for bar/area + a colour series (part-to-whole). */
stack?: StackMode; stack?: StackMode;
/** Derived fields (`transform: [{ calculate, as }]`), emitted before `filters`. */
calculates?: BuilderCalculate[];
/** Row filters (`transform: [{ filter }]`), applied before encoding aggregation. */
filters?: BuilderFilter[];
} }
/** /**
@@ -665,6 +745,182 @@ export function builderWarnings(
return warnings; return warnings;
} }
// --- Top-level data transforms: filters + calculated fields (spec §06 → Data). ----
/**
* The comparison operators a field of this type admits (spec §06 → Data filters).
* A measure or a temporal field can be ordered and ranged (`lt`…`gte`, `range`);
* a category (nominal/ordinal) offers equality and membership (`oneOf`) only —
* ordering categories by value isn't meaningful. `equal`/`notEqual` apply to every
* type. Mirrors Vega-Lite's field-predicate grammar (Voyager's guarded filter).
*/
export function validFilterOps(fieldType: FieldType): FilterOp[] {
if (fieldType === 'quantitative' || fieldType === 'temporal') {
return ['equal', 'notEqual', 'lt', 'lte', 'gt', 'gte', 'range'];
}
return ['equal', 'notEqual', 'oneOf'];
}
/** `range` reads two bounds; `oneOf` a membership list; the rest a single value. */
export function filterOpArity(op: FilterOp): 'single' | 'range' | 'list' {
if (op === 'range') return 'range';
if (op === 'oneOf') return 'list';
return 'single';
}
/**
* Coerce a filter's text value to the type Vega-Lite compares against: a
* quantitative field compares as a number (so `> 10` orders numerically, not
* lexically); every other type compares as the raw string. ISO date strings sort
* lexically == chronologically, so temporal predicates work as strings without a
* date parse; non-ISO dates are the expression-mode case. A non-numeric string on a
* quantitative field falls back to the string (Vega-Lite then compares loosely).
*/
function coerceFilterValue(value: string, fieldType: FieldType): string | number {
if (fieldType === 'quantitative') {
const n = Number(value);
return value.trim() !== '' && Number.isFinite(n) ? n : value;
}
return value;
}
/**
* The Vega-Lite field predicate for a guarded filter, or `null` when it is still
* incomplete (no field/op, or a value the operator needs is blank) — an incomplete
* filter is skipped so the preview keeps rendering. `notEqual` wraps an `equal`
* predicate in a `{ not }` (Vega-Lite has no bare inequality predicate).
*/
function predicateObject(filter: BuilderFilter): Record<string, unknown> | null {
const { field, op } = filter;
if (!field || !op) return null;
// TODO: the data-derived column name reaches `field:` unescaped here, as it also
// does in encodingObject. A column whose name contains `.`/`[`/`]` is then read as
// a nested-property accessor rather than a literal field. core/rendering.ts exports
// escapeVegaField for exactly this but nothing currently wires it into the builder.
const type = filter.fieldType ?? 'nominal';
const value = filter.value ?? '';
const coerce = (v: string) => coerceFilterValue(v, type);
switch (op) {
case 'equal':
return value === '' ? null : { field, equal: coerce(value) };
case 'notEqual':
return value === '' ? null : { not: { field, equal: coerce(value) } };
case 'lt':
return value === '' ? null : { field, lt: coerce(value) };
case 'lte':
return value === '' ? null : { field, lte: coerce(value) };
case 'gt':
return value === '' ? null : { field, gt: coerce(value) };
case 'gte':
return value === '' ? null : { field, gte: coerce(value) };
case 'range': {
const upper = filter.value2 ?? '';
if (value === '' || upper === '') return null;
return { field, range: [coerce(value), coerce(upper)] };
}
case 'oneOf': {
const items = value
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
return items.length === 0 ? null : { field, oneOf: items.map(coerce) };
}
default:
return null;
}
}
/** One filter's `{ filter }` transform entry, or `null` when incomplete. */
function filterTransformObject(filter: BuilderFilter): Record<string, unknown> | null {
if (filter.mode === 'expression') {
const expr = (filter.expr ?? '').trim();
return expr === '' ? null : { filter: expr };
}
const predicate = predicateObject(filter);
return predicate ? { filter: predicate } : null;
}
/** One calculate's `{ calculate, as }` transform entry, or `null` when incomplete. */
function calculateTransformObject(calc: BuilderCalculate): Record<string, unknown> | null {
const expr = calc.expr.trim();
const as = calc.as.trim();
return expr === '' || as === '' ? null : { calculate: expr, as };
}
/**
* The complete top-level `transform` array for a configuration: every calculated
* field first (so filters and encodings can reference the derived columns), then
* every filter, each in the user's list order. Incomplete entries (a half-typed
* filter, an unnamed calculate) are dropped so a configuration mid-edit still
* produces a renderable spec. An empty result means no `transform` key is emitted.
*/
export function buildTransforms(config: BuilderConfig): Array<Record<string, unknown>> {
const out: Array<Record<string, unknown>> = [];
for (const calc of config.calculates ?? []) {
const t = calculateTransformObject(calc);
if (t) out.push(t);
}
for (const filter of config.filters ?? []) {
const t = filterTransformObject(filter);
if (t) out.push(t);
}
return out;
}
/** The named (`as`) derived fields a config defines, in order, ignoring unnamed ones. */
export function calculatedFieldNames(
calculates: readonly BuilderCalculate[] | undefined,
): string[] {
return (calculates ?? []).map((c) => c.as.trim()).filter((as) => as !== '');
}
/**
* The dataset's columns augmented with the config's calculated fields, so the UI's
* column dropdowns and field-type logic treat a derived field like any other. A
* calculated field's inferred type is unknown, so it defaults to **number**
* (quantitative — the common arithmetic case; the user can retype it within the
* valid set on the channel). Calculated names that collide with a real column are
* skipped (the real column wins). No cardinality stats are derived for them.
*/
export function effectiveColumns(
base: BuilderColumns,
calculates: readonly BuilderCalculate[] | undefined,
): BuilderColumns {
const added = calculatedFieldNames(calculates).filter(
(name, i, all) => !base.columns.includes(name) && all.indexOf(name) === i,
);
if (added.length === 0) return base;
return {
columns: [...base.columns, ...added],
columnTypes: [
...base.columnTypes,
...added.map((name): { name: string; type: ColumnType } => ({ name, type: 'number' })),
],
columnStats: base.columnStats,
};
}
/**
* Clear any channel whose mapped column no longer exists among the effective
* columns — the cleanup after a calculated field is removed or renamed, so the
* produced spec never encodes a dangling field (which Vega-Lite would render empty).
* Returns the same config object when nothing changed (stable for React equality).
*/
export function pruneEncodings(config: BuilderConfig, base: BuilderColumns): BuilderConfig {
const available = new Set(effectiveColumns(base, config.calculates).columns);
let changed = false;
const encodings = { ...config.encodings };
for (const channel of CHANNELS) {
const mapping = encodings[channel];
if (mapping && mapping.field !== undefined && !available.has(mapping.field)) {
encodings[channel] = null;
changed = true;
}
}
return changed ? { ...config, encodings } : config;
}
/** A built Vega-Lite spec, as a plain object (serialize with `buildSnippetSpecText`). */ /** A built Vega-Lite spec, as a plain object (serialize with `buildSnippetSpecText`). */
export type ChartSpec = Record<string, unknown>; export type ChartSpec = Record<string, unknown>;
@@ -686,9 +942,10 @@ function encodingObject(mapping: ChannelMapping): Record<string, unknown> {
/** /**
* Assemble the complete Vega-Lite spec from a builder configuration (spec §06 → * Assemble the complete Vega-Lite spec from a builder configuration (spec §06 →
* Output). Includes the schema reference, a named data reference to the dataset, * Output). Includes the schema reference, a named data reference to the dataset,
* the mark with tooltips enabled, every mapped encoding (field, type, and any * any top-level `transform` (calculated fields then row filters), the mark with
* aggregate/bin/timeUnit transform), chart-level sort (rank a categorical axis by * tooltips enabled, every mapped encoding (field, type, and any aggregate/bin/
* its measure) and stack (part-to-whole), and any explicit width/height. Unmapped * timeUnit transform), chart-level sort (rank a categorical axis by its measure)
* and stack (part-to-whole), and any explicit width/height. Unmapped
* channels are omitted; if nothing is mapped the `encoding` block is omitted * channels are omitted; if nothing is mapped the `encoding` block is omitted
* entirely (validation prevents saving that, but the live preview may render a bare * entirely (validation prevents saving that, but the live preview may render a bare
* mark while the user is still configuring). * mark while the user is still configuring).
@@ -697,9 +954,15 @@ export function buildChartSpec(config: BuilderConfig): ChartSpec {
const spec: ChartSpec = { const spec: ChartSpec = {
$schema: VEGA_LITE_SCHEMA_URL, $schema: VEGA_LITE_SCHEMA_URL,
data: { name: config.datasetName }, data: { name: config.datasetName },
mark: { type: config.mark, tooltip: true },
}; };
// Top-level transforms (calculated fields, then filters) sit between data and
// mark — applied to the raw rows before encoding aggregation.
const transform = buildTransforms(config);
if (transform.length > 0) spec.transform = transform;
spec.mark = { type: config.mark, tooltip: true };
const encoding: Record<string, Record<string, unknown>> = {}; const encoding: Record<string, Record<string, unknown>> = {};
for (const [channel, mapping] of mappedChannels(config)) { for (const [channel, mapping] of mappedChannels(config)) {
encoding[channel] = encodingObject(mapping); encoding[channel] = encodingObject(mapping);
+39
View File
@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { validateExpression, referencedFields } from './expr-validate';
describe('validateExpression', () => {
it('accepts a well-formed Vega expression', () => {
expect(validateExpression('datum.price * datum.qty').valid).toBe(true);
expect(validateExpression("datum.region === 'East' && datum.value > 0").valid).toBe(true);
});
it('treats an empty / whitespace expression as valid (incomplete, not a mistake)', () => {
expect(validateExpression('').valid).toBe(true);
expect(validateExpression(' ').valid).toBe(true);
});
it('rejects a malformed expression with a message', () => {
const result = validateExpression('datum.price *');
expect(result.valid).toBe(false);
expect(result.error).toBeTruthy();
});
});
describe('referencedFields', () => {
it('collects datum.field and datum["field"] references, de-duplicated in order', () => {
expect(referencedFields("datum.price * datum['qty'] + datum.price")).toEqual(['price', 'qty']);
});
it('ignores function calls and non-datum identifiers', () => {
expect(referencedFields('toNumber(datum.amount) + PI')).toEqual(['amount']);
});
it('skips dynamic (computed, non-literal) access', () => {
expect(referencedFields('datum[someVar]')).toEqual([]);
});
it('returns nothing for an empty or unparseable expression', () => {
expect(referencedFields('')).toEqual([]);
expect(referencedFields('datum.price *')).toEqual([]);
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* Vega expression validation (spec §06 → Data; chart-builder enhancement 1E).
*
* Portable core: no browser APIs, no React. The Chart Builder's filter
* (expression mode) and calculated-field controls let the user write a raw Vega
* expression; this module checks it with **the same parser Vega-Lite uses at render
* time** (`vega-expression`'s `parseExpression`), so the inline "is this valid?"
* feedback agrees exactly with what the chart will accept — no second, divergent
* grammar. It also extracts the `datum.<field>` references so the UI can flag a typo
* against the dataset's actual columns before the chart silently renders empty.
*
* `vega-expression` is a pure dependency already in the bundle (Vega pulls it in for
* rendering), so importing it here adds nothing and keeps this module browser-free.
*/
import { parseExpression } from 'vega-expression';
/** The outcome of validating one expression string. */
export interface ExprValidation {
/** True when the expression parses (or is empty — an empty field isn't an error). */
valid: boolean;
/** A short parser message when invalid; absent when valid. */
error?: string;
}
/**
* Validate a Vega expression. An **empty** string is treated as valid (it is an
* incomplete entry, not a mistake — the assembler skips it), so the UI shows no
* error until the user actually types something malformed. A parse failure returns
* the parser's message, trimmed of the noisy position suffix where present.
*/
export function validateExpression(expr: string): ExprValidation {
if (expr.trim() === '') return { valid: true };
try {
parseExpression(expr);
return { valid: true };
} catch (e) {
return { valid: false, error: cleanParserMessage((e as Error).message) };
}
}
/** Tidy a vega-expression parse error for inline display (drop a trailing "(N)"). */
function cleanParserMessage(message: string): string {
return message.replace(/\s*\(\d+\)\s*$/, '').trim() || 'Invalid expression.';
}
/**
* The distinct `datum.<field>` (and `datum['field']`) column names an expression
* references, in first-seen order — best-effort, for flagging unknown fields in the
* UI. Returns an empty list for an empty or unparseable expression (validation
* surfaces the parse error separately). Only direct member access off `datum` is
* collected; dynamic access (`datum[someVar]`) is not a static field name and is
* skipped.
*/
export function referencedFields(expr: string): string[] {
if (expr.trim() === '') return [];
let ast: unknown;
try {
ast = parseExpression(expr);
} catch {
return [];
}
const fields: string[] = [];
const seen = new Set<string>();
const visit = (node: unknown): void => {
if (Array.isArray(node)) {
for (const child of node) visit(child);
return;
}
if (!node || typeof node !== 'object') return;
const rec = node as Record<string, unknown>;
if (rec.type === 'MemberExpression') {
const name = datumMemberName(rec);
if (name !== null && !seen.has(name)) {
seen.add(name);
fields.push(name);
}
}
for (const key of Object.keys(rec)) {
if (key === 'type') continue;
visit(rec[key]);
}
};
visit(ast);
return fields;
}
/**
* The static field name of a `datum.<name>` / `datum['name']` member access, or
* `null` when the node isn't such an access (a different object, computed-dynamic
* access, or a non-string key).
*/
function datumMemberName(member: Record<string, unknown>): string | null {
const object = member.object as Record<string, unknown> | undefined;
if (!object || object.type !== 'Identifier' || object.name !== 'datum') return null;
const property = member.property as Record<string, unknown> | undefined;
if (!property) return null;
if (member.computed) {
// datum['field'] — a string literal key is a static field name.
return property.type === 'Literal' && typeof property.value === 'string'
? property.value
: null;
}
// datum.field — an identifier key.
return property.type === 'Identifier' && typeof property.name === 'string' ? property.name : null;
}