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

+ {feedback.text} +

+ ) : ( +

+ {feedback.text} +

+ ); +} + +/** + * A contextual pointer to the Vega expression vocabulary, shown only when an + * expression input is in play (a calculated field, or a filter in expression mode) — + * the place the user needs to know what functions/operators exist. + */ +function ExprHelp() { + return ( +

+ Filter expressions and calculated fields use the{' '} + + Vega expression language + + (opens in a new tab) + + . +

+ ); +} + +/** 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 ( +
+
+ {expressionMode ? ( + updateFilter(filter.id, { expr: e.target.value })} + /> + ) : ( + + )} + +
+ + {!expressionMode && ( +
+ + {arity === 'range' ? ( + <> + updateFilter(filter.id, { value: e.target.value })} + /> + + updateFilter(filter.id, { value2: e.target.value })} + /> + + ) : ( + updateFilter(filter.id, { value: e.target.value })} + /> + )} +
+ )} + + {expressionMode && } + + {hasColumns && ( + + )} +
+ ); +} + +/** 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 ( +
+
+ updateCalculate(calc.id, { as: e.target.value })} + /> + + updateCalculate(calc.id, { expr: e.target.value })} + /> + +
+ +
+ ); +} + +/** + * 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 ( +
+ + + {open && + (rows ? ( +
+ + + + {baseColumns.columns.map((col) => ( + + ))} + + + + {rows.map((row, ri) => ( + + {baseColumns.columns.map((col) => ( + + ))} + + ))} + +
+ {col}{' '} + {typeBadge(typeOf(col))} +
{cellText(row[col])}
+
+ ) : ( +

This dataset has no tabular rows to preview.

+ ))} + + {open && rows && dataset.rowCount != null && dataset.rowCount > rows.length && ( +

+ Showing the first {rows.length} of {dataset.rowCount.toLocaleString()} rows. +

+ )} +
+ ); +} + +/** + * 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 ( +
+ Data + + {/* 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. */} + + +
+
+ Filters + +
+ {(filters ?? []).map((f) => ( + + ))} +
+ +
+
+ Calculated fields + +
+ {(calculates ?? []).map((c) => ( + + ))} +
+ + {hasExpression && } +
+ ); +} + /** Sort control values: 'none' maps to an unsorted config. */ const SORT_OPTIONS: ReadonlyArray> = [ { value: 'none', label: 'None' }, @@ -488,6 +905,8 @@ export function ChartBuilderModal() { Building from {datasetName}

+ +
Mark { }); }); +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', [ diff --git a/src/app/stores/ChartBuilderStore.ts b/src/app/stores/ChartBuilderStore.ts index 2d2f217..e10d053 100644 --- a/src/app/stores/ChartBuilderStore.ts +++ b/src/app/stores/ChartBuilderStore.ts @@ -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>) => 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>) => 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((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((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: { diff --git a/src/core/chart-builder.test.ts b/src/core/chart-builder.test.ts index 6cc9189..c860a5a 100644 --- a/src/core/chart-builder.test.ts +++ b/src/core/chart-builder.test.ts @@ -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 => ({ + id: 'f1', + mode: 'predicate', + ...over, +}); +const calc = (over: Partial): 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); + }); +}); diff --git a/src/core/chart-builder.ts b/src/core/chart-builder.ts index 9b34b92..a3779ca 100644 --- a/src/core/chart-builder.ts +++ b/src/core/chart-builder.ts @@ -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 | 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 | 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 | 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> { + const out: Array> = []; + 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; @@ -686,9 +942,10 @@ function encodingObject(mapping: ChannelMapping): Record { /** * 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> = {}; for (const [channel, mapping] of mappedChannels(config)) { encoding[channel] = encodingObject(mapping); diff --git a/src/core/expr-validate.test.ts b/src/core/expr-validate.test.ts new file mode 100644 index 0000000..3559131 --- /dev/null +++ b/src/core/expr-validate.test.ts @@ -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([]); + }); +}); diff --git a/src/core/expr-validate.ts b/src/core/expr-validate.ts new file mode 100644 index 0000000..3241a93 --- /dev/null +++ b/src/core/expr-validate.ts @@ -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.` 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.` (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(); + 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; + 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.` / `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 | null { + const object = member.object as Record | undefined; + if (!object || object.type !== 'Identifier' || object.name !== 'datum') return null; + const property = member.property as Record | 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; +}