mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart builder: SelectControl pickers, channel chooser, per-type aggregates
This commit is contained in:
@@ -421,6 +421,13 @@ open, wraps `Tab`/`Shift+Tab` within the modal, and restores focus on close. The
|
|||||||
optional `initialSelector` picks _which_ child takes focus (e.g. Cancel for a
|
optional `initialSelector` picks _which_ child takes focus (e.g. Cancel for a
|
||||||
destructive confirm); it falls back to the first focusable child.
|
destructive confirm); it falls back to the first focusable child.
|
||||||
|
|
||||||
|
The trap wraps Tab **only within the shell element**: content portaled to
|
||||||
|
`<body>` (a `SelectControl` panel or other disclosure popover opened from inside
|
||||||
|
a modal) is outside both the trap's DOM subtree and its keydown listener. Such
|
||||||
|
popovers must therefore handle Tab themselves — close and refocus their trigger
|
||||||
|
(the native-select convention) — so focus can't strand outside the dialog while
|
||||||
|
it is open.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// src/app/hooks/useFocusTrap.ts
|
// src/app/hooks/useFocusTrap.ts
|
||||||
import { useRef, useEffect } from 'react';
|
import { useRef, useEffect } from 'react';
|
||||||
|
|||||||
@@ -471,6 +471,10 @@ the existing view is re-measured via a `ResizeObserver`-driven event — see §8
|
|||||||
- **Don't** put reference resolution or fit-mode logic in the renderer — it is
|
- **Don't** put reference resolution or fit-mode logic in the renderer — it is
|
||||||
pure core logic and must be unit-testable without a DOM.
|
pure core logic and must be unit-testable without a DOM.
|
||||||
- **Don't** mutate the input spec anywhere in the pipeline.
|
- **Don't** mutate the input spec anywhere in the pipeline.
|
||||||
|
- **Fit modes overwrite the spec's own sizing** (Width replaces `width` _and
|
||||||
|
deletes_ `height`, etc.), so a surface that lets the user set an explicit
|
||||||
|
width/height must pass `fitMode: 'default'` while either is set and reserve the
|
||||||
|
container fit for auto sizing — the Chart Builder preview does exactly this.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -418,6 +418,34 @@ _(Consulted via /council → NN/g #4 consistency, #6 recognition-over-recall, #8
|
|||||||
WAI-ARIA APG disclosure + menu-and-menubar; Carbon popover/overflow-menu/text-toolbar. This
|
WAI-ARIA APG disclosure + menu-and-menubar; Carbon popover/overflow-menu/text-toolbar. This
|
||||||
bullet is the contract.)_
|
bullet is the contract.)_
|
||||||
|
|
||||||
|
**Resolved — value pickers are the SelectControl disclosure, not native `<select>`.** A
|
||||||
|
native select's popup can't be token-styled and renders differently on every browser/OS — a
|
||||||
|
foreign object inside a designed surface — so anywhere a control is part of one,
|
||||||
|
`SelectControl` replaces it: the same disclosure primitive as the settings popovers (trigger
|
||||||
|
with `aria-expanded`/`aria-controls`; portaled, `fixed`-positioned panel; labelled `group` of
|
||||||
|
option buttons — **not** an ARIA menu or combobox; single-open registry; Esc closes and
|
||||||
|
refocuses the trigger; outside press closes; open lands focus on the selected option;
|
||||||
|
Arrow/Home/End rove). The selected option carries `aria-current` and a visible ✓, never
|
||||||
|
colour alone. The same control doubles as an **action picker** (no `value`; e.g. "Add field
|
||||||
|
to which channel?"). A custom `triggerClassName` _replaces_ the default trigger styling, so
|
||||||
|
chip-styled triggers (the pill's type chip, the shelf's field chips) stay chips.
|
||||||
|
_(Consulted via /council → WAI-ARIA APG disclosure/menu-button/radio, Carbon, NN/g #4. This
|
||||||
|
bullet is the contract; cite it, not the source.)_
|
||||||
|
|
||||||
|
**Resolved — field→channel assignment: explicit choice, visible armed state.** Clicking a
|
||||||
|
shelf field with no channel armed opens an explicit **channel chooser** (the channels that
|
||||||
|
accept the field; an occupied one is labelled with what it replaces) — never a silent
|
||||||
|
first-empty-seat grab (NN/g #3, user control). Arming a channel slot short-circuits the
|
||||||
|
chooser (the fast path) and **must be visible where the next click happens**: the shelf
|
||||||
|
gains an accent ring plus a polite `role="status"` line naming the target ("Assigning to X —
|
||||||
|
choose a field below. Esc cancels"); **Esc disarms** without closing the modal (captured
|
||||||
|
before the dialog's own Escape handling). Chart-level properties (title/subtitle,
|
||||||
|
width/height) live **on the chart side**, in a strip under the preview — a chart property
|
||||||
|
belongs on the chart (NN/g #4; the Tableau/Lyra convention). A link-styled affordance that
|
||||||
|
_acts_ rather than navigates is mis-dressed: such actions are **ghost buttons** with
|
||||||
|
verb-first labels (Carbon links-vs-buttons; "Use a constant"). _(Consulted via /council →
|
||||||
|
NN/g #1/#3/#4, Carbon button/link usage. This bullet is the contract.)_
|
||||||
|
|
||||||
**Resolved — an error names the right fix, not a boilerplate one.** Don't staple a generic
|
**Resolved — an error names the right fix, not a boilerplate one.** Don't staple a generic
|
||||||
remedy onto every failure. A missing dataset reference is **not** a JSON/spec syntax problem,
|
remedy onto every failure. A missing dataset reference is **not** a JSON/spec syntax problem,
|
||||||
so the preview gives it a tailored, fixable line — _"Dataset «X» not found. Create it from
|
so the preview gives it a tailored, fixable line — _"Dataset «X» not found. Create it from
|
||||||
|
|||||||
@@ -25,6 +25,43 @@
|
|||||||
Newest first. The at-a-glance build-order tracker is §4; per-item detail is §3. This log is
|
Newest first. The at-a-glance build-order tracker is §4; per-item detail is §3. This log is
|
||||||
the quick "where are we" — read it first.
|
the quick "where are we" — read it first.
|
||||||
|
|
||||||
|
- **2026-06-12 (polish batch)** — **the 2B visual-pass findings fixed; the parked council
|
||||||
|
batch resolved and applied; two capability gaps closed.**
|
||||||
|
- **Aggregation for any field type.** `validAggregateOps` replaces the quantitative-only
|
||||||
|
gate: **Count distinct** on anything (a nominal Colour/Y now measures unique values —
|
||||||
|
emitted as a quantitative `{ aggregate: 'distinct' }`), Min/Max on temporal/ordinal,
|
||||||
|
arithmetic still quantitative-only. Retyping keeps a still-valid aggregate.
|
||||||
|
- **Title/subtitle** (pulled forward from the placement discussion): top-level `title`
|
||||||
|
emission; a user title becomes the snippet name; subtitle gated on a title.
|
||||||
|
- **Council batch applied:** the type chip and every native `<select>` in the builder are
|
||||||
|
now **`SelectControl`** — a reusable value-picker disclosure (the SortControl primitive
|
||||||
|
generalized; replaces native selects app-wide where the control is part of a designed
|
||||||
|
surface). Field chips open an explicit **channel chooser** (occupied channels say what
|
||||||
|
they'd replace); an **armed** channel short-circuits it and is now visible (accent ring +
|
||||||
|
status line + Esc disarms). "or constant" → **"Use a constant"** ghost button. Chart
|
||||||
|
properties (Title · Subtitle · W · H) moved to a **strip under the preview**; Sort/Stack
|
||||||
|
stay by the encodings. Resolutions recorded in `architecture/10` §5; `ux-second-pass.md`
|
||||||
|
cleared (drag stays deferred).
|
||||||
|
- **Visual-pass fixes:** pill ✕ flush right (pills hug content), Swap X/Y beside the Axes
|
||||||
|
heading, field shelf scrolls (280px viewport, sticky group heads), per-channel transforms
|
||||||
|
inline beside the pill, preview honors explicit width/height (fit-mode only while auto).
|
||||||
|
- **Verified:** `typecheck` + `test` (774, +10) + `eslint` + `build`. **Owed:** a fresh
|
||||||
|
visual look at the reworked surfaces (user-driven).
|
||||||
|
|
||||||
|
- **2026-06-12 (scope)** — **3D added to Phase 3: builder entry points & discoverability**
|
||||||
|
(council-reviewed: NN/g #6 recognition + #7 expert accelerators, Carbon empty-states).
|
||||||
|
Today the builder's only door is Datasets → row "Build Chart" while the primary "Create
|
||||||
|
New Snippet" lands in blank Monaco — the data model's shape, not the user's intent.
|
||||||
|
Decided: fork the library's creation surface (primary **Build Chart**, ghost **New JSON
|
||||||
|
snippet**), give the builder an internal dataset picker + a no-datasets empty state, add
|
||||||
|
an onboarding tile. 3D lands **with or just before 3A** — the Build-Chart button opens
|
||||||
|
onto the intent front door, making 3A the app's guided creation flow. Detail in §3 · 3D.
|
||||||
|
|
||||||
|
- **2026-06-11 (scope)** — **3C added to Phase 3: open-in-builder (strict spec hydration).**
|
||||||
|
A snippet-list button, enabled exactly when the spec round-trips losslessly through the
|
||||||
|
builder dialect (checked by re-assemble + deep-compare, not feature enumeration). Strict
|
||||||
|
only — no lossy/residue modes (§5's round-trip trap stays closed). Detail in §3 · 3C.
|
||||||
|
|
||||||
- **2026-06-11 (Phase 2)** — **2A + 2B shipped: the field-first interaction substrate.** Up next: **3A intent-first front door**.
|
- **2026-06-11 (Phase 2)** — **2A + 2B shipped: the field-first interaction substrate.** Up next: **3A intent-first front door**.
|
||||||
- **2A · Value-or-field channels (the Property model).** `ChannelMapping` gained a constant
|
- **2A · Value-or-field channels (the Property model).** `ChannelMapping` gained a constant
|
||||||
`value` arm; the assembler emits `{ value }` and the measure/stack/area-split/prune logic
|
`value` arm; the assembler emits `{ value }` and the measure/stack/area-split/prune logic
|
||||||
@@ -304,6 +341,12 @@ revision (field shelf alongside the channel rows)._
|
|||||||
|
|
||||||
### Phase 3 — Tier C (the intent-first front door) — _the defining feature of this push_
|
### Phase 3 — Tier C (the intent-first front door) — _the defining feature of this push_
|
||||||
|
|
||||||
|
The grand idea, stated once: **the builder is the app's rapid, intuitive on-ramp; Monaco
|
||||||
|
is the expert surface; the JSON spec stays the document.** Phase 3 is where that becomes
|
||||||
|
true — 3A gives the builder an intent-first opening screen, 3D makes that screen the
|
||||||
|
app's guided creation flow (not a feature hidden in a modal), and 3B/3C seed and re-enter
|
||||||
|
it.
|
||||||
|
|
||||||
**3A · Intent-first front door** — _the B→C step_
|
**3A · Intent-first front door** — _the B→C step_
|
||||||
Source: research §5/§8 Tier C (FT Visual Vocabulary + Datawrapper intent taxonomy). A
|
Source: research §5/§8 Tier C (FT Visual Vocabulary + Datawrapper intent taxonomy). A
|
||||||
_"what do you want to show?"_ entry mapping **intent × column types → recommended mark +
|
_"what do you want to show?"_ entry mapping **intent × column types → recommended mark +
|
||||||
@@ -331,7 +374,72 @@ this as the deferred tier, so this is the planned amendment, not drift._
|
|||||||
Source: Lyra §3.7. A small set of **curated starter snippets**, one per covered FT intent
|
Source: Lyra §3.7. A small set of **curated starter snippets**, one per covered FT intent
|
||||||
(Magnitude/Bar, Change-over-time/Line, Correlation/Point, Distribution/histogram,
|
(Magnitude/Bar, Change-over-time/Line, Correlation/Point, Distribution/histogram,
|
||||||
Part-to-whole/stacked). Improves first-run, doubles as living documentation of what the app
|
Part-to-whole/stacked). Improves first-run, doubles as living documentation of what the app
|
||||||
does well. Natural home: the snippet library. _Spec impact: §02 (library seed content)._
|
does well. Natural home: the snippet library. **Author them in the builder dialect so they
|
||||||
|
hydrate via 3C.** _Spec impact: §02 (library seed content)._
|
||||||
|
|
||||||
|
**3C · Open in builder (strict spec hydration)** — _decided 2026-06-11; pairs with 3B_
|
||||||
|
Reverse the assembler: a pure `parseChartSpec(spec) → BuilderConfig | null` so an existing
|
||||||
|
snippet can re-enter the builder populated. **Strict policy only** — a snippet-list button
|
||||||
|
("Open in builder") enabled exactly when the spec is **losslessly** representable in the
|
||||||
|
builder's dialect; anything richer stays Monaco-only. Losslessness is checked not by
|
||||||
|
enumerating unsupported features but by **re-assembling the parsed config and
|
||||||
|
deep-comparing against the original** (ignoring key order, `$schema`, and the injected
|
||||||
|
`tooltip: true`) — exact, ~20 lines, and it stays correct automatically as the dialect
|
||||||
|
grows (every Phase-4 addition widens what hydrates for free). No lossy or
|
||||||
|
residue-preserving mode: the moment the builder can overwrite a richer spec, the GUI starts
|
||||||
|
competing with the JSON as the document (the Lyra round-trip trap — §5).
|
||||||
|
|
||||||
|
Value: converts the builder from **create-only to create-and-revise** for its own output,
|
||||||
|
and makes the 3B starters openable as builder seeds. Known fiddly bits (all mechanical):
|
||||||
|
inverse of `escapeVegaField`, filter-value un-coercion back to the `BuilderFilter` string
|
||||||
|
shape, `sort: "-y"` → `{sort: 'descending'}`. Round-trip property tests
|
||||||
|
(`parse(build(config))` ≈ `config`) are the natural core coverage. Per-row check in the
|
||||||
|
library is cheap but memoize it. **Decisions at build time:** save semantics on an opened
|
||||||
|
snippet (update-in-place via a builder "edit" mode vs. always-create-new — today the
|
||||||
|
builder only creates) and the disabled-button affordance (disabled-with-reason vs. hidden —
|
||||||
|
park in `ux-second-pass.md` if non-obvious). _Spec impact: §02 (library row action) + §06
|
||||||
|
(hydration + the strict gate)._
|
||||||
|
|
||||||
|
**3D · Entry points & discoverability** — _decided 2026-06-12, council-reviewed; lands with or just before 3A_
|
||||||
|
Today the builder has **one entry, two levels deep**: Datasets modal → per-dataset "Build
|
||||||
|
Chart" (`DatasetsModal.tsx`) — the data model's shape ("charts come from datasets"), not
|
||||||
|
the user's intent ("I want to make a chart"). Meanwhile the library's pinned primary
|
||||||
|
action, "Create New Snippet", drops straight into blank Monaco — the **expert** path is
|
||||||
|
the default, and the audience the builder serves may never find it. Council ruling
|
||||||
|
(recorded here; contract updates at build time):
|
||||||
|
|
||||||
|
- **NN/g #6 (recognition over recall):** the build-a-chart action must be visible where
|
||||||
|
the intent forms — the library's creation surface, not recalled as a dataset-row action.
|
||||||
|
- **NN/g #7 (flexibility/efficiency):** the guided path is what novices see; raw JSON is
|
||||||
|
the expert accelerator — visible, one click, never hidden. Builder-forward, Monaco
|
||||||
|
intact.
|
||||||
|
- **Carbon empty-states:** one primary action per surface ("pick the most important"; a
|
||||||
|
tertiary button for the secondary CTA); a no-data state explains what the space will
|
||||||
|
hold, offers one primary next step, and never dead-ends.
|
||||||
|
|
||||||
|
The decided shape:
|
||||||
|
|
||||||
|
1. **Fork the library's creation surface** — primary **Build Chart** (opens the builder),
|
||||||
|
ghost/tertiary **New JSON snippet** (today's instant create, unchanged). Two plain
|
||||||
|
buttons with clear hierarchy — no split/menu-button widget (two static choices don't
|
||||||
|
earn an ARIA menu).
|
||||||
|
2. **Dataset picker inside the builder** (header select, default = most recently used), so
|
||||||
|
the builder opens without a preselected dataset. Build-time detail: switching datasets
|
||||||
|
mid-build re-derives smart defaults vs. prunes incompatible encodings — decide then.
|
||||||
|
Useful beyond 3D (switch data without leaving the builder).
|
||||||
|
3. **No-datasets empty state** in the builder, per the Carbon no-data pattern: what the
|
||||||
|
builder does + one primary action ("Add a dataset" → Datasets modal). No dead end.
|
||||||
|
4. **Onboarding tile** — a data-first path ("import your data → build a chart") beside the
|
||||||
|
existing examples gallery.
|
||||||
|
5. **Keep the dataset-row "Build Chart"** — contextual shortcut, pre-picks the dataset; it
|
||||||
|
just stops being the only door.
|
||||||
|
|
||||||
|
**Composition with 3A (the point of the sequencing):** the Build-Chart button lands on the
|
||||||
|
intent front door — "what do you want to show, with which data?" _is_ the builder's
|
||||||
|
opening screen. 3D without 3A opens onto the mark-first builder (fine, interim); 3A
|
||||||
|
without 3D is a front door nobody finds. Discoverability is a prerequisite for 3A's value,
|
||||||
|
so: **3D with or just before 3A.** _Spec impact: §02 (library creation actions +
|
||||||
|
onboarding) + §06 (dataset picker, empty state)._
|
||||||
|
|
||||||
### Builder UX & perf — in-flight fixes (2026-06-10, from dogfooding the Superstore dataset)
|
### Builder UX & perf — in-flight fixes (2026-06-10, from dogfooding the Superstore dataset)
|
||||||
|
|
||||||
@@ -418,8 +526,10 @@ Phase 1 1A actionable hints ✓ done
|
|||||||
1E expr-validate ✓ done (syntax + unknown-field; completion popup deferred)
|
1E expr-validate ✓ done (syntax + unknown-field; completion popup deferred)
|
||||||
Phase 2 2A value-or-field channels (Property model) ✓ done
|
Phase 2 2A value-or-field channels (Property model) ✓ done
|
||||||
2B field shelf + in-place type cycling ✓ done (field-first + on-chart shelves)
|
2B field shelf + in-place type cycling ✓ done (field-first + on-chart shelves)
|
||||||
Phase 3 3A intent-first front door (Tier C) ← next; built on 2B; the defining feature
|
Phase 3 3D entry points & discoverability ← next, with/just before 3A (its prerequisite)
|
||||||
|
3A intent-first front door (Tier C) ← built on 2B; the defining feature
|
||||||
3B starter examples
|
3B starter examples
|
||||||
|
3C open in builder (strict hydration) ← added 2026-06-11; pairs with 3B
|
||||||
Phase 4 (gated) theta/facets/styling-overrides/undo/lookup — decide after Phase 3
|
Phase 4 (gated) theta/facets/styling-overrides/undo/lookup — decide after Phase 3
|
||||||
|
|
||||||
Also shipped (builder UX/perf, from dogfooding): near-fullscreen modal, internal-scroll
|
Also shipped (builder UX/perf, from dogfooding): near-fullscreen modal, internal-scroll
|
||||||
@@ -431,7 +541,10 @@ Rationale for the order: Phase 1 is the cheapest large quality jump and needs no
|
|||||||
interaction model, so it ships value while the bigger design settles. Phase 2 is pure
|
interaction model, so it ships value while the bigger design settles. Phase 2 is pure
|
||||||
substrate — low _user-visible_ payoff alone, but Phase 3 is much cleaner on top of it than
|
substrate — low _user-visible_ payoff alone, but Phase 3 is much cleaner on top of it than
|
||||||
bolted onto the channel-first UI. Phase 3 delivers the brief's headline ("recommendations
|
bolted onto the channel-first UI. Phase 3 delivers the brief's headline ("recommendations
|
||||||
on the fly"). Phase 4 is deliberately deferred to protect the "moderately capable" ceiling.
|
on the fly") — 3D first, because a front door nobody finds delivers nothing: the entry
|
||||||
|
points make the builder the app's guided creation flow, then 3A gives that flow its
|
||||||
|
intent-first opening. Phase 4 is deliberately deferred to protect the "moderately capable"
|
||||||
|
ceiling.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ The Chart Builder is a visual, no-JSON way to compose a Vega-Lite chart from a s
|
|||||||
|
|
||||||
A two-pane modal:
|
A two-pane modal:
|
||||||
|
|
||||||
- **Left — configuration:** dataset name, a **Data** section (row filters, calculated fields, and a collapsible row preview — see _Data_ below), mark type selector, a **field shelf** (the dataset's columns, field-first), a **Marks** card (the Colour and Size encodings), 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, a **field shelf** (the dataset's columns, field-first), a **Marks** card (the Colour and Size encodings), and a "Create Snippet" action. Chart-level properties (title/subtitle, width/height) sit on the **preview side**, under the chart (see _Chart properties_).
|
||||||
- **Right — live preview:** the **Columns** (X) and **Rows** (Y) shelves stacked above a rendered chart that updates as the configuration changes, with a placeholder/error area. Position is a property of the chart, so its controls sit on the chart (Tableau's Columns/Rows metaphor).
|
- **Right — live preview:** the **Columns** (X) and **Rows** (Y) shelves stacked above a rendered chart that updates as the configuration changes, with a placeholder/error area. Position is a property of the chart, so its controls sit on the chart (Tableau's Columns/Rows metaphor).
|
||||||
|
|
||||||
## Data (preview, filters, calculated fields)
|
## Data (preview, filters, calculated fields)
|
||||||
@@ -62,9 +62,9 @@ The section is ordered **input → shaping** so the distinction reads at a glanc
|
|||||||
Exactly four channels are offered: **X, Y** (the positional axes, on the on-chart Columns/Rows shelves) and **Colour, Size** (the Marks card). Assignment is **field-first**.
|
Exactly four channels are offered: **X, Y** (the positional axes, on the on-chart Columns/Rows shelves) and **Colour, Size** (the Marks card). Assignment is **field-first**.
|
||||||
|
|
||||||
- **The field shelf** lists the dataset's detected columns (see _Datasets_), each with a small type glyph, plus any calculated fields and a field-less **"Count of records"** measure (Vega-Lite `count`). Past a threshold of columns the shelf groups into **Dimensions** (categories/dates) and **Measures** (numerics); a small dataset stays a single flat list. A column already mapped somewhere is dimmed (it may still be placed on more than one channel).
|
- **The field shelf** lists the dataset's detected columns (see _Datasets_), each with a small type glyph, plus any calculated fields and a field-less **"Count of records"** measure (Vega-Lite `count`). Past a threshold of columns the shelf groups into **Dimensions** (categories/dates) and **Measures** (numerics); a small dataset stays a single flat list. A column already mapped somewhere is dimmed (it may still be placed on more than one channel).
|
||||||
- **Assigning a field:** clicking a shelf field places it on the **armed** channel, else the first empty channel that accepts it (X→Y→Colour→Size). A channel slot is armed by clicking it (then a clicked field fills it); the armed slot shows a "pick a field" prompt. A field that no channel can take is a no-op.
|
- **Assigning a field:** clicking a shelf field opens an explicit **channel chooser** listing the channels that accept it (an occupied channel is labelled with what it would replace); picking one places the field there. With a channel **armed**, the chooser is skipped — the clicked field fills the armed channel directly. A channel slot is armed by clicking it; arming is visible at the shelf (an accent ring plus a status line naming the target — "Assigning to X — choose a field below. Esc cancels"), and **Esc** disarms without closing the builder. A field that no channel can take offers no choices.
|
||||||
- **A mapped channel is a pill:** a leading type chip, the field (or "Count") label, and a remove (×). The type chip **is the field-type control** — activating it cycles the field's type within the set **valid for that column** and channel (Tier B valid-type locking): number → {Quantitative (default), Ordinal, Nominal}; date → {Temporal}; text → {Nominal (default), Ordinal}; boolean → {Nominal}. When only one type applies (e.g. a date), the chip is inert. A fresh mapping defaults its type from the inferred column type (numeric → Quantitative, date → Temporal, otherwise Nominal).
|
- **A mapped channel is a pill:** a leading type chip, the field (or "Count") label, and a remove (×). The type chip **is the field-type control** — activating it opens a **direct pick** of the types **valid for that column** and channel (Tier B valid-type locking): number → {Quantitative (default), Ordinal, Nominal}; date → {Temporal}; text → {Nominal (default), Ordinal}; boolean → {Nominal}. When only one type applies (e.g. a date), the chip is inert. A fresh mapping defaults its type from the inferred column type (numeric → Quantitative, date → Temporal, otherwise Nominal).
|
||||||
- **Constant values (the Property model):** the **Colour** and **Size** channels may instead hold a **fixed constant** — a literal colour or size applied to every mark, emitted as Vega-Lite `{ value }` rather than a field binding. An empty Colour/Size slot offers an "or constant" affordance; the bound constant shows a colour picker (Colour) or a number (Size). X and Y stay field-only (a constant position is not useful). Switching a channel between a field and a constant is reversible, and the prior field type is preserved across the toggle.
|
- **Constant values (the Property model):** the **Colour** and **Size** channels may instead hold a **fixed constant** — a literal colour or size applied to every mark, emitted as Vega-Lite `{ value }` rather than a field binding. An empty Colour/Size slot offers a **"Use a constant"** ghost button; the bound constant shows a colour picker (Colour) or a number (Size). X and Y stay field-only (a constant position is not useful). Switching a channel between a field and a constant is reversible, and the prior field type is preserved across the toggle.
|
||||||
- **Size discipline:** the **Size** channel accepts only columns whose natural type is a magnitude (numeric) — size implies an ordered magnitude, so categories and dates are not placed on Size by assignment (they remain available on X/Y/Colour). A constant size is always allowed.
|
- **Size discipline:** the **Size** channel accepts only columns whose natural type is a magnitude (numeric) — size implies an ordered magnitude, so categories and dates are not placed on Size by assignment (they remain available on X/Y/Colour). A constant size is always allowed.
|
||||||
- **Clearing** a channel (the pill's ×) leaves it out of the produced spec.
|
- **Clearing** a channel (the pill's ×) leaves it out of the produced spec.
|
||||||
- A **Swap X/Y** control, by the on-chart shelves, exchanges the X and Y bindings (field/constant and type) in one click — for quickly flipping the axes of the pre-populated default.
|
- A **Swap X/Y** control, by the on-chart shelves, exchanges the X and Y bindings (field/constant and type) in one click — for quickly flipping the axes of the pre-populated default.
|
||||||
@@ -77,7 +77,7 @@ Each on-chart shelf shows, beside its axis slot, a **non-interactive placeholder
|
|||||||
|
|
||||||
Once a column is mapped, the channel offers the transforms that apply to its field type — and only those:
|
Once a column is mapped, the channel offers the transforms that apply to its field type — and only those:
|
||||||
|
|
||||||
- **Aggregate** (a measure / Quantitative field): one of `Sum`, `Mean`, `Median`, `Min`, `Max`, or `None`. (The field-less `Count` measure is chosen via the "Count of records" column option above.)
|
- **Aggregate** (any field — the menu narrows by field type): a Quantitative field offers `Sum`, `Mean`, `Median`, `Min`, `Max`, `Count distinct`, or `None`; a Temporal or Ordinal field offers `Min`, `Max`, `Count distinct` (an ordering but no arithmetic); a Nominal field offers `Count distinct` alone. `Count distinct` counts a field's unique values, so the channel reads as a **quantitative measure** whatever the field's own type (e.g. "unique customers per region" on a Color or Y channel); the field's asserted type is preserved and restored when the aggregate is removed. (The field-less `Count` measure is chosen via the "Count of records" column option above.)
|
||||||
- **Bin** (a Quantitative field): bins the values into ranges — e.g. a Quantitative X binned with a Count Y is a histogram. Binning and aggregating the same field are mutually exclusive (setting one clears the other).
|
- **Bin** (a Quantitative field): bins the values into ranges — e.g. a Quantitative X binned with a Count Y is a histogram. Binning and aggregating the same field are mutually exclusive (setting one clears the other).
|
||||||
- **Granularity** (a Temporal field): a Vega-Lite `timeUnit` — Year, Year-Quarter, Year-Month, Year-Month-Day, Quarter, Month, Week, Day of month, Day of week, Hour — or `None` (raw timestamps). Defaults to **None** (no silent change to what the raw data shows).
|
- **Granularity** (a Temporal field): a Vega-Lite `timeUnit` — Year, Year-Quarter, Year-Month, Year-Month-Day, Quarter, Month, Week, Day of month, Day of week, Hour — or `None` (raw timestamps). Defaults to **None** (no silent change to what the raw data shows).
|
||||||
|
|
||||||
@@ -106,10 +106,13 @@ The builder surfaces short, plain-language hints for configurations that render
|
|||||||
|
|
||||||
A clean configuration shows no hints.
|
A clean configuration shows no hints.
|
||||||
|
|
||||||
### Dimensions (optional)
|
### Chart properties (optional)
|
||||||
|
|
||||||
|
A slim strip pinned **under the live preview** — these describe the chart itself, so they live on the chart side rather than in the configuration pane:
|
||||||
|
|
||||||
|
- **Title** and **Subtitle** text inputs, written into the spec's top-level `title` (a bare string for a lone title; the `{ text, subtitle }` object form when both are set). A subtitle is emitted only alongside a title — Vega-Lite has no standalone subtitle — so the Subtitle input is disabled until a title exists. A non-empty title is also preferred verbatim as the created snippet's name.
|
||||||
- Optional numeric **Width** and **Height** inputs in pixels.
|
- Optional numeric **Width** and **Height** inputs in pixels.
|
||||||
- When left empty, the chart uses default/responsive sizing (consistent with _Live Preview_); when provided, the values are written into the spec.
|
- When left empty, sizing is default/responsive (consistent with _Live Preview_); when provided, the values are written into the spec **and the builder preview renders at that explicit size** (the preview's fit-to-pane sizing applies only while sizing is auto).
|
||||||
|
|
||||||
## Live Preview
|
## Live Preview
|
||||||
|
|
||||||
@@ -127,7 +130,7 @@ A clean configuration shows no hints.
|
|||||||
|
|
||||||
Selecting "Create Snippet" produces the final artifact:
|
Selecting "Create Snippet" produces the final artifact:
|
||||||
|
|
||||||
- Builds a complete Vega-Lite spec containing: the schema reference, a named data reference to the dataset, any top-level `transform` (calculated fields first, then row filters — see _Data_), the chosen mark (with tooltips enabled), the bound encodings (a field encoding carries its field and field type plus any aggregate / bin / `timeUnit` transform; a constant encoding is a `{ value }`), 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 bound encodings (a field encoding carries its field and field type plus any aggregate / bin / `timeUnit` transform; a constant encoding is a `{ value }`), chart-level sort and stacking where set, any title/subtitle, and any explicit width/height.
|
||||||
- Channels left unmapped are omitted; if no encodings exist the spec omits the encoding block entirely (prevented by validation here).
|
- Channels left unmapped are omitted; if no encodings exist the spec omits the encoding block entirely (prevented by validation here).
|
||||||
- Creates a new snippet from that spec with an auto-generated descriptive name, adds it to the snippet library, and records that it was built from the dataset.
|
- Creates a new snippet from that spec with an auto-generated descriptive name, adds it to the snippet library, and records that it was built from the dataset.
|
||||||
- Links the snippet to the dataset by recording the dataset reference, so the bidirectional snippet↔dataset relationship is established (see _Datasets_).
|
- Links the snippet to the dataset by recording the dataset reference, so the bidirectional snippet↔dataset relationship is established (see _Datasets_).
|
||||||
|
|||||||
+10
-18
@@ -8,23 +8,15 @@ record the resolution into the contract (`docs/architecture/09`+`10` and the rel
|
|||||||
|
|
||||||
## Open
|
## Open
|
||||||
|
|
||||||
### Chart Builder — encoding type control: cycle vs. explicit pick
|
_(none — the 2026-06-12 batch resolved all parked items: type-cycle chip → direct-pick
|
||||||
|
SelectControl; field-assignment flow → explicit channel chooser + visible armed state, drag
|
||||||
|
still deferred; "or constant" → "Use a constant" ghost button; chart-level controls →
|
||||||
|
properties strip under the preview. Resolutions recorded in `architecture/10` §5 and
|
||||||
|
`spec/06`.)_
|
||||||
|
|
||||||
- **Where:** `src/app/components/ChartBuilderModal.tsx` → `ChannelPill` (the `pillType` chip).
|
## Deferred (not design debts, revisit on demand)
|
||||||
- **What:** a mapped channel's field type is shown as a **cycling chip** — activating it steps
|
|
||||||
N → O → Q → T within the set valid for the column/channel. It is compact and matches the
|
|
||||||
approved field-first pill design, but a _cycling_ button gives a screen-reader / keyboard
|
|
||||||
user no way to jump straight to a type; they step through the cycle. The pre-2B control was
|
|
||||||
an explicit `N | O | Q | T` segmented radio (direct pick, APG radio pattern).
|
|
||||||
- **Decision to make:** keep the cycle, restore the explicit segmented control, or a hybrid
|
|
||||||
(chip for a quick mouse cycle + an accessible radio/menu for direct selection).
|
|
||||||
- **Lens:** WAI-ARIA APG (radio vs. toggle button), IBM Carbon, the project's keyboard-first
|
|
||||||
accessibility bar.
|
|
||||||
|
|
||||||
### Chart Builder — empty Colour/Size "or constant" affordance
|
- **Drag-and-drop field assignment** — chips are click/keyboard-first by design; drag would
|
||||||
|
be a progressive enhancement on top of the chooser model, not a replacement. Revisit if
|
||||||
- **Where:** `ChannelPill` / `ChannelSlot` — the constant-value entry on Colour/Size (2A).
|
real usage asks for it (likely alongside Phase 4 faceting, where drag-to-shelf reads most
|
||||||
- **What:** an empty Colour/Size slot offers a small underlined "or constant" text button to
|
naturally).
|
||||||
switch the channel from a field binding to a fixed `{ value }`. Confirm the copy, emphasis,
|
|
||||||
and discoverability read well alongside the field-assign affordance.
|
|
||||||
- **Lens:** Carbon / GOV.UK content + control emphasis.
|
|
||||||
|
|||||||
@@ -103,18 +103,6 @@
|
|||||||
gap: var(--space-2);
|
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 {
|
.filterPredicate {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -173,7 +161,6 @@
|
|||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.filterField:focus-visible,
|
|
||||||
.calcName:focus-visible,
|
.calcName:focus-visible,
|
||||||
.valueInput:focus-visible,
|
.valueInput:focus-visible,
|
||||||
.exprInput:focus-visible {
|
.exprInput:focus-visible {
|
||||||
@@ -428,16 +415,6 @@
|
|||||||
color: var(--text-placeholder);
|
color: var(--text-placeholder);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini {
|
|
||||||
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: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle {
|
.toggle {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -451,31 +428,11 @@
|
|||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mini:focus-visible,
|
|
||||||
.dimInput:focus-visible {
|
.dimInput:focus-visible {
|
||||||
outline: 2px solid var(--focus);
|
outline: 2px solid var(--focus);
|
||||||
outline-offset: -1px;
|
outline-offset: -1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dimensions {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dimInputs {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dimField {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-1);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dimInput {
|
.dimInput {
|
||||||
width: 100px;
|
width: 100px;
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
@@ -713,6 +670,44 @@
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Scroll viewport around the field lists: a wide dataset (Superstore: 25 columns)
|
||||||
|
must not push the Marks card and chart controls below the fold. Group headings
|
||||||
|
pin to the top while their list scrolls under them. */
|
||||||
|
.shelfScroll {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
max-height: 280px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* An armed channel makes the shelf the live target — say so where the next click
|
||||||
|
happens (NN/g #1): an accent ring on the list plus the status line above it. */
|
||||||
|
.shelfArmed {
|
||||||
|
outline: 2px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
|
||||||
|
.armedHint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.armedHint strong {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shelfScroll .shelfGroupHead {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
margin-top: 0;
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
background: var(--layer-01);
|
||||||
|
}
|
||||||
|
|
||||||
.shelfList {
|
.shelfList {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -785,11 +780,14 @@
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pill — a bound channel (field/count or constant). */
|
/* Pill — a bound channel (field/count or constant). The per-channel transform
|
||||||
|
controls (Aggregate/Bin/Granularity) flow inline beside the pill, wrapping only
|
||||||
|
when the row truly runs out of room (a narrow window; the Marks card). */
|
||||||
.pillWrap {
|
.pillWrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-wrap: wrap;
|
||||||
gap: var(--space-2);
|
align-items: center;
|
||||||
|
gap: var(--space-2) var(--space-3);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -827,6 +825,10 @@
|
|||||||
opacity: 0.85;
|
opacity: 0.85;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pillType[aria-expanded='true'] {
|
||||||
|
background: color-mix(in srgb, #000 30%, var(--accent));
|
||||||
|
}
|
||||||
|
|
||||||
.pillType:focus-visible {
|
.pillType:focus-visible {
|
||||||
outline: 2px solid var(--accent-contrast);
|
outline: 2px solid var(--accent-contrast);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
@@ -841,6 +843,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pillName {
|
.pillName {
|
||||||
|
/* Grow into any pill slack so the remove ✕ stays at the pill's right edge. */
|
||||||
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -937,19 +941,22 @@
|
|||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ghost button (Carbon: links navigate, buttons act — this one acts). */
|
||||||
.slotConst {
|
.slotConst {
|
||||||
padding: var(--space-1);
|
padding: var(--space-1) var(--space-2);
|
||||||
border: none;
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-decoration: underline;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: background var(--dur-fast) var(--ease);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slotConst:hover {
|
.slotConst:hover {
|
||||||
|
background: var(--layer-02);
|
||||||
color: var(--accent-hover);
|
color: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -966,10 +973,12 @@
|
|||||||
padding: var(--space-5) var(--space-5) 0;
|
padding: var(--space-5) var(--space-5) 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Swap sits beside the heading — an action flushed to the far edge of a very wide
|
||||||
|
pane reads as unrelated to the shelves it acts on. */
|
||||||
.shelvesHead {
|
.shelvesHead {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
justify-content: space-between;
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shelfStrip {
|
.shelfStrip {
|
||||||
@@ -1000,11 +1009,53 @@
|
|||||||
padding: var(--space-2) var(--space-3);
|
padding: var(--space-2) var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shelfSlots .slot,
|
/* Empty slots keep a minimum presence; a filled pill hugs its content so the
|
||||||
.shelfSlots .pillWrap {
|
remove ✕ sits at its right edge rather than adrift in dead pill background. */
|
||||||
|
.shelfSlots .slot {
|
||||||
min-width: 190px;
|
min-width: 190px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Chart properties — title/subtitle/size as a slim strip pinned under the preview
|
||||||
|
(chart-level properties live on the chart side; council 2026-06-12). */
|
||||||
|
.chartProps {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-4);
|
||||||
|
padding: var(--space-3) var(--space-5);
|
||||||
|
border-top: var(--border-width) solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.propField {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.propInput {
|
||||||
|
width: 200px;
|
||||||
|
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: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.propInput:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.propInput:focus-visible {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Reserved faceting placeholder (Phase 4) — non-interactive, signals where it'll live. */
|
/* Reserved faceting placeholder (Phase 4) — non-interactive, signals where it'll live. */
|
||||||
.facetSlot {
|
.facetSlot {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ describe('ChartBuilderModal', () => {
|
|||||||
await vi.advanceTimersByTimeAsync(400); // drive the debounced preview render
|
await vi.advanceTimersByTimeAsync(400); // drive the debounced preview render
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(container.querySelector('select[aria-label="Filter operator"]')).toBeTruthy();
|
expect(container.querySelector('button[aria-label^="Filter operator"]')).toBeTruthy();
|
||||||
const calls = vi.mocked(renderSpec).mock.calls;
|
const calls = vi.mocked(renderSpec).mock.calls;
|
||||||
const lastSpec = calls[calls.length - 1][1] as { transform?: unknown };
|
const lastSpec = calls[calls.length - 1][1] as { transform?: unknown };
|
||||||
expect(lastSpec.transform).toEqual([{ filter: { field: 'revenue', gt: 60 } }]);
|
expect(lastSpec.transform).toEqual([{ filter: { field: 'revenue', gt: 60 } }]);
|
||||||
@@ -297,7 +297,7 @@ describe('ChartBuilderModal', () => {
|
|||||||
expect(refLink()!.getAttribute('href')).toContain('vega.github.io');
|
expect(refLink()!.getAttribute('href')).toContain('vega.github.io');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clicking a field in the shelf assigns it to a channel as a pill (field-first, 2B)', async () => {
|
test('clicking a field opens the channel chooser; picking a channel assigns it (field-first, 2B)', async () => {
|
||||||
const ds = createDataset({
|
const ds = createDataset({
|
||||||
name: 'Shop',
|
name: 'Shop',
|
||||||
data: [{ region: 'E', sales: 5 }],
|
data: [{ region: 'E', sales: 5 }],
|
||||||
@@ -309,7 +309,6 @@ describe('ChartBuilderModal', () => {
|
|||||||
const id = useDatasetStore.getState().datasets[0].id;
|
const id = useDatasetStore.getState().datasets[0].id;
|
||||||
const store = useChartBuilderStore.getState();
|
const store = useChartBuilderStore.getState();
|
||||||
store.init(id);
|
store.init(id);
|
||||||
// Clear the smart-default axes so the click lands on the first empty channel (X).
|
|
||||||
store.setChannelColumn('x', null);
|
store.setChannelColumn('x', null);
|
||||||
store.setChannelColumn('y', null);
|
store.setChannelColumn('y', null);
|
||||||
|
|
||||||
@@ -328,12 +327,100 @@ describe('ChartBuilderModal', () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Unarmed, the click opens an explicit channel chooser (portaled to <body>)
|
||||||
|
// rather than silently filling the first empty seat (council 2026-06-12).
|
||||||
|
expect(useChartBuilderStore.getState().config.encodings.x).toBeNull();
|
||||||
|
const option = Array.from(document.body.querySelectorAll('button')).find((b) =>
|
||||||
|
b.textContent?.includes('Columns (X)'),
|
||||||
|
);
|
||||||
|
expect(option).toBeDefined();
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
option!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
expect(useChartBuilderStore.getState().config.encodings.x).toEqual({
|
expect(useChartBuilderStore.getState().config.encodings.x).toEqual({
|
||||||
field: 'region',
|
field: 'region',
|
||||||
type: 'nominal',
|
type: 'nominal',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an armed channel short-circuits the chooser: the field assigns directly (2B)', async () => {
|
||||||
|
const ds = createDataset({
|
||||||
|
name: 'Shop',
|
||||||
|
data: [{ region: 'E', sales: 5 }],
|
||||||
|
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.setChannelColumn('x', null);
|
||||||
|
store.setChannelColumn('y', null);
|
||||||
|
store.focusChannel('y');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<ChartBuilderModal />);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The armed state announces itself at the shelf.
|
||||||
|
expect(container.textContent).toContain('Assigning to Y');
|
||||||
|
|
||||||
|
const fieldButton = Array.from(container.querySelectorAll('button')).find((b) =>
|
||||||
|
b.textContent?.includes('region'),
|
||||||
|
);
|
||||||
|
await act(async () => {
|
||||||
|
fieldButton!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useChartBuilderStore.getState().config.encodings.y).toEqual({
|
||||||
|
field: 'region',
|
||||||
|
type: 'nominal',
|
||||||
|
});
|
||||||
|
expect(useChartBuilderStore.getState().activeChannel).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opening a SelectControl lands focus on the selected option, not the first (regression)', async () => {
|
||||||
|
const ds = createDataset({
|
||||||
|
name: 'Sales',
|
||||||
|
data: [{ day: '2026-01-01', v: 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.setChannelColumn('x', 'day'); // temporal → the pill offers Granularity
|
||||||
|
store.setChannelTimeUnit('x', 'month'); // "Month" sits mid-list, after "None (raw)"
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<ChartBuilderModal />);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const trigger = container.querySelector<HTMLButtonElement>(
|
||||||
|
'button[aria-label^="Granularity for"]',
|
||||||
|
);
|
||||||
|
expect(trigger).toBeTruthy();
|
||||||
|
await act(async () => {
|
||||||
|
trigger!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// A selector list ('[aria-current="true"], button') would return the first
|
||||||
|
// button in document order — the "None (raw)" option — instead of the selection.
|
||||||
|
const focused = document.activeElement as HTMLElement;
|
||||||
|
expect(focused.getAttribute('aria-current')).toBe('true');
|
||||||
|
expect(focused.textContent).toContain('Month');
|
||||||
|
});
|
||||||
|
|
||||||
test('Colour can be switched to a constant value (the Property model, 2A/2B)', async () => {
|
test('Colour can be switched to a constant value (the Property model, 2A/2B)', async () => {
|
||||||
const ds = createDataset({
|
const ds = createDataset({
|
||||||
name: 'Shop',
|
name: 'Shop',
|
||||||
@@ -351,9 +438,10 @@ describe('ChartBuilderModal', () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
// The empty Colour slot offers an "or constant" affordance (Colour is first in Marks).
|
// The empty Colour slot offers a "Use a constant" ghost button (Colour is
|
||||||
|
// first in Marks).
|
||||||
const constButton = Array.from(container.querySelectorAll('button')).find(
|
const constButton = Array.from(container.querySelectorAll('button')).find(
|
||||||
(b) => b.textContent === 'or constant',
|
(b) => b.textContent === 'Use a constant',
|
||||||
);
|
);
|
||||||
expect(constButton).toBeDefined();
|
expect(constButton).toBeDefined();
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Chart Builder — the modal body (spec §06).
|
* Chart Builder — the modal body (spec §06).
|
||||||
*
|
*
|
||||||
* A two-pane composer: left is the configuration (dataset name, mark selector, one
|
* A two-pane composer. Left: the Data section (filters, calculated fields, row
|
||||||
* block per channel, chart-level sort/stacking, optional dimensions, guidance,
|
* preview), the mark selector, the field shelf (columns as type-glyphed chips), the
|
||||||
* Create), right is a live preview of the spec the configuration produces. All spec
|
* Marks card (Colour/Size — field or constant), chart-level sort/stacking, guidance,
|
||||||
* logic and Tier-B defaults/guards come from `@core/chart-builder` via
|
* and Create. Right: the on-chart Columns/Rows shelves, the live preview, and the
|
||||||
* `ChartBuilderStore`; this component is the view. Each channel is a small block:
|
* chart-properties strip (title/subtitle/size). All spec logic and defaults/guards
|
||||||
* a column dropdown (with a field-less "Count of records" option), a fixed
|
* come from `@core/chart-builder` via `ChartBuilderStore`; this component is the view.
|
||||||
* `N | O | Q | T` field-type segmented control (the column's invalid types are
|
*
|
||||||
* disabled), and the transforms that apply to its type (aggregate + bin for a
|
* Assignment is field-first: a chip click opens an explicit channel chooser, or
|
||||||
* measure, granularity for a temporal field). The preview is builder-local (its own
|
* assigns directly when a channel is armed (the slot's "Pick a field…" state, made
|
||||||
|
* visible at the shelf). A bound channel renders as a pill — a field-type chip that
|
||||||
|
* opens a direct type pick, the field name, a remove ✕ — with its per-type
|
||||||
|
* transforms (aggregate / bin / granularity) inline beside it. Pickers use
|
||||||
|
* `SelectControl`, not native selects. The preview is builder-local (its own
|
||||||
* debounced render over the shared `chart-renderer` service) rather than a reuse of
|
* debounced render over the shared `chart-renderer` service) rather than a reuse of
|
||||||
* `LivePreview`, which is bound to the snippet editor's stores.
|
* `LivePreview`, which is bound to the snippet editor's stores.
|
||||||
*/
|
*/
|
||||||
@@ -29,12 +33,14 @@ import {
|
|||||||
filterOpArity,
|
filterOpArity,
|
||||||
isBuilderConfigValid,
|
isBuilderConfigValid,
|
||||||
isChannelTypeAllowed,
|
isChannelTypeAllowed,
|
||||||
|
isColumnAllowedOnChannel,
|
||||||
isValueMapping,
|
isValueMapping,
|
||||||
supportsAggregate,
|
supportsAggregate,
|
||||||
supportsBin,
|
supportsBin,
|
||||||
supportsSort,
|
supportsSort,
|
||||||
supportsStack,
|
supportsStack,
|
||||||
supportsTimeUnit,
|
supportsTimeUnit,
|
||||||
|
validAggregateOps,
|
||||||
validFieldTypes,
|
validFieldTypes,
|
||||||
validFilterOps,
|
validFilterOps,
|
||||||
type BuilderCalculate,
|
type BuilderCalculate,
|
||||||
@@ -65,6 +71,7 @@ import {
|
|||||||
useChartBuilderStore,
|
useChartBuilderStore,
|
||||||
} from '../stores/ChartBuilderStore';
|
} from '../stores/ChartBuilderStore';
|
||||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||||
|
import { SelectControl } from './SelectControl';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
import styles from './ChartBuilderModal.module.css';
|
import styles from './ChartBuilderModal.module.css';
|
||||||
|
|
||||||
@@ -124,8 +131,16 @@ const TYPE_ABBR: Record<FieldType, string> = {
|
|||||||
temporal: 'T',
|
temporal: 'T',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Non-count aggregate operators offered for a quantitative field. */
|
/** Readable labels for the non-count aggregate operators (`validAggregateOps`
|
||||||
const FIELD_AGGREGATES: readonly AggregateOp[] = ['sum', 'mean', 'median', 'min', 'max'];
|
* supplies the per-type menu — e.g. a Nominal field offers only Count distinct). */
|
||||||
|
const AGGREGATE_LABELS: Record<Exclude<AggregateOp, 'count'>, string> = {
|
||||||
|
sum: 'Sum',
|
||||||
|
mean: 'Mean',
|
||||||
|
median: 'Median',
|
||||||
|
min: 'Min',
|
||||||
|
max: 'Max',
|
||||||
|
distinct: 'Count distinct',
|
||||||
|
};
|
||||||
|
|
||||||
/** Friendly labels for each temporal granularity. */
|
/** Friendly labels for each temporal granularity. */
|
||||||
const TIME_UNIT_LABELS: Record<TimeUnit, string> = {
|
const TIME_UNIT_LABELS: Record<TimeUnit, string> = {
|
||||||
@@ -286,14 +301,9 @@ function ChannelPill({
|
|||||||
|
|
||||||
const count = isCount(mapping);
|
const count = isCount(mapping);
|
||||||
const typeOptions = count ? [] : channelTypeOptions(channel, mapping, columns);
|
const typeOptions = count ? [] : channelTypeOptions(channel, mapping, columns);
|
||||||
const canCycle = typeOptions.length > 1;
|
const canPick = typeOptions.length > 1;
|
||||||
const currentType: FieldType = count ? 'quantitative' : mapping.type;
|
const currentType: FieldType = count ? 'quantitative' : mapping.type;
|
||||||
const label = count ? 'Count' : (mapping.field ?? '');
|
const label = count ? 'Count' : (mapping.field ?? '');
|
||||||
const cycleType = () => {
|
|
||||||
if (!canCycle) return;
|
|
||||||
const i = typeOptions.indexOf(mapping.type);
|
|
||||||
setChannelType(channel, typeOptions[(i + 1) % typeOptions.length]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasTransforms =
|
const hasTransforms =
|
||||||
!count &&
|
!count &&
|
||||||
@@ -304,18 +314,20 @@ function ChannelPill({
|
|||||||
return (
|
return (
|
||||||
<div className={styles.pillWrap}>
|
<div className={styles.pillWrap}>
|
||||||
<div className={styles.pill}>
|
<div className={styles.pill}>
|
||||||
{/* TODO(ux-second-pass): the type chip cycles N→O→Q→T — no direct pick for
|
{/* The type chip opens a direct pick of the valid types (council 2026-06-12:
|
||||||
keyboard/SR users. Cycle vs. explicit radio is parked for a batched council
|
a cycling button gave keyboard/SR users no way to jump to a type). */}
|
||||||
review (docs/ux-second-pass.md). */}
|
<SelectControl
|
||||||
<button
|
id={`cb-type-${channel}`}
|
||||||
type="button"
|
label={`Field type for ${CHANNEL_LABELS[channel]}`}
|
||||||
className={styles.pillType}
|
heading="Field type"
|
||||||
aria-label={`Field type: ${titleCase(currentType)}${canCycle ? ' — activate to change' : ''}`}
|
options={typeOptions.map((t) => ({ value: t, label: titleCase(t) }))}
|
||||||
disabled={!canCycle}
|
value={currentType}
|
||||||
onClick={cycleType}
|
onSelect={(t) => setChannelType(channel, t)}
|
||||||
>
|
triggerClassName={styles.pillType}
|
||||||
{TYPE_ABBR[currentType]}
|
triggerContent={TYPE_ABBR[currentType]}
|
||||||
</button>
|
triggerTitle={titleCase(currentType)}
|
||||||
|
disabled={!canPick}
|
||||||
|
/>
|
||||||
<span className={styles.pillName} title={label}>
|
<span className={styles.pillName} title={label}>
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
@@ -327,26 +339,25 @@ function ChannelPill({
|
|||||||
{hasTransforms && (
|
{hasTransforms && (
|
||||||
<div className={styles.pillControls}>
|
<div className={styles.pillControls}>
|
||||||
{supportsAggregate(mapping.type) && (
|
{supportsAggregate(mapping.type) && (
|
||||||
<label className={styles.transform}>
|
<div className={styles.transform}>
|
||||||
<span className={styles.miniLabel}>Aggregate</span>
|
<span className={styles.miniLabel} aria-hidden="true">
|
||||||
<select
|
Aggregate
|
||||||
className={styles.mini}
|
</span>
|
||||||
|
<SelectControl
|
||||||
|
id={`cb-agg-${channel}`}
|
||||||
|
label={`Aggregate for ${CHANNEL_LABELS[channel]}`}
|
||||||
|
heading="Aggregate"
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'None' },
|
||||||
|
...validAggregateOps(mapping.type).map((op) => ({
|
||||||
|
value: op,
|
||||||
|
label: AGGREGATE_LABELS[op],
|
||||||
|
})),
|
||||||
|
]}
|
||||||
value={mapping.aggregate && mapping.aggregate !== 'count' ? mapping.aggregate : ''}
|
value={mapping.aggregate && mapping.aggregate !== 'count' ? mapping.aggregate : ''}
|
||||||
onChange={(e) =>
|
onSelect={(v) => setChannelAggregate(channel, v || undefined)}
|
||||||
setChannelAggregate(
|
/>
|
||||||
channel,
|
</div>
|
||||||
(e.target.value || undefined) as AggregateOp | undefined,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">None</option>
|
|
||||||
{FIELD_AGGREGATES.map((op) => (
|
|
||||||
<option key={op} value={op}>
|
|
||||||
{titleCase(op)}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{supportsBin(mapping.type) && (
|
{supportsBin(mapping.type) && (
|
||||||
@@ -361,23 +372,22 @@ function ChannelPill({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{supportsTimeUnit(mapping.type) && (
|
{supportsTimeUnit(mapping.type) && (
|
||||||
<label className={styles.transform}>
|
<div className={styles.transform}>
|
||||||
<span className={styles.miniLabel}>Granularity</span>
|
<span className={styles.miniLabel} aria-hidden="true">
|
||||||
<select
|
Granularity
|
||||||
className={styles.mini}
|
</span>
|
||||||
|
<SelectControl
|
||||||
|
id={`cb-tu-${channel}`}
|
||||||
|
label={`Granularity for ${CHANNEL_LABELS[channel]}`}
|
||||||
|
heading="Granularity"
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'None (raw)' },
|
||||||
|
...TIME_UNITS.map((u) => ({ value: u, label: TIME_UNIT_LABELS[u] })),
|
||||||
|
]}
|
||||||
value={mapping.timeUnit ?? ''}
|
value={mapping.timeUnit ?? ''}
|
||||||
onChange={(e) =>
|
onSelect={(v) => setChannelTimeUnit(channel, v || undefined)}
|
||||||
setChannelTimeUnit(channel, (e.target.value || undefined) as TimeUnit | undefined)
|
/>
|
||||||
}
|
</div>
|
||||||
>
|
|
||||||
<option value="">None (raw)</option>
|
|
||||||
{TIME_UNITS.map((u) => (
|
|
||||||
<option key={u} value={u}>
|
|
||||||
{TIME_UNIT_LABELS[u]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -421,36 +431,75 @@ function ChannelSlot({ channel, hint }: { channel: ChannelName; hint?: string })
|
|||||||
{active ? 'Pick a field…' : (hint ?? 'Add a field')}
|
{active ? 'Pick a field…' : (hint ?? 'Add a field')}
|
||||||
</button>
|
</button>
|
||||||
{channelAcceptsValue(channel) && (
|
{channelAcceptsValue(channel) && (
|
||||||
|
// A ghost button, not a link-styled affordance: it acts (binds a constant),
|
||||||
|
// and Carbon draws the line at links navigate / buttons act (council
|
||||||
|
// 2026-06-12). Verb-first label per the content rules.
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.slotConst}
|
className={styles.slotConst}
|
||||||
onClick={() => setChannelConstant(channel, String(defaultChannelValue(channel)))}
|
onClick={() => setChannelConstant(channel, String(defaultChannelValue(channel)))}
|
||||||
>
|
>
|
||||||
or constant
|
Use a constant
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a channel currently shows, for the assignment chooser's "replaces …" hint. */
|
||||||
|
function occupantLabel(mapping: ChannelMapping | null | undefined): string | undefined {
|
||||||
|
if (!mapping) return undefined;
|
||||||
|
if (mapping.value !== undefined) return 'replaces the constant';
|
||||||
|
if (mapping.aggregate === 'count' && !mapping.field) return 'replaces Count';
|
||||||
|
return mapping.field ? `replaces ${mapping.field}` : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Channel names as the assignment chooser shows them (the on-chart shelf words). */
|
||||||
|
const ASSIGN_LABELS: Record<ChannelName, string> = {
|
||||||
|
x: 'Columns (X)',
|
||||||
|
y: 'Rows (Y)',
|
||||||
|
color: 'Color',
|
||||||
|
size: 'Size',
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The field shelf (spec §06 → Encoding, field-first): the dataset's columns (plus any
|
* The field shelf (spec §06 → Encoding, field-first): the dataset's columns (plus any
|
||||||
* calculated fields and a field-less "Count of records") as clickable chips with a type
|
* calculated fields and a field-less "Count of records") as clickable chips with a type
|
||||||
* glyph. Clicking a field assigns it to the armed channel, else the first empty channel
|
* glyph. Clicking a chip opens an explicit channel chooser (the channels that accept the
|
||||||
* that accepts it (`assignField`). Past `FIELD_SHELF_SPLIT_MIN` columns it groups into
|
* column; an occupied one says what it would replace); with a channel armed, the click
|
||||||
* Dimensions (categories/dates) and Measures (numerics); a small dataset stays flat.
|
* assigns straight there instead and Esc disarms. Past `FIELD_SHELF_SPLIT_MIN` columns it
|
||||||
* Already-mapped fields are dimmed (a field may still be placed on several channels).
|
* groups into Dimensions (categories/dates) and Measures (numerics); a small dataset stays
|
||||||
|
* flat. Already-mapped fields are dimmed (a field may still be placed on several channels).
|
||||||
*/
|
*/
|
||||||
function FieldShelf() {
|
function FieldShelf() {
|
||||||
const baseColumns = useChartBuilderStore((s) => s.columns);
|
const baseColumns = useChartBuilderStore((s) => s.columns);
|
||||||
const calculates = useChartBuilderStore((s) => s.config.calculates);
|
const calculates = useChartBuilderStore((s) => s.config.calculates);
|
||||||
const encodings = useChartBuilderStore((s) => s.config.encodings);
|
const encodings = useChartBuilderStore((s) => s.config.encodings);
|
||||||
const assignField = useChartBuilderStore((s) => s.assignField);
|
const assignField = useChartBuilderStore((s) => s.assignField);
|
||||||
|
const activeChannel = useChartBuilderStore((s) => s.activeChannel);
|
||||||
|
const focusChannel = useChartBuilderStore((s) => s.focusChannel);
|
||||||
const columns = useMemo(
|
const columns = useMemo(
|
||||||
() => effectiveColumns(baseColumns, calculates),
|
() => effectiveColumns(baseColumns, calculates),
|
||||||
[baseColumns, calculates],
|
[baseColumns, calculates],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Esc disarms the armed channel (captured so the modal itself doesn't close).
|
||||||
|
// TODO: with a channel armed AND a SelectControl popover open, one Esc both closes
|
||||||
|
// the popover and disarms — both are capture-phase document listeners, so
|
||||||
|
// stopPropagation can't serialize them. Rare combination; needs a shared
|
||||||
|
// escape-layer stack if it ever matters.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeChannel) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
focusChannel(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey, true);
|
||||||
|
return () => document.removeEventListener('keydown', onKey, true);
|
||||||
|
}, [activeChannel, focusChannel]);
|
||||||
|
|
||||||
const assigned = useMemo(() => {
|
const assigned = useMemo(() => {
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
for (const ch of CHANNELS) {
|
for (const ch of CHANNELS) {
|
||||||
@@ -463,33 +512,59 @@ function FieldShelf() {
|
|||||||
const colTypeOf = (name: string): ColumnType =>
|
const colTypeOf = (name: string): ColumnType =>
|
||||||
columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
|
columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
|
||||||
|
|
||||||
const fieldButton = (name: string) => (
|
// A field chip: with a channel armed, clicking assigns straight there (the fast
|
||||||
<button
|
// path); unarmed, it opens an explicit channel chooser instead of silently
|
||||||
key={name}
|
// grabbing the first empty seat (council 2026-06-12 — NN/g #3, user choice).
|
||||||
type="button"
|
const fieldChip = (
|
||||||
className={`${styles.shelfField} ${assigned.has(name) ? styles.shelfFieldUsed : ''}`}
|
name: string,
|
||||||
onClick={() => assignField(name)}
|
display: string,
|
||||||
>
|
domId: string,
|
||||||
<span className={styles.shelfGlyph} aria-hidden="true">
|
colType: ColumnType,
|
||||||
{TYPE_ABBR[defaultFieldType(colTypeOf(name))]}
|
glyph: string,
|
||||||
</span>
|
) => {
|
||||||
<span className={styles.shelfFieldName}>{name}</span>
|
const choices = CHANNELS.filter((ch) => isColumnAllowedOnChannel(ch, colType));
|
||||||
</button>
|
return (
|
||||||
);
|
<SelectControl
|
||||||
|
key={domId}
|
||||||
|
id={`cb-assign-${domId}`}
|
||||||
|
label={`Add ${display} to a channel`}
|
||||||
|
heading="Add to"
|
||||||
|
options={choices.map((ch) => ({
|
||||||
|
value: ch,
|
||||||
|
label: ASSIGN_LABELS[ch],
|
||||||
|
detail: occupantLabel(encodings[ch]),
|
||||||
|
}))}
|
||||||
|
onSelect={(ch) => assignField(name, ch)}
|
||||||
|
beforeOpen={() => {
|
||||||
|
if (activeChannel) {
|
||||||
|
assignField(name);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
triggerClassName={`${styles.shelfField} ${assigned.has(name) ? styles.shelfFieldUsed : ''}`}
|
||||||
|
triggerContent={
|
||||||
|
<>
|
||||||
|
<span className={styles.shelfGlyph} aria-hidden="true">
|
||||||
|
{glyph}
|
||||||
|
</span>
|
||||||
|
<span className={styles.shelfFieldName}>{display}</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const countButton = (
|
const fieldButton = (name: string) =>
|
||||||
<button
|
fieldChip(
|
||||||
key="__count"
|
name,
|
||||||
type="button"
|
name,
|
||||||
className={styles.shelfField}
|
`f${columns.columns.indexOf(name)}`,
|
||||||
onClick={() => assignField(COUNT_FIELD)}
|
colTypeOf(name),
|
||||||
>
|
TYPE_ABBR[defaultFieldType(colTypeOf(name))],
|
||||||
<span className={styles.shelfGlyph} aria-hidden="true">
|
);
|
||||||
∑
|
|
||||||
</span>
|
const countButton = fieldChip(COUNT_FIELD, 'Count of records', 'count', 'number', '∑');
|
||||||
<span className={styles.shelfFieldName}>Count of records</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
const dimensions = columns.columns.filter((n) => colTypeOf(n) !== 'number');
|
const dimensions = columns.columns.filter((n) => colTypeOf(n) !== 'number');
|
||||||
const measures = columns.columns.filter((n) => colTypeOf(n) === 'number');
|
const measures = columns.columns.filter((n) => colTypeOf(n) === 'number');
|
||||||
@@ -499,22 +574,33 @@ function FieldShelf() {
|
|||||||
return (
|
return (
|
||||||
<div className={styles.fieldShelf}>
|
<div className={styles.fieldShelf}>
|
||||||
<span className={styles.fieldLabel}>Fields</span>
|
<span className={styles.fieldLabel}>Fields</span>
|
||||||
{split ? (
|
{/* Arming a channel must be visible at the place the next click happens
|
||||||
<>
|
(NN/g #1): the shelf gains an accent ring and a status line naming the
|
||||||
<span className={styles.shelfGroupHead}>Dimensions</span>
|
target. The hint is a polite status so AT hears the mode change too. */}
|
||||||
<div className={styles.shelfList}>{dimensions.map(fieldButton)}</div>
|
{activeChannel && (
|
||||||
<span className={styles.shelfGroupHead}>Measures</span>
|
<p className={styles.armedHint} role="status">
|
||||||
|
Assigning to <strong>{CHANNEL_LABELS[activeChannel]}</strong> — choose a field below. Esc
|
||||||
|
cancels.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className={`${styles.shelfScroll} ${activeChannel ? styles.shelfArmed : ''}`}>
|
||||||
|
{split ? (
|
||||||
|
<>
|
||||||
|
<span className={styles.shelfGroupHead}>Dimensions</span>
|
||||||
|
<div className={styles.shelfList}>{dimensions.map(fieldButton)}</div>
|
||||||
|
<span className={styles.shelfGroupHead}>Measures</span>
|
||||||
|
<div className={styles.shelfList}>
|
||||||
|
{measures.map(fieldButton)}
|
||||||
|
{countButton}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<div className={styles.shelfList}>
|
<div className={styles.shelfList}>
|
||||||
{measures.map(fieldButton)}
|
{columns.columns.map(fieldButton)}
|
||||||
{countButton}
|
{countButton}
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<div className={styles.shelfList}>
|
|
||||||
{columns.columns.map(fieldButton)}
|
|
||||||
{countButton}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -687,19 +773,15 @@ function FilterRow({ filter, columns }: { filter: BuilderFilter; columns: Builde
|
|||||||
onChange={(e) => updateFilter(filter.id, { expr: e.target.value })}
|
onChange={(e) => updateFilter(filter.id, { expr: e.target.value })}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<select
|
<SelectControl
|
||||||
className={styles.filterField}
|
id={`cb-ffield-${filter.id}`}
|
||||||
aria-label="Filter field"
|
label="Filter field"
|
||||||
value={filter.field ?? ''}
|
heading="Filter field"
|
||||||
onChange={(e) => setFilterField(filter.id, e.target.value)}
|
options={columns.columns.map((name) => ({ value: name, label: name }))}
|
||||||
>
|
value={filter.field}
|
||||||
{!filter.field && <option value="">Choose a field…</option>}
|
onSelect={(name) => setFilterField(filter.id, name)}
|
||||||
{columns.columns.map((name) => (
|
triggerContent={filter.field ? undefined : 'Choose a field…'}
|
||||||
<option key={name} value={name}>
|
/>
|
||||||
{name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -713,18 +795,17 @@ function FilterRow({ filter, columns }: { filter: BuilderFilter; columns: Builde
|
|||||||
|
|
||||||
{!expressionMode && (
|
{!expressionMode && (
|
||||||
<div className={styles.filterPredicate}>
|
<div className={styles.filterPredicate}>
|
||||||
<select
|
<SelectControl
|
||||||
className={styles.mini}
|
id={`cb-fop-${filter.id}`}
|
||||||
aria-label="Filter operator"
|
label="Filter operator"
|
||||||
|
heading="Operator"
|
||||||
|
options={validFilterOps(fieldType).map((o) => ({
|
||||||
|
value: o,
|
||||||
|
label: FILTER_OP_LABELS[o],
|
||||||
|
}))}
|
||||||
value={op}
|
value={op}
|
||||||
onChange={(e) => updateFilter(filter.id, { op: e.target.value as FilterOp })}
|
onSelect={(o) => updateFilter(filter.id, { op: o })}
|
||||||
>
|
/>
|
||||||
{validFilterOps(fieldType).map((o) => (
|
|
||||||
<option key={o} value={o}>
|
|
||||||
{FILTER_OP_LABELS[o]}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
{arity === 'range' ? (
|
{arity === 'range' ? (
|
||||||
<>
|
<>
|
||||||
<input
|
<input
|
||||||
@@ -985,6 +1066,11 @@ function BuilderPreview() {
|
|||||||
|
|
||||||
const specText = useChartBuilderStore(selectBuilderSpecText);
|
const specText = useChartBuilderStore(selectBuilderSpecText);
|
||||||
const valid = useChartBuilderStore(selectBuilderValid);
|
const valid = useChartBuilderStore(selectBuilderValid);
|
||||||
|
// An explicit Chart size must show up in the preview — the 'width' fit mode
|
||||||
|
// overwrites width AND drops height, so it only applies while sizing is auto.
|
||||||
|
const explicitSize = useChartBuilderStore(
|
||||||
|
(s) => s.config.width !== undefined || s.config.height !== undefined,
|
||||||
|
);
|
||||||
const uiTheme = useAppStore((s) => s.uiTheme);
|
const uiTheme = useAppStore((s) => s.uiTheme);
|
||||||
const datasets = useDatasetStore(useShallow((s) => s.datasets));
|
const datasets = useDatasetStore(useShallow((s) => s.datasets));
|
||||||
|
|
||||||
@@ -1005,7 +1091,10 @@ function BuilderPreview() {
|
|||||||
const t0 = performance.now();
|
const t0 = performance.now();
|
||||||
const parsed: unknown = JSON.parse(specText);
|
const parsed: unknown = JSON.parse(specText);
|
||||||
const t1 = performance.now();
|
const t1 = performance.now();
|
||||||
const prepared = prepareSpecForRender(parsed, { fitMode: 'width', datasets });
|
const prepared = prepareSpecForRender(parsed, {
|
||||||
|
fitMode: explicitSize ? 'default' : 'width',
|
||||||
|
datasets,
|
||||||
|
});
|
||||||
const t2 = performance.now();
|
const t2 = performance.now();
|
||||||
handleRef.current?.destroy(); // finalizing a huge prior SVG is itself a cost
|
handleRef.current?.destroy(); // finalizing a huge prior SVG is itself a cost
|
||||||
handleRef.current = null;
|
handleRef.current = null;
|
||||||
@@ -1069,7 +1158,7 @@ function BuilderPreview() {
|
|||||||
}, RENDER_DEBOUNCE_MS);
|
}, RENDER_DEBOUNCE_MS);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [specText, valid, uiTheme, datasets]);
|
}, [specText, valid, explicitSize, uiTheme, datasets]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
@@ -1110,19 +1199,87 @@ function BuilderPreview() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A positive integer from a dimension input; blank/garbage reads as "auto". */
|
||||||
|
function parseDim(raw: string): number | undefined {
|
||||||
|
if (raw.trim() === '') return undefined;
|
||||||
|
const n = Number(raw);
|
||||||
|
return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chart properties — title/subtitle and explicit width/height, as a slim strip
|
||||||
|
* pinned under the preview: these describe *the chart*, so they live on the chart
|
||||||
|
* side, not in the encoding pane (council 2026-06-12 — NN/g #4, the Tableau/Lyra
|
||||||
|
* convention). The subtitle is disabled until a title exists because Vega-Lite has
|
||||||
|
* no standalone subtitle (it nests under `title`).
|
||||||
|
*/
|
||||||
|
function ChartProps() {
|
||||||
|
const title = useChartBuilderStore((s) => s.config.title ?? '');
|
||||||
|
const subtitle = useChartBuilderStore((s) => s.config.subtitle ?? '');
|
||||||
|
const width = useChartBuilderStore((s) => s.config.width);
|
||||||
|
const height = useChartBuilderStore((s) => s.config.height);
|
||||||
|
const setTitle = useChartBuilderStore((s) => s.setTitle);
|
||||||
|
const setSubtitle = useChartBuilderStore((s) => s.setSubtitle);
|
||||||
|
const setWidth = useChartBuilderStore((s) => s.setWidth);
|
||||||
|
const setHeight = useChartBuilderStore((s) => s.setHeight);
|
||||||
|
const hasTitle = title.trim() !== '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.chartProps}>
|
||||||
|
<label className={styles.propField}>
|
||||||
|
<span>Title</span>
|
||||||
|
<input
|
||||||
|
className={styles.propInput}
|
||||||
|
value={title}
|
||||||
|
placeholder="None"
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={styles.propField}>
|
||||||
|
<span>Subtitle</span>
|
||||||
|
<input
|
||||||
|
className={styles.propInput}
|
||||||
|
value={subtitle}
|
||||||
|
placeholder={hasTitle ? 'None' : 'Add a title first'}
|
||||||
|
disabled={!hasTitle}
|
||||||
|
onChange={(e) => setSubtitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={styles.propField}>
|
||||||
|
<span>Width</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className={styles.dimInput}
|
||||||
|
value={width ?? ''}
|
||||||
|
placeholder="auto"
|
||||||
|
onChange={(e) => setWidth(parseDim(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={styles.propField}>
|
||||||
|
<span>Height</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
className={styles.dimInput}
|
||||||
|
value={height ?? ''}
|
||||||
|
placeholder="auto"
|
||||||
|
onChange={(e) => setHeight(parseDim(e.target.value))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ChartBuilderModal() {
|
export function ChartBuilderModal() {
|
||||||
const datasetId = useChartBuilderStore((s) => s.datasetId);
|
const datasetId = useChartBuilderStore((s) => s.datasetId);
|
||||||
const datasetName = useChartBuilderStore((s) => s.config.datasetName);
|
const datasetName = useChartBuilderStore((s) => s.config.datasetName);
|
||||||
const mark = useChartBuilderStore((s) => s.config.mark);
|
const mark = useChartBuilderStore((s) => s.config.mark);
|
||||||
const width = useChartBuilderStore((s) => s.config.width);
|
|
||||||
const height = useChartBuilderStore((s) => s.config.height);
|
|
||||||
const sort = useChartBuilderStore((s) => s.config.sort);
|
const sort = useChartBuilderStore((s) => s.config.sort);
|
||||||
const stack = useChartBuilderStore((s) => s.config.stack);
|
const stack = useChartBuilderStore((s) => s.config.stack);
|
||||||
const setMark = useChartBuilderStore((s) => s.setMark);
|
const setMark = useChartBuilderStore((s) => s.setMark);
|
||||||
const setSort = useChartBuilderStore((s) => s.setSort);
|
const setSort = useChartBuilderStore((s) => s.setSort);
|
||||||
const setStack = useChartBuilderStore((s) => s.setStack);
|
const setStack = useChartBuilderStore((s) => s.setStack);
|
||||||
const setWidth = useChartBuilderStore((s) => s.setWidth);
|
|
||||||
const setHeight = useChartBuilderStore((s) => s.setHeight);
|
|
||||||
const applyWarningFix = useChartBuilderStore((s) => s.applyWarningFix);
|
const applyWarningFix = useChartBuilderStore((s) => s.applyWarningFix);
|
||||||
const runCreate = useChartBuilderStore((s) => s.createSnippet);
|
const runCreate = useChartBuilderStore((s) => s.createSnippet);
|
||||||
|
|
||||||
@@ -1170,12 +1327,6 @@ export function ChartBuilderModal() {
|
|||||||
return <p className={styles.muted}>No dataset loaded. Open this from a dataset in Datasets.</p>;
|
return <p className={styles.muted}>No dataset loaded. Open this from a dataset in Datasets.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parseDim = (raw: string): number | undefined => {
|
|
||||||
if (raw.trim() === '') return undefined;
|
|
||||||
const n = Number(raw);
|
|
||||||
return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.builder}>
|
<div className={styles.builder}>
|
||||||
<div className={styles.configPane} ref={configPaneRef} tabIndex={-1}>
|
<div className={styles.configPane} ref={configPaneRef} tabIndex={-1}>
|
||||||
@@ -1228,36 +1379,6 @@ export function ChartBuilderModal() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className={styles.dimensions}>
|
|
||||||
{/* "Chart size", not just "Size" — the Marks card now has a Size *encoding*
|
|
||||||
channel; this is the rendered chart's width/height. */}
|
|
||||||
<span className={styles.fieldLabel}>Chart size (optional)</span>
|
|
||||||
<div className={styles.dimInputs}>
|
|
||||||
<label className={styles.dimField}>
|
|
||||||
<span>Width</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
className={styles.dimInput}
|
|
||||||
value={width ?? ''}
|
|
||||||
placeholder="auto"
|
|
||||||
onChange={(e) => setWidth(parseDim(e.target.value))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className={styles.dimField}>
|
|
||||||
<span>Height</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
className={styles.dimInput}
|
|
||||||
value={height ?? ''}
|
|
||||||
placeholder="auto"
|
|
||||||
onChange={(e) => setHeight(parseDim(e.target.value))}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{warnings.length > 0 && (
|
{warnings.length > 0 && (
|
||||||
<ul
|
<ul
|
||||||
className={styles.warnings}
|
className={styles.warnings}
|
||||||
@@ -1314,6 +1435,7 @@ export function ChartBuilderModal() {
|
|||||||
<div className={styles.previewSide}>
|
<div className={styles.previewSide}>
|
||||||
<OnChartShelves />
|
<OnChartShelves />
|
||||||
<BuilderPreview />
|
<BuilderPreview />
|
||||||
|
<ChartProps />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
/* SelectControl — the app's value-picker disclosure (replaces native <select>;
|
||||||
|
arch 10 §5). Trigger + panel mirror SortControl's geometry and tokens. */
|
||||||
|
|
||||||
|
.trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-1);
|
||||||
|
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: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background var(--dur-fast) var(--ease),
|
||||||
|
color var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger:hover:not(:disabled) {
|
||||||
|
background: var(--layer-01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger[aria-expanded='true'] {
|
||||||
|
background: var(--layer-02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trigger:focus-visible {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.triggerLabel {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.caret {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The disclosed panel — portaled to <body>, fixed, above modal content. */
|
||||||
|
.pop {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2000;
|
||||||
|
min-width: 150px;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: var(--space-2);
|
||||||
|
background: var(--layer-01);
|
||||||
|
border: var(--border-width) solid var(--border-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 8px 24px rgb(0 0 0 / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.heading {
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
padding: 0 var(--space-2);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A long option list (a 25-column filter-field picker) scrolls in place. */
|
||||||
|
.list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: min(320px, 50vh);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
width: 100%;
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option:hover {
|
||||||
|
background: var(--layer-02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option:focus-visible {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.optionLabel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* SelectControl — the app's value picker: a disclosure trigger showing the current
|
||||||
|
* choice plus a portaled, non-modal popover listing the options as plain buttons.
|
||||||
|
*
|
||||||
|
* This replaces native `<select>` elements wherever a control is part of a designed
|
||||||
|
* surface: a native select's popup can't be token-styled and renders differently on
|
||||||
|
* every browser/OS, which reads as a foreign object inside an otherwise consistent
|
||||||
|
* UI (arch 10 §5 records the resolution). It is the same APG **disclosure**
|
||||||
|
* primitive as SortControl/SettingsPopover — deliberately NOT an ARIA menu and not
|
||||||
|
* a combobox; a short list of buttons needs neither's contract.
|
||||||
|
*
|
||||||
|
* Behaviour (mirrors SortControl): at most one popover is open app-wide
|
||||||
|
* (`useSettingsPopoverStore`); Esc closes and refocuses the trigger; an outside
|
||||||
|
* pointer press closes; opening focuses the selected option (or the first);
|
||||||
|
* Arrow/Home/End move focus through the options; choosing one fires `onSelect`,
|
||||||
|
* closes, and refocuses the trigger. The panel is portaled to <body> and positioned
|
||||||
|
* fixed so it escapes pane/modal overflow clipping, flipping above the trigger when
|
||||||
|
* the viewport below is too short.
|
||||||
|
*
|
||||||
|
* The same control doubles as an **action picker** (e.g. "Add field to which
|
||||||
|
* channel?"): pass no `value` and a custom `triggerContent`; `beforeOpen` lets the
|
||||||
|
* caller intercept the click entirely (the armed-channel fast path).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, type ReactNode } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useSettingsPopoverStore } from '../stores/SettingsPopoverStore';
|
||||||
|
import styles from './SelectControl.module.css';
|
||||||
|
|
||||||
|
/** Gap (px) between the trigger and the disclosed panel (matches SortControl). */
|
||||||
|
const GAP = 6;
|
||||||
|
|
||||||
|
export interface SelectControlOption<V extends string> {
|
||||||
|
value: V;
|
||||||
|
label: string;
|
||||||
|
/** Optional secondary line (e.g. "replaces Ship Mode" on an occupied channel). */
|
||||||
|
detail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectControlProps<V extends string> {
|
||||||
|
/** Unique id — popover registry key and the panel's DOM id. */
|
||||||
|
id: string;
|
||||||
|
/** Accessible name for the control ("Aggregate for Y", "Add Region to…"). */
|
||||||
|
label: string;
|
||||||
|
options: ReadonlyArray<SelectControlOption<V>>;
|
||||||
|
/** Current value; omit for an action picker (no option reads as selected). */
|
||||||
|
value?: V;
|
||||||
|
onSelect: (value: V) => void;
|
||||||
|
/** Trigger body; defaults to the current option's label plus a caret. */
|
||||||
|
triggerContent?: ReactNode;
|
||||||
|
/** Replaces (not extends) the default trigger styling — for chip-styled triggers. */
|
||||||
|
triggerClassName?: string;
|
||||||
|
triggerTitle?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Popover heading; defaults to `label`. */
|
||||||
|
heading?: string;
|
||||||
|
/** Return false to swallow the trigger click without opening (fast paths). */
|
||||||
|
beforeOpen?: () => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SelectControl<V extends string>({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onSelect,
|
||||||
|
triggerContent,
|
||||||
|
triggerClassName,
|
||||||
|
triggerTitle,
|
||||||
|
disabled,
|
||||||
|
heading,
|
||||||
|
beforeOpen,
|
||||||
|
}: SelectControlProps<V>) {
|
||||||
|
const open = useSettingsPopoverStore((s) => s.openId === id);
|
||||||
|
const toggle = useSettingsPopoverStore((s) => s.toggle);
|
||||||
|
const close = useSettingsPopoverStore((s) => s.close);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const popRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
const current = value !== undefined ? options.find((o) => o.value === value) : undefined;
|
||||||
|
|
||||||
|
// Fixed-position from the trigger's rect (no React state → no re-render on
|
||||||
|
// scroll). Below the trigger by default; above when the viewport below is short.
|
||||||
|
const place = useCallback(() => {
|
||||||
|
const trigger = triggerRef.current;
|
||||||
|
const pop = popRef.current;
|
||||||
|
if (!trigger || !pop) return;
|
||||||
|
const r = trigger.getBoundingClientRect();
|
||||||
|
const below = window.innerHeight - r.bottom - GAP;
|
||||||
|
const height = pop.offsetHeight;
|
||||||
|
pop.style.top =
|
||||||
|
below < height && r.top > height + GAP ? `${r.top - GAP - height}px` : `${r.bottom + GAP}px`;
|
||||||
|
// Keep the panel on-screen when the trigger sits near the right edge.
|
||||||
|
const left = Math.min(r.left, window.innerWidth - pop.offsetWidth - GAP);
|
||||||
|
pop.style.left = `${Math.max(GAP, left)}px`;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
window.addEventListener('resize', place);
|
||||||
|
window.addEventListener('scroll', place, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', place);
|
||||||
|
window.removeEventListener('scroll', place, true);
|
||||||
|
};
|
||||||
|
}, [open, place]);
|
||||||
|
|
||||||
|
// Esc closes + restores focus to the trigger; an outside pointer press closes
|
||||||
|
// (APG disclosure; non-modal). Esc is captured so it settles here, not on a
|
||||||
|
// parent (the builder modal also listens for Esc).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.stopPropagation();
|
||||||
|
close();
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onPointer = (e: PointerEvent) => {
|
||||||
|
const t = e.target as Node;
|
||||||
|
if (!popRef.current?.contains(t) && !triggerRef.current?.contains(t)) close();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey, true);
|
||||||
|
document.addEventListener('pointerdown', onPointer, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey, true);
|
||||||
|
document.removeEventListener('pointerdown', onPointer, true);
|
||||||
|
};
|
||||||
|
}, [open, close]);
|
||||||
|
|
||||||
|
// Arrow/Home/End roving among the option buttons — a convenience on top of the
|
||||||
|
// natural Tab order, matching what a native select's popup offers.
|
||||||
|
const onPopKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
const pop = popRef.current;
|
||||||
|
if (!pop) return;
|
||||||
|
// Tab closes the popup and resumes tabbing from the trigger (the native-select
|
||||||
|
// convention) — also keeps focus inside a host modal's trap, since the panel is
|
||||||
|
// portaled outside it.
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
close();
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = Array.from(pop.querySelectorAll<HTMLButtonElement>('button'));
|
||||||
|
const i = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||||
|
let next = -1;
|
||||||
|
if (e.key === 'ArrowDown') next = i < 0 ? 0 : Math.min(i + 1, items.length - 1);
|
||||||
|
else if (e.key === 'ArrowUp') next = i < 0 ? items.length - 1 : Math.max(i - 1, 0);
|
||||||
|
else if (e.key === 'Home') next = 0;
|
||||||
|
else if (e.key === 'End') next = items.length - 1;
|
||||||
|
if (next >= 0) {
|
||||||
|
e.preventDefault();
|
||||||
|
items[next]?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// On mount: position before paint, then land focus on the selected option (or
|
||||||
|
// the first) so keyboard users arrive inside the popover.
|
||||||
|
const setPopNode = useCallback(
|
||||||
|
(node: HTMLDivElement | null) => {
|
||||||
|
popRef.current = node;
|
||||||
|
if (node) {
|
||||||
|
place();
|
||||||
|
// Two queries, not one selector list — `querySelector('a, b')` returns the
|
||||||
|
// first match in document order, which would always be the first button.
|
||||||
|
const target =
|
||||||
|
node.querySelector<HTMLElement>('[aria-current="true"]') ??
|
||||||
|
node.querySelector<HTMLElement>('button');
|
||||||
|
target?.focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[place],
|
||||||
|
);
|
||||||
|
|
||||||
|
const choose = (v: V) => {
|
||||||
|
onSelect(v);
|
||||||
|
close();
|
||||||
|
triggerRef.current?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
className={triggerClassName ?? styles.trigger}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={open ? id : undefined}
|
||||||
|
aria-label={current ? `${label}: ${current.label}` : label}
|
||||||
|
title={triggerTitle}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
if (beforeOpen && !beforeOpen()) return;
|
||||||
|
toggle(id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{triggerContent ?? (
|
||||||
|
<>
|
||||||
|
<span className={styles.triggerLabel}>{current?.label ?? '—'}</span>
|
||||||
|
<span className={styles.caret} aria-hidden="true">
|
||||||
|
▾
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{open &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={setPopNode}
|
||||||
|
id={id}
|
||||||
|
className={styles.pop}
|
||||||
|
role="group"
|
||||||
|
aria-label={label}
|
||||||
|
onKeyDown={onPopKeyDown}
|
||||||
|
>
|
||||||
|
<h4 className={styles.heading}>{heading ?? label}</h4>
|
||||||
|
<div className={styles.list}>
|
||||||
|
{options.map((o) => {
|
||||||
|
const selected = value !== undefined && o.value === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.option} ${selected ? styles.selected : ''}`}
|
||||||
|
aria-current={selected || undefined}
|
||||||
|
onClick={() => choose(o.value)}
|
||||||
|
>
|
||||||
|
<span className={styles.optionLabel}>
|
||||||
|
{o.label}
|
||||||
|
{o.detail !== undefined && <span className={styles.detail}>{o.detail}</span>}
|
||||||
|
</span>
|
||||||
|
{selected && <span aria-hidden="true">✓</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -104,6 +104,38 @@ describe('transforms — aggregate / bin / timeUnit / count', () => {
|
|||||||
expect(cb().config.encodings.x).toEqual({ field: 'price', type: 'nominal' });
|
expect(cb().config.encodings.x).toEqual({ field: 'price', type: 'nominal' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('assignField with an explicit channel places the field there, replacing any occupant', () => {
|
||||||
|
const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
|
||||||
|
cb().init(id);
|
||||||
|
cb().setChannelColumn('x', 'revenue');
|
||||||
|
cb().assignField('region', 'x'); // explicit target from the chip's channel chooser
|
||||||
|
expect(cb().config.encodings.x).toEqual({ field: 'region', type: 'nominal' });
|
||||||
|
expect(cb().activeChannel).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setTitle / setSubtitle set and clear the chart title fields', () => {
|
||||||
|
const id = seedDataset('S', [{ v: 1 }]);
|
||||||
|
cb().init(id);
|
||||||
|
cb().setTitle('My chart');
|
||||||
|
cb().setSubtitle('A closer look');
|
||||||
|
expect(cb().config.title).toBe('My chart');
|
||||||
|
expect(cb().config.subtitle).toBe('A closer look');
|
||||||
|
cb().setTitle('');
|
||||||
|
expect(cb().config.title).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retyping drops a now-invalid aggregate but keeps a distinct (valid for any type)', () => {
|
||||||
|
const id = seedDataset('S', [{ price: 5 }]);
|
||||||
|
cb().init(id);
|
||||||
|
cb().setChannelColumn('x', 'price');
|
||||||
|
cb().setChannelAggregate('x', 'sum');
|
||||||
|
cb().setChannelType('x', 'nominal'); // sum can't reduce a nominal field
|
||||||
|
expect(cb().config.encodings.x?.aggregate).toBeUndefined();
|
||||||
|
cb().setChannelAggregate('x', 'distinct');
|
||||||
|
cb().setChannelType('x', 'ordinal'); // distinct counts unique values of anything
|
||||||
|
expect(cb().config.encodings.x?.aggregate).toBe('distinct');
|
||||||
|
});
|
||||||
|
|
||||||
test('setChannelTimeUnit sets and clears granularity', () => {
|
test('setChannelTimeUnit sets and clears granularity', () => {
|
||||||
const id = seedDataset('S', [{ day: '2026-01-01', v: 1 }]);
|
const id = seedDataset('S', [{ day: '2026-01-01', v: 1 }]);
|
||||||
cb().init(id);
|
cb().init(id);
|
||||||
|
|||||||
@@ -27,9 +27,9 @@ import {
|
|||||||
isChannelTypeAllowed,
|
isChannelTypeAllowed,
|
||||||
isColumnAllowedOnChannel,
|
isColumnAllowedOnChannel,
|
||||||
pruneEncodings,
|
pruneEncodings,
|
||||||
supportsAggregate,
|
|
||||||
supportsBin,
|
supportsBin,
|
||||||
supportsTimeUnit,
|
supportsTimeUnit,
|
||||||
|
validAggregateOps,
|
||||||
validFieldTypes,
|
validFieldTypes,
|
||||||
validFilterOps,
|
validFilterOps,
|
||||||
type AggregateOp,
|
type AggregateOp,
|
||||||
@@ -106,9 +106,11 @@ export interface ChartBuilderState {
|
|||||||
/**
|
/**
|
||||||
* Place a clicked shelf field on a channel: the armed channel if it accepts the
|
* Place a clicked shelf field on a channel: the armed channel if it accepts the
|
||||||
* column, else the first empty channel that does (X→Y→Color→Size). A no-op when no
|
* column, else the first empty channel that does (X→Y→Color→Size). A no-op when no
|
||||||
* channel can take it. Clears the armed slot.
|
* channel can take it. Clears the armed slot. An explicit `channel` (the field
|
||||||
|
* chip's channel chooser) overrides both — it places the column there even when
|
||||||
|
* the channel is occupied (the chooser labels that as a replacement).
|
||||||
*/
|
*/
|
||||||
assignField: (columnName: string) => void;
|
assignField: (columnName: string, channel?: ChannelName) => void;
|
||||||
/**
|
/**
|
||||||
* Set/replace a channel's binding with a **constant value** (the Property model,
|
* Set/replace a channel's binding with a **constant value** (the Property model,
|
||||||
* spec §06) — a fixed colour/size emitted as Vega-Lite `{ value }`. Only Color/Size
|
* spec §06) — a fixed colour/size emitted as Vega-Lite `{ value }`. Only Color/Size
|
||||||
@@ -151,6 +153,10 @@ export interface ChartBuilderState {
|
|||||||
removeCalculate: (id: string) => void;
|
removeCalculate: (id: string) => void;
|
||||||
setWidth: (width: number | undefined) => void;
|
setWidth: (width: number | undefined) => void;
|
||||||
setHeight: (height: number | undefined) => void;
|
setHeight: (height: number | undefined) => void;
|
||||||
|
/** Set/clear the chart title (whitespace-only clears). */
|
||||||
|
setTitle: (raw: string) => void;
|
||||||
|
/** Set/clear the subtitle; emitted into the spec only alongside a title. */
|
||||||
|
setSubtitle: (raw: string) => void;
|
||||||
/** Build the spec, create + activate a linked snippet, toast, and close. */
|
/** Build the spec, create + activate a linked snippet, toast, and close. */
|
||||||
createSnippet: (now?: Date) => boolean;
|
createSnippet: (now?: Date) => boolean;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
@@ -259,9 +265,9 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
|
|||||||
|
|
||||||
focusChannel: (channel) => set({ activeChannel: channel }),
|
focusChannel: (channel) => set({ activeChannel: channel }),
|
||||||
|
|
||||||
assignField: (columnName) =>
|
assignField: (columnName, channel) =>
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const target = chooseTargetChannel(s, columnName);
|
const target = channel ?? chooseTargetChannel(s, columnName);
|
||||||
if (!target) return { activeChannel: null }; // nothing can take it → just disarm
|
if (!target) return { activeChannel: null }; // nothing can take it → just disarm
|
||||||
const mapping = mappingForColumn(s, target, columnName);
|
const mapping = mappingForColumn(s, target, columnName);
|
||||||
return {
|
return {
|
||||||
@@ -288,10 +294,18 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
|
|||||||
set((s) => {
|
set((s) => {
|
||||||
const current = s.config.encodings[channel];
|
const current = s.config.encodings[channel];
|
||||||
if (!current) return s; // no field on this channel → nothing to retype
|
if (!current) return s; // no field on this channel → nothing to retype
|
||||||
// Drop transforms that no longer apply to the new type (e.g. an aggregate or
|
// Drop transforms that no longer apply to the new type (e.g. a sum or bin when
|
||||||
// bin when leaving Quantitative, a granularity when leaving Temporal).
|
// leaving Quantitative, a granularity when leaving Temporal). A `distinct`
|
||||||
|
// survives any retype (it applies to every field type); `count` is field-less
|
||||||
|
// and never reaches here (a count pill offers no type cycle).
|
||||||
const next: ChannelMapping = { ...current, type };
|
const next: ChannelMapping = { ...current, type };
|
||||||
if (!supportsAggregate(type)) delete next.aggregate;
|
if (
|
||||||
|
next.aggregate &&
|
||||||
|
next.aggregate !== 'count' &&
|
||||||
|
!validAggregateOps(type).includes(next.aggregate)
|
||||||
|
) {
|
||||||
|
delete next.aggregate;
|
||||||
|
}
|
||||||
if (!supportsBin(type)) delete next.bin;
|
if (!supportsBin(type)) delete next.bin;
|
||||||
if (!supportsTimeUnit(type)) delete next.timeUnit;
|
if (!supportsTimeUnit(type)) delete next.timeUnit;
|
||||||
return updateEncoding(s, channel, next);
|
return updateEncoding(s, channel, next);
|
||||||
@@ -451,6 +465,11 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
|
|||||||
setWidth: (width) => set((s) => ({ config: { ...s.config, width } })),
|
setWidth: (width) => set((s) => ({ config: { ...s.config, width } })),
|
||||||
setHeight: (height) => set((s) => ({ config: { ...s.config, height } })),
|
setHeight: (height) => set((s) => ({ config: { ...s.config, height } })),
|
||||||
|
|
||||||
|
// The raw input value is kept (mid-edit spaces and all) so typing feels normal;
|
||||||
|
// the assembler trims and skips a blank/whitespace-only title at emission.
|
||||||
|
setTitle: (raw) => set((s) => ({ config: { ...s.config, title: raw || undefined } })),
|
||||||
|
setSubtitle: (raw) => set((s) => ({ config: { ...s.config, subtitle: raw || undefined } })),
|
||||||
|
|
||||||
createSnippet: (now) => {
|
createSnippet: (now) => {
|
||||||
const { config } = get();
|
const { config } = get();
|
||||||
if (!isBuilderConfigValid(config)) return false; // guarded by a disabled action too
|
if (!isBuilderConfigValid(config)) return false; // guarded by a disabled action too
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
isChannelTypeAllowed,
|
isChannelTypeAllowed,
|
||||||
isColumnAllowedOnChannel,
|
isColumnAllowedOnChannel,
|
||||||
supportsAggregate,
|
supportsAggregate,
|
||||||
|
validAggregateOps,
|
||||||
supportsBin,
|
supportsBin,
|
||||||
supportsTimeUnit,
|
supportsTimeUnit,
|
||||||
supportsSort,
|
supportsSort,
|
||||||
@@ -840,12 +841,96 @@ describe('transforms — aggregate / bin / timeUnit', () => {
|
|||||||
|
|
||||||
it('exposes the transform-applicability predicates by field type', () => {
|
it('exposes the transform-applicability predicates by field type', () => {
|
||||||
expect(supportsAggregate('quantitative')).toBe(true);
|
expect(supportsAggregate('quantitative')).toBe(true);
|
||||||
expect(supportsAggregate('nominal')).toBe(false);
|
// Every type now takes at least one aggregate (`distinct` applies to anything).
|
||||||
|
expect(supportsAggregate('nominal')).toBe(true);
|
||||||
expect(supportsBin('quantitative')).toBe(true);
|
expect(supportsBin('quantitative')).toBe(true);
|
||||||
expect(supportsBin('temporal')).toBe(false);
|
expect(supportsBin('temporal')).toBe(false);
|
||||||
expect(supportsTimeUnit('temporal')).toBe(true);
|
expect(supportsTimeUnit('temporal')).toBe(true);
|
||||||
expect(supportsTimeUnit('quantitative')).toBe(false);
|
expect(supportsTimeUnit('quantitative')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('offers per-type aggregate menus: arithmetic needs numbers, min/max an ordering, distinct anything', () => {
|
||||||
|
expect(validAggregateOps('quantitative')).toEqual([
|
||||||
|
'sum',
|
||||||
|
'mean',
|
||||||
|
'median',
|
||||||
|
'min',
|
||||||
|
'max',
|
||||||
|
'distinct',
|
||||||
|
]);
|
||||||
|
expect(validAggregateOps('temporal')).toEqual(['min', 'max', 'distinct']);
|
||||||
|
expect(validAggregateOps('ordinal')).toEqual(['min', 'max', 'distinct']);
|
||||||
|
expect(validAggregateOps('nominal')).toEqual(['distinct']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits a distinct-count of a categorical field as a quantitative measure', () => {
|
||||||
|
const spec = buildChartSpec({
|
||||||
|
datasetName: 'D',
|
||||||
|
mark: 'bar',
|
||||||
|
encodings: {
|
||||||
|
x: { field: 'region', type: 'nominal' },
|
||||||
|
y: { field: 'customer', type: 'nominal', aggregate: 'distinct' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const enc = spec.encoding as Record<string, Record<string, unknown>>;
|
||||||
|
// The carried (nominal) type is for round-tripping; the emitted type is the
|
||||||
|
// effective one — a count of unique values reads as a quantitative measure.
|
||||||
|
expect(enc.y).toEqual({ field: 'customer', type: 'quantitative', aggregate: 'distinct' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the field type on order-preserving aggregates (a temporal min is still temporal)', () => {
|
||||||
|
const spec = buildChartSpec({
|
||||||
|
datasetName: 'D',
|
||||||
|
mark: 'point',
|
||||||
|
encodings: {
|
||||||
|
x: { field: 'region', type: 'nominal' },
|
||||||
|
y: { field: 'orderDate', type: 'temporal', aggregate: 'min' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const enc = spec.encoding as Record<string, Record<string, unknown>>;
|
||||||
|
expect(enc.y).toEqual({ field: 'orderDate', type: 'temporal', aggregate: 'min' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits title as a bare string, the object form with a subtitle, nothing without a title', () => {
|
||||||
|
const base: BuilderConfig = {
|
||||||
|
datasetName: 'D',
|
||||||
|
mark: 'bar',
|
||||||
|
encodings: { x: { field: 'region', type: 'nominal' } },
|
||||||
|
};
|
||||||
|
expect(buildChartSpec({ ...base, title: 'Sales by region' }).title).toBe('Sales by region');
|
||||||
|
expect(buildChartSpec({ ...base, title: 'Sales', subtitle: 'FY26' }).title).toEqual({
|
||||||
|
text: 'Sales',
|
||||||
|
subtitle: 'FY26',
|
||||||
|
});
|
||||||
|
// A subtitle alone is not emitted (VL has no standalone subtitle), nor is a
|
||||||
|
// whitespace-only title.
|
||||||
|
expect(buildChartSpec({ ...base, subtitle: 'orphan' }).title).toBeUndefined();
|
||||||
|
expect(buildChartSpec({ ...base, title: ' ' }).title).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers a user-written title as the generated snippet name', () => {
|
||||||
|
expect(
|
||||||
|
generateChartName({
|
||||||
|
datasetName: 'D',
|
||||||
|
mark: 'bar',
|
||||||
|
title: 'Quarterly revenue',
|
||||||
|
encodings: { x: { field: 'region', type: 'nominal' } },
|
||||||
|
}),
|
||||||
|
).toBe('Quarterly revenue');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names a distinct-count chart with a "unique" phrase', () => {
|
||||||
|
expect(
|
||||||
|
generateChartName({
|
||||||
|
datasetName: 'D',
|
||||||
|
mark: 'bar',
|
||||||
|
encodings: {
|
||||||
|
x: { field: 'region', type: 'nominal' },
|
||||||
|
y: { field: 'customer', type: 'nominal', aggregate: 'distinct' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe('Bar chart of unique customer by region');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('sort (ranking)', () => {
|
describe('sort (ranking)', () => {
|
||||||
|
|||||||
@@ -44,9 +44,12 @@ export type ChannelName = (typeof CHANNELS)[number];
|
|||||||
/**
|
/**
|
||||||
* Aggregation operators a channel may apply (Vega-Lite `aggregate`). `count` is
|
* Aggregation operators a channel may apply (Vega-Lite `aggregate`). `count` is
|
||||||
* special — it is **field-less** (counts records), so a `count` mapping carries no
|
* special — it is **field-less** (counts records), so a `count` mapping carries no
|
||||||
* `field`. The rest reduce a quantitative `field`.
|
* `field`. `distinct` counts a field's unique values, so it applies to **any** field
|
||||||
|
* type and reads as a quantitative measure. The arithmetic ops (sum/mean/median)
|
||||||
|
* reduce a quantitative field; min/max also order a temporal or ordinal one. See
|
||||||
|
* `validAggregateOps` for the per-type menu.
|
||||||
*/
|
*/
|
||||||
export const AGGREGATE_OPS = ['count', 'sum', 'mean', 'median', 'min', 'max'] as const;
|
export const AGGREGATE_OPS = ['count', 'distinct', 'sum', 'mean', 'median', 'min', 'max'] as const;
|
||||||
export type AggregateOp = (typeof AGGREGATE_OPS)[number];
|
export type AggregateOp = (typeof AGGREGATE_OPS)[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -193,6 +196,10 @@ export interface BuilderConfig {
|
|||||||
mark: MarkType;
|
mark: MarkType;
|
||||||
/** Per-channel mapping; `null` (or absent) means the channel is unmapped. */
|
/** Per-channel mapping; `null` (or absent) means the channel is unmapped. */
|
||||||
encodings: Partial<Record<ChannelName, ChannelMapping | null>>;
|
encodings: Partial<Record<ChannelName, ChannelMapping | null>>;
|
||||||
|
/** Optional chart title (Vega-Lite top-level `title`). */
|
||||||
|
title?: string;
|
||||||
|
/** Optional subtitle; emitted only alongside a title (VL nests it under `title`). */
|
||||||
|
subtitle?: string;
|
||||||
/** Optional explicit chart width in pixels. */
|
/** Optional explicit chart width in pixels. */
|
||||||
width?: number;
|
width?: number;
|
||||||
/** Optional explicit chart height in pixels. */
|
/** Optional explicit chart height in pixels. */
|
||||||
@@ -276,9 +283,29 @@ export function isColumnAllowedOnChannel(channel: ChannelName, columnType: Colum
|
|||||||
return isChannelTypeAllowed(channel, defaultFieldType(columnType));
|
return isChannelTypeAllowed(channel, defaultFieldType(columnType));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether a non-count aggregate (sum/mean/…) can apply to this field type. */
|
/**
|
||||||
|
* The non-count aggregates that legitimately apply to a field of this type — the
|
||||||
|
* channel's Aggregate menu. Arithmetic reduction (sum/mean/median) needs numbers;
|
||||||
|
* min/max need an ordering (numbers, dates, asserted-ordinal values); `distinct`
|
||||||
|
* (count of unique values) applies to anything — the natural measure to wring out
|
||||||
|
* of a category ("how many unique customers"), which is why the menu is per-type
|
||||||
|
* rather than quantitative-only.
|
||||||
|
*/
|
||||||
|
export function validAggregateOps(type: FieldType): Exclude<AggregateOp, 'count'>[] {
|
||||||
|
switch (type) {
|
||||||
|
case 'quantitative':
|
||||||
|
return ['sum', 'mean', 'median', 'min', 'max', 'distinct'];
|
||||||
|
case 'temporal':
|
||||||
|
case 'ordinal':
|
||||||
|
return ['min', 'max', 'distinct'];
|
||||||
|
case 'nominal':
|
||||||
|
return ['distinct'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether any non-count aggregate (sum/…/distinct) can apply to this field type. */
|
||||||
export function supportsAggregate(type: FieldType): boolean {
|
export function supportsAggregate(type: FieldType): boolean {
|
||||||
return type === 'quantitative';
|
return validAggregateOps(type).length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether binning into ranges can apply to this field type. */
|
/** Whether binning into ranges can apply to this field type. */
|
||||||
@@ -498,9 +525,13 @@ function mappedChannels(config: BuilderConfig): Array<[ChannelName, ChannelMappi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The effective field type a mapping encodes (a count is quantitative). */
|
/** The effective field type a mapping encodes: a count or a distinct-count reads as
|
||||||
|
* a quantitative measure whatever the underlying field; other aggregates (sum, a
|
||||||
|
* temporal min/…) keep the field's own type. */
|
||||||
function effectiveType(mapping: ChannelMapping): FieldType {
|
function effectiveType(mapping: ChannelMapping): FieldType {
|
||||||
return mapping.aggregate === 'count' ? 'quantitative' : mapping.type;
|
return mapping.aggregate === 'count' || mapping.aggregate === 'distinct'
|
||||||
|
? 'quantitative'
|
||||||
|
: mapping.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when a mapping reads as a continuous measure (count/aggregate or continuous type). */
|
/** True when a mapping reads as a continuous measure (count/aggregate or continuous type). */
|
||||||
@@ -1034,7 +1065,9 @@ function encodingObject(mapping: ChannelMapping): Record<string, unknown> {
|
|||||||
// Escape `.`/`[`/`]` so a column literally named e.g. `user.age` is read as that
|
// Escape `.`/`[`/`]` so a column literally named e.g. `user.age` is read as that
|
||||||
// field, not a nested-property accessor (docs/architecture/05 §4).
|
// field, not a nested-property accessor (docs/architecture/05 §4).
|
||||||
if (mapping.field !== undefined) enc.field = escapeVegaField(mapping.field);
|
if (mapping.field !== undefined) enc.field = escapeVegaField(mapping.field);
|
||||||
enc.type = mapping.type;
|
// The emitted type is the *effective* one: a distinct-count of any field is a
|
||||||
|
// quantitative measure (the carried field type is preserved for a later un-aggregate).
|
||||||
|
enc.type = effectiveType(mapping);
|
||||||
if (mapping.aggregate) enc.aggregate = mapping.aggregate;
|
if (mapping.aggregate) enc.aggregate = mapping.aggregate;
|
||||||
if (mapping.bin) enc.bin = true;
|
if (mapping.bin) enc.bin = true;
|
||||||
if (mapping.timeUnit) enc.timeUnit = mapping.timeUnit;
|
if (mapping.timeUnit) enc.timeUnit = mapping.timeUnit;
|
||||||
@@ -1047,7 +1080,7 @@ function encodingObject(mapping: ChannelMapping): Record<string, unknown> {
|
|||||||
* any top-level `transform` (calculated fields then row filters), the mark with
|
* any top-level `transform` (calculated fields then row filters), the mark with
|
||||||
* tooltips enabled, every mapped encoding (field, type, and any aggregate/bin/
|
* tooltips enabled, every mapped encoding (field, type, and any aggregate/bin/
|
||||||
* timeUnit transform), chart-level sort (rank a categorical axis by its measure)
|
* timeUnit transform), chart-level sort (rank a categorical axis by its measure)
|
||||||
* and stack (part-to-whole), and any explicit width/height. Unmapped
|
* and stack (part-to-whole), any title/subtitle, and any explicit width/height. Unmapped
|
||||||
* channels are omitted; if nothing is mapped the `encoding` block is omitted
|
* channels are omitted; if nothing is mapped the `encoding` block is omitted
|
||||||
* entirely (validation prevents saving that, but the live preview may render a bare
|
* entirely (validation prevents saving that, but the live preview may render a bare
|
||||||
* mark while the user is still configuring).
|
* mark while the user is still configuring).
|
||||||
@@ -1063,6 +1096,15 @@ export function buildChartSpec(config: BuilderConfig): ChartSpec {
|
|||||||
const transform = buildTransforms(config);
|
const transform = buildTransforms(config);
|
||||||
if (transform.length > 0) spec.transform = transform;
|
if (transform.length > 0) spec.transform = transform;
|
||||||
|
|
||||||
|
// Title/subtitle: a bare string for a lone title, the object form when a
|
||||||
|
// subtitle rides along. A subtitle without a title is not emitted (VL has no
|
||||||
|
// standalone subtitle; the UI disables the input until a title exists).
|
||||||
|
const title = config.title?.trim();
|
||||||
|
if (title) {
|
||||||
|
const subtitle = config.subtitle?.trim();
|
||||||
|
spec.title = subtitle ? { text: title, subtitle } : title;
|
||||||
|
}
|
||||||
|
|
||||||
spec.mark = { type: config.mark, tooltip: true };
|
spec.mark = { type: config.mark, tooltip: true };
|
||||||
|
|
||||||
const encoding: Record<string, Record<string, unknown>> = {};
|
const encoding: Record<string, Record<string, unknown>> = {};
|
||||||
@@ -1109,6 +1151,7 @@ function describeMapping(mapping: ChannelMapping): string {
|
|||||||
if (mapping.value !== undefined) return 'a constant';
|
if (mapping.value !== undefined) return 'a constant';
|
||||||
if (mapping.aggregate === 'count') return 'count';
|
if (mapping.aggregate === 'count') return 'count';
|
||||||
const field = mapping.field ?? '';
|
const field = mapping.field ?? '';
|
||||||
|
if (mapping.aggregate === 'distinct') return `unique ${field}`;
|
||||||
if (mapping.aggregate) return `${mapping.aggregate} of ${field}`;
|
if (mapping.aggregate) return `${mapping.aggregate} of ${field}`;
|
||||||
return field;
|
return field;
|
||||||
}
|
}
|
||||||
@@ -1121,6 +1164,9 @@ function describeMapping(mapping: ChannelMapping): string {
|
|||||||
* no timestamp — so the name describes the chart, not when it was made.
|
* no timestamp — so the name describes the chart, not when it was made.
|
||||||
*/
|
*/
|
||||||
export function generateChartName(config: BuilderConfig): string {
|
export function generateChartName(config: BuilderConfig): string {
|
||||||
|
// A user-written chart title is the best possible name — prefer it verbatim.
|
||||||
|
const title = config.title?.trim();
|
||||||
|
if (title) return title;
|
||||||
const mark = markLabel(config.mark);
|
const mark = markLabel(config.mark);
|
||||||
const x = config.encodings.x;
|
const x = config.encodings.x;
|
||||||
const y = config.encodings.y;
|
const y = config.encodings.y;
|
||||||
|
|||||||
Reference in New Issue
Block a user