mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart builder: filter/calculate transforms, data preview, and inline expression validation
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.<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).**
|
||||
- **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,16 +79,14 @@ 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
|
||||
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 `<rect>`). **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
|
||||
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.
|
||||
@@ -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
|
||||
|
||||
@@ -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".
|
||||
@@ -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.<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
|
||||
|
||||
### 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_).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(<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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<TimeUnit, string> = {
|
||||
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). */
|
||||
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.<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. */
|
||||
const SORT_OPTIONS: ReadonlyArray<SegmentedOption<'none' | 'ascending' | 'descending'>> = [
|
||||
{ value: 'none', label: 'None' },
|
||||
@@ -488,6 +905,8 @@ export function ChartBuilderModal() {
|
||||
Building from <strong>{datasetName}</strong>
|
||||
</p>
|
||||
|
||||
<DataSection />
|
||||
|
||||
<div className={styles.field}>
|
||||
<span className={styles.fieldLabel}>Mark</span>
|
||||
<SegmentedControl
|
||||
|
||||
@@ -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', () => {
|
||||
test('builds a linked snippet, activates it, and resets the builder', () => {
|
||||
const id = seedDataset('Sales', [
|
||||
|
||||
@@ -18,20 +18,26 @@ import {
|
||||
buildSnippetSpecText,
|
||||
defaultBuilderConfig,
|
||||
defaultFieldType,
|
||||
effectiveColumns,
|
||||
generateChartName,
|
||||
isBuilderConfigValid,
|
||||
isChannelTypeAllowed,
|
||||
pruneEncodings,
|
||||
supportsAggregate,
|
||||
supportsBin,
|
||||
supportsTimeUnit,
|
||||
validFieldTypes,
|
||||
validFilterOps,
|
||||
type AggregateOp,
|
||||
type BuilderCalculate,
|
||||
type BuilderColumns,
|
||||
type BuilderConfig,
|
||||
type BuilderFilter,
|
||||
type BuilderWarningFix,
|
||||
type ChannelMapping,
|
||||
type ChannelName,
|
||||
type FieldType,
|
||||
type FilterMode,
|
||||
type MarkType,
|
||||
type SortOrder,
|
||||
type StackMode,
|
||||
@@ -54,6 +60,15 @@ const EMPTY_COLUMNS: BuilderColumns = { columns: [], columnTypes: [] };
|
||||
*/
|
||||
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 {
|
||||
/** The dataset being built from, or null when none is loaded. */
|
||||
datasetId: number | null;
|
||||
@@ -87,6 +102,24 @@ export interface ChartBuilderState {
|
||||
setSort: (sort: SortOrder | undefined) => void;
|
||||
/** Stacking mode for bar/area + a colour series; `undefined` = Vega-Lite default. */
|
||||
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;
|
||||
setHeight: (height: number | undefined) => void;
|
||||
/** 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';
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
function updateEncoding(
|
||||
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
|
||||
// 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.
|
||||
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 =
|
||||
valid.find((t) => isChannelTypeAllowed(channel, t)) ??
|
||||
defaultFieldType(columnType(s.columns, columnName));
|
||||
valid.find((t) => isChannelTypeAllowed(channel, t)) ?? defaultFieldType(colType);
|
||||
mapping = { field: columnName, type }; // a fresh mapping clears prior transforms
|
||||
}
|
||||
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 } })),
|
||||
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: () =>
|
||||
set((s) => ({
|
||||
config: {
|
||||
|
||||
@@ -16,8 +16,16 @@ import {
|
||||
buildChartSpec,
|
||||
buildSnippetSpecText,
|
||||
generateChartName,
|
||||
validFilterOps,
|
||||
filterOpArity,
|
||||
buildTransforms,
|
||||
calculatedFieldNames,
|
||||
effectiveColumns,
|
||||
pruneEncodings,
|
||||
type BuilderCalculate,
|
||||
type BuilderColumns,
|
||||
type BuilderConfig,
|
||||
type BuilderFilter,
|
||||
type ChannelMapping,
|
||||
} from './chart-builder';
|
||||
import { VEGA_LITE_SCHEMA_URL } from './snippet';
|
||||
@@ -831,3 +839,229 @@ describe('generateChartName', () => {
|
||||
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
@@ -73,6 +73,82 @@ export type SortOrder = (typeof SORT_ORDERS)[number];
|
||||
export const STACK_MODES = ['zero', 'normalize'] as const;
|
||||
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
|
||||
* transforms. `field` is omitted only for a `count` aggregate (which counts records
|
||||
@@ -108,6 +184,10 @@ export interface BuilderConfig {
|
||||
sort?: SortOrder;
|
||||
/** Stacking for bar/area + a colour series (part-to-whole). */
|
||||
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;
|
||||
}
|
||||
|
||||
// --- 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`). */
|
||||
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 →
|
||||
* Output). Includes the schema reference, a named data reference to the dataset,
|
||||
* the mark with tooltips enabled, every mapped encoding (field, type, and any
|
||||
* aggregate/bin/timeUnit transform), chart-level sort (rank a categorical axis by
|
||||
* its measure) and stack (part-to-whole), and any explicit width/height. Unmapped
|
||||
* any top-level `transform` (calculated fields then row filters), the mark with
|
||||
* tooltips enabled, every mapped encoding (field, type, and any aggregate/bin/
|
||||
* 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
|
||||
* entirely (validation prevents saving that, but the live preview may render a bare
|
||||
* mark while the user is still configuring).
|
||||
@@ -697,9 +954,15 @@ export function buildChartSpec(config: BuilderConfig): ChartSpec {
|
||||
const spec: ChartSpec = {
|
||||
$schema: VEGA_LITE_SCHEMA_URL,
|
||||
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>> = {};
|
||||
for (const [channel, mapping] of mappedChannels(config)) {
|
||||
encoding[channel] = encodingObject(mapping);
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user