diff --git a/docs/architecture/05-rendering-theming-preview.md b/docs/architecture/05-rendering-theming-preview.md index c849343..2979ca5 100644 --- a/docs/architecture/05-rendering-theming-preview.md +++ b/docs/architecture/05-rendering-theming-preview.md @@ -426,6 +426,12 @@ renderer service. Builder flow: `chart-builder.ts` (pure spec assembler) → `Ch (config + create) → `ChartBuilderModal`'s `BuilderPreview`. Reach for a reusable preview component only if a _third_ surface appears. +The builder's **X/Y axis controls live in the preview pane, not the config pane**: the +on-chart Columns/Rows shelves (`OnChartShelves`) sit _above_ `BuilderPreview`, because axis +position is a property of the chart (Tableau's Columns/Rows metaphor). The field shelf and +the Colour/Size Marks card stay in the config pane. A reserved faceting slot in each shelf +is a placeholder only. + --- ## 6. Rendering Contract Lives Upstream (reference) diff --git a/docs/chart-builder-enhancement-scope.md b/docs/chart-builder-enhancement-scope.md index 4cae93f..1a071b7 100644 --- a/docs/chart-builder-enhancement-scope.md +++ b/docs/chart-builder-enhancement-scope.md @@ -25,6 +25,29 @@ Newest first. The at-a-glance build-order tracker is §4; per-item detail is §3. This log is the quick "where are we" — read it first. +- **2026-06-11 (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 + `value` arm; the assembler emits `{ value }` and the measure/stack/area-split/prune logic + all treat a constant Colour as "no series." New pure helpers (`isValueMapping`, + `channelAcceptsValue`, `defaultChannelValue`, `coerceChannelValue`, + `isColumnAllowedOnChannel`). `type` stays required (a constant carries a preserved-but- + ignored type, so a field↔constant toggle round-trips). Tested. + - **2B · Field-first builder.** The channel-first dropdown rows are replaced by a **field + shelf** (columns as type-glyphed chips, auto Dimensions/Measures split past a column + threshold), **click-to-assign** (armed channel, else first empty that accepts it — + `assignField`/`focusChannel`), Tableau-style **pills** (a type chip that **cycles** the + field type in-place, transforms beneath), and the **on-chart Columns/Rows shelves** above + the preview (X/Y as a property of the chart) with a **reserved faceting placeholder** in + each. Colour/Size live in a left **Marks card**, each switchable to a **constant** (2A). + - **Decisions taken (overridable):** drag deferred (click/keyboard-first, fully tested); + field grouping auto; aggregate stays a control under the pill (no pill context-menu yet). + - **Spec §06** rewritten for the field-first model (Layout, Encoding channels, faceting + placeholder, constants, validation/output). + - **Verified:** `typecheck` + `test` (762, +20 core/store/modal) + `eslint` + `build`. + **Owed:** a manual/visual pass against the live builder; a **`/council`** look at the + type-cycle chip (a cycling button gives no direct type pick — explicit menu vs cycle is a + real a11y trade-off) and the constant affordance. + - **2026-06-11 (later)** — **Owed debts on the Data section closed. Up next: Phase 2 (2A value-or-field channels, then 2B field shelf).** - **Council pass** on the new error/disclosure copy (the previously-deferred auto-fire surface). Three a11y conformance gaps against `architecture/10` were fixed: the inline @@ -393,9 +416,9 @@ Phase 1 1A actionable hints ✓ done 1C filter (+ calculate) ✓ done 1D data preview ✓ done 1E expr-validate ✓ done (syntax + unknown-field; completion popup deferred) -Phase 2 2A value-or-field channels (Property model) ← next - 2B field shelf + in-place type cycling ← Tier-C substrate -Phase 3 3A intent-first front door (Tier C) ← built on 2B; the defining feature +Phase 2 2A value-or-field channels (Property model) ✓ done + 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 3B starter examples Phase 4 (gated) theta/facets/styling-overrides/undo/lookup — decide after Phase 3 diff --git a/docs/spec/06-chart-builder.md b/docs/spec/06-chart-builder.md index 027c09e..624e83d 100644 --- a/docs/spec/06-chart-builder.md +++ b/docs/spec/06-chart-builder.md @@ -2,7 +2,7 @@ The Chart Builder is a visual, no-JSON way to compose a Vega-Lite chart from a selected dataset. The user picks a mark type and maps the dataset's columns to encoding channels; the builder produces a complete Vega-Lite spec and saves it as a new snippet that references the dataset. It is intended for users who want to start a chart quickly without hand-writing JSON in the _Spec Editor & Draft/Published Workflow_. -> **Design level — "smart + guarded" (Tier B).** The builder is mark-first and stays within the inputs below, but it is not a dumb composer: it picks a sensible default mark for the data shape, offers only field types valid for each column, keeps unsuitable channel mappings out of reach, and surfaces non-blocking guidance for encodings that render poorly. These behaviors are derived from cross-source chart-choice research recorded in [`docs/chart-builder-research.md`](../chart-builder-research.md) (the convergence of Draco, Voyager, the FT Visual Vocabulary, and Datawrapper). The richer "intent-first" front door (ask _what do you want to show?_ and recommend a chart) is explicitly out of scope for now and noted there as a future tier. +> **Design level — "smart + guarded" (Tier B).** The builder is field-first — the user works from a shelf of the dataset's columns and drops them onto encoding channels — and stays within the inputs below, but it is not a dumb composer: it picks a sensible default mark for the data shape, offers only field types valid for each column, keeps unsuitable channel mappings out of reach, and surfaces non-blocking guidance for encodings that render poorly. These behaviors are derived from cross-source chart-choice research recorded in [`docs/chart-builder-research.md`](../chart-builder-research.md) (the convergence of Draco, Voyager, the FT Visual Vocabulary, and Datawrapper). The richer "intent-first" front door (ask _what do you want to show?_ and recommend a chart) is explicitly out of scope for now and noted there as a future tier. ## Opening @@ -14,8 +14,8 @@ The Chart Builder is a visual, no-JSON way to compose a Vega-Lite chart from a s A two-pane modal: -- **Left — configuration:** dataset name, a **Data** section (row filters, calculated fields, and a collapsible row preview — see _Data_ below), mark type selector, one row per encoding channel, optional width/height inputs, and a "Create Snippet" action. -- **Right — live preview:** a rendered chart that updates as the configuration changes, with a placeholder/error area. +- **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. +- **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) @@ -59,17 +59,19 @@ The section is ordered **input → shaping** so the distinction reads at a glanc ### Encoding channels -- Exactly four channels are offered, in this order: **X, Y, Color, Size**. -- For each channel the user: - - Picks a dataset column from a dropdown of the dataset's detected columns (see _Datasets_ for column detection). A "None" option leaves the channel unmapped. Each column option shows a small type indicator alongside the column name. - - Optionally overrides the channel's **field type**. The override appears only once a column is selected, and offers only the **types valid for that column** (Tier B valid-type locking) — a string/boolean column never offers Quantitative, and only a date column offers Temporal. Concretely: number → {Quantitative (default), Ordinal, Nominal}; date → {Temporal}; text → {Nominal (default), Ordinal}; boolean → {Nominal}. When a column admits only one valid type, no override control is shown. -- When a column is chosen, its field type defaults from the dataset's inferred column type (numeric → Quantitative, date → Temporal, otherwise Nominal); the user may change it within the valid set above. -- **Size discipline:** the **Size** channel accepts only Quantitative or Ordinal columns — size implies an ordered magnitude, so categorical (Nominal) and Temporal columns are not offered for Size (they remain available on X/Y/Color). A column that can't go on Size is shown disabled there with a brief reason. -- The column dropdown also offers a field-less **"Count of records"** measure (Vega-Lite `count`) — a quantitative count of the rows, with no column. -- Clearing a channel back to "None" leaves it out of the produced spec. -- A **Swap X/Y** control exchanges the X and Y mappings (field and type) in one click, for quickly flipping the axes of the pre-populated default without re-selecting both columns. +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-type override is presented as a fixed **`N | O | Q | T`** segmented control (abbreviations with full-name tooltips, after _Datasets_' Nominal/Ordinal/Quantitative/Temporal), always showing all four with the column's invalid types **disabled** rather than hidden — so the control keeps one shape on every 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. +- **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). +- **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. +- **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. +- 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. + +### Faceting placeholder (reserved) + +Each on-chart shelf shows, beside its axis slot, a **non-interactive placeholder** for **faceting → small multiples** (a future capability). It only signals where row/column faceting will live; it does nothing yet. ### Transforms (per channel) @@ -118,14 +120,14 @@ A clean configuration shows no hints. ## Validation -- A chart requires **at least one** channel mapped to a column. -- While no channel is mapped, the "Create Snippet" action is disabled and the preview shows the configuration prompt. +- A chart requires **at least one** channel bound — a field, a count, or a constant value. +- While nothing is bound, the "Create Snippet" action is disabled and the preview shows the configuration prompt. ## Output / Create 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 mapped encodings (each with its field and field type, plus any aggregate / bin / `timeUnit` transform), chart-level sort and stacking where set, and any explicit width/height. +- Builds a complete Vega-Lite spec containing: the schema reference, a named data reference to the dataset, any top-level `transform` (calculated fields first, then row filters — see _Data_), the chosen mark (with tooltips enabled), the 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. - Channels left unmapped are omitted; if no encodings exist the spec omits the encoding block entirely (prevented by validation here). - Creates a new snippet from that spec with an auto-generated descriptive name, adds it to the snippet library, and records that it was built from the dataset. - Links the snippet to the dataset by recording the dataset reference, so the bidirectional snippet↔dataset relationship is established (see _Datasets_). diff --git a/docs/ux-second-pass.md b/docs/ux-second-pass.md new file mode 100644 index 0000000..2cdd794 --- /dev/null +++ b/docs/ux-second-pass.md @@ -0,0 +1,30 @@ +# UX second pass — batched council review + +A running parking lot of small UX / interaction decisions deferred for a **batched +[`/council`](../.claude/skills/council/SKILL.md) review**, rather than gating each one the +moment it surfaces. Append quirks here as they come up; resolve them together in one pass, +record the resolution into the contract (`docs/architecture/09`+`10` and the relevant +`docs/spec/`), then delete the row. + +## Open + +### Chart Builder — encoding type control: cycle vs. explicit pick + +- **Where:** `src/app/components/ChartBuilderModal.tsx` → `ChannelPill` (the `pillType` chip). +- **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 + +- **Where:** `ChannelPill` / `ChannelSlot` — the constant-value entry on Colour/Size (2A). +- **What:** an empty Colour/Size slot offers a small underlined "or constant" text button to + 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. diff --git a/src/app/components/ChartBuilderModal.module.css b/src/app/components/ChartBuilderModal.module.css index 51c6762..7eddaf4 100644 --- a/src/app/components/ChartBuilderModal.module.css +++ b/src/app/components/ChartBuilderModal.module.css @@ -390,18 +390,6 @@ color: var(--text-secondary); } -.channels { - display: flex; - flex-direction: column; - gap: var(--space-3); -} - -.channelsHeader { - display: flex; - align-items: baseline; - justify-content: space-between; -} - .swap { border: none; background: transparent; @@ -422,56 +410,13 @@ outline-offset: 1px; } -/* Each channel is a small block: a top row (label + column) and, when mapped, a - controls row (N|O|Q|T type + contextual transforms). */ -.channel { - display: flex; - flex-direction: column; - gap: var(--space-2); - padding: var(--space-3); - border: var(--border-width) solid var(--border); - border-radius: var(--radius); -} - -.channelTop { - display: grid; - grid-template-columns: 44px 1fr; - align-items: center; - gap: var(--space-2); -} - +/* Channel name label (Marks card rows + the on-chart Columns/Rows shelves). */ .channelLabel { font-size: 12px; font-weight: 600; color: var(--text); } -.channelControls { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: var(--space-2); - padding-left: 52px; -} - -.select { - 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; - width: 100%; -} - -/* The N|O|Q|T type control: monospace abbreviations so the four segments line up. */ -.typeSeg button { - font-family: var(--font-mono); - font-size: 11px; - padding: var(--space-1) var(--space-2); -} - .transform { display: inline-flex; align-items: center; @@ -506,7 +451,6 @@ gap: var(--space-5); } -.select:focus-visible, .mini:focus-visible, .dimInput:focus-visible { outline: 2px solid var(--focus); @@ -672,9 +616,19 @@ /* ── Right: live preview ─────────────────────────────────────────────── */ +/* Right side = on-chart shelves stacked above the live preview (spec §06). */ +.previewSide { + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + background: var(--bg); +} + .previewPane { display: flex; flex-direction: column; + flex: 1; padding: var(--space-5); min-width: 0; min-height: 0; @@ -741,3 +695,334 @@ white-space: pre-wrap; word-break: break-word; } + +/* ── Encoding: field shelf + Tableau-style shelves/pills (spec §06 → Encoding) ─── */ + +/* Field shelf — the dataset's columns as clickable, type-glyphed chips (field-first). */ +.fieldShelf { + display: flex; + flex-direction: column; + gap: var(--space-2); +} + +.shelfGroupHead { + margin-top: var(--space-2); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-secondary); +} + +.shelfList { + display: flex; + flex-direction: column; + gap: 2px; +} + +.shelfField { + display: flex; + align-items: center; + gap: var(--space-2); + width: 100%; + padding: var(--space-2) var(--space-3); + border: var(--border-width) solid var(--border); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.shelfField:hover { + border-color: var(--accent); + background: var(--layer-01); +} + +.shelfField:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 1px; +} + +.shelfFieldUsed { + opacity: 0.55; +} + +.shelfGlyph { + min-width: 22px; + padding: 1px var(--space-2); + border: var(--border-width) solid var(--border-strong); + background: var(--layer-01); + color: var(--text-secondary); + font-family: var(--font-mono); + font-size: 11px; + text-align: center; +} + +.shelfFieldName { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Marks card — the non-positional encodings (Colour, Size). */ +.marksCard { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-3); + border: var(--border-width) solid var(--border); + border-radius: var(--radius); +} + +.marksRow { + display: grid; + grid-template-columns: 44px 1fr; + align-items: start; + gap: var(--space-2); +} + +/* Pill — a bound channel (field/count or constant). */ +.pillWrap { + display: flex; + flex-direction: column; + gap: var(--space-2); + min-width: 0; +} + +.pill { + display: inline-flex; + align-items: center; + max-width: 100%; + background: var(--accent); + color: var(--accent-contrast); + border-radius: var(--radius); +} + +.pillConst { + background: var(--layer-02); + color: var(--text); +} + +.pillType { + padding: var(--space-2); + border: none; + background: color-mix(in srgb, #000 18%, var(--accent)); + color: var(--accent-contrast); + font-family: var(--font-mono); + font-size: 11px; + line-height: 1; + cursor: pointer; +} + +.pillType:hover:not(:disabled) { + background: color-mix(in srgb, #000 30%, var(--accent)); +} + +.pillType:disabled { + cursor: default; + opacity: 0.85; +} + +.pillType:focus-visible { + outline: 2px solid var(--accent-contrast); + outline-offset: -2px; +} + +.pillTag { + padding: var(--space-2); + color: var(--text-secondary); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.pillName { + min-width: 0; + padding: var(--space-2) var(--space-3); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; +} + +.pillRemove { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-2); + border: none; + background: transparent; + color: inherit; + cursor: pointer; +} + +.pillRemove:hover { + background: color-mix(in srgb, var(--text) 14%, transparent); +} + +.pillRemove:focus-visible { + outline: 2px solid var(--focus); + outline-offset: -2px; +} + +.constColor { + width: 28px; + height: 22px; + margin: 0 var(--space-2); + padding: 0; + border: var(--border-width) solid var(--border-strong); + background: var(--bg); + cursor: pointer; +} + +.constNumber { + width: 64px; + margin: 0 var(--space-2); + 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; +} + +.pillControls { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-2); +} + +/* Empty assign target — armed by click, then filled by a clicked field. */ +.slot { + display: flex; + align-items: center; + gap: var(--space-2); + min-height: 32px; + padding: var(--space-1) var(--space-2); + border: var(--border-width) dashed var(--border-strong); + background: var(--layer-01); +} + +.slotActive { + border-style: solid; + border-color: var(--accent); + box-shadow: inset 0 0 0 1px var(--accent); +} + +.slotAssign { + flex: 1; + min-width: 0; + padding: var(--space-1); + border: none; + background: transparent; + color: var(--text-placeholder); + font: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.slotAssign:hover { + color: var(--text-secondary); +} + +.slotAssign:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 1px; +} + +.slotConst { + padding: var(--space-1); + border: none; + background: none; + color: var(--accent); + font: inherit; + font-size: 11px; + white-space: nowrap; + text-decoration: underline; + cursor: pointer; +} + +.slotConst:hover { + color: var(--accent-hover); +} + +.slotConst:focus-visible { + outline: 2px solid var(--focus); + outline-offset: 1px; +} + +/* On-chart Columns / Rows shelves (X/Y), stacked above the preview. */ +.shelves { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-5) var(--space-5) 0; +} + +.shelvesHead { + display: flex; + align-items: baseline; + justify-content: space-between; +} + +.shelfStrip { + display: flex; + align-items: stretch; + min-height: 42px; + border: var(--border-width) solid var(--border); +} + +.shelfName { + flex: 0 0 84px; + display: flex; + align-items: center; + padding: 0 var(--space-3); + border-right: var(--border-width) solid var(--border); + background: var(--layer-01); + color: var(--text-secondary); + font-size: 12px; +} + +.shelfSlots { + flex: 1; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--space-3); + min-width: 0; + padding: var(--space-2) var(--space-3); +} + +.shelfSlots .slot, +.shelfSlots .pillWrap { + min-width: 190px; +} + +/* Reserved faceting placeholder (Phase 4) — non-interactive, signals where it'll live. */ +.facetSlot { + display: flex; + align-items: center; + gap: var(--space-2); + min-width: 150px; + min-height: 32px; + padding: var(--space-1) var(--space-3); + border: var(--border-width) dashed var(--border); + color: var(--text-placeholder); + font-size: 12px; + user-select: none; +} + +.facetTag { + padding: 0 var(--space-2); + border: var(--border-width) solid var(--border); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.03em; +} diff --git a/src/app/components/ChartBuilderModal.test.tsx b/src/app/components/ChartBuilderModal.test.tsx index 6f218ec..b6cda59 100644 --- a/src/app/components/ChartBuilderModal.test.tsx +++ b/src/app/components/ChartBuilderModal.test.tsx @@ -296,4 +296,74 @@ describe('ChartBuilderModal', () => { expect(refLink()).toBeDefined(); 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 () => { + 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); + // Clear the smart-default axes so the click lands on the first empty channel (X). + store.setChannelColumn('x', null); + store.setChannelColumn('y', null); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + const fieldButton = Array.from(container.querySelectorAll('button')).find((b) => + b.textContent?.includes('region'), + ); + expect(fieldButton).toBeDefined(); + + await act(async () => { + fieldButton!.click(); + await Promise.resolve(); + }); + + expect(useChartBuilderStore.getState().config.encodings.x).toEqual({ + field: 'region', + type: 'nominal', + }); + }); + + test('Colour can be switched to a constant value (the Property model, 2A/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; + useChartBuilderStore.getState().init(id); + + await act(async () => { + root.render(); + await Promise.resolve(); + }); + + // The empty Colour slot offers an "or constant" affordance (Colour is first in Marks). + const constButton = Array.from(container.querySelectorAll('button')).find( + (b) => b.textContent === 'or constant', + ); + expect(constButton).toBeDefined(); + + await act(async () => { + constButton!.click(); + await Promise.resolve(); + }); + + expect(useChartBuilderStore.getState().config.encodings.color?.value).toBeDefined(); + // A colour picker renders for the constant. + expect(container.querySelector('input[type="color"]')).not.toBeNull(); + }); }); diff --git a/src/app/components/ChartBuilderModal.tsx b/src/app/components/ChartBuilderModal.tsx index 8312ee3..9df9c54 100644 --- a/src/app/components/ChartBuilderModal.tsx +++ b/src/app/components/ChartBuilderModal.tsx @@ -19,15 +19,17 @@ import { useShallow } from 'zustand/react/shallow'; import type { VisualizationSpec } from 'vega-embed'; import { CHANNELS, - FIELD_TYPES, MARK_TYPES, TIME_UNITS, builderWarnings, + channelAcceptsValue, + defaultChannelValue, defaultFieldType, effectiveColumns, filterOpArity, isBuilderConfigValid, isChannelTypeAllowed, + isValueMapping, supportsAggregate, supportsBin, supportsSort, @@ -114,8 +116,7 @@ const CHANNEL_LABELS: Record = { size: 'Size', }; -/** The fixed N | O | Q | T field-type segments (terse, with full-name tooltips). */ -const TYPE_ORDER: readonly FieldType[] = ['nominal', 'ordinal', 'quantitative', 'temporal']; +/** Terse N | O | Q | T abbreviations for a field type (with full-name tooltips). */ const TYPE_ABBR: Record = { nominal: 'N', ordinal: 'O', @@ -205,86 +206,126 @@ function typeBadge(type: ColumnType): string { } } -/** Whether a column may be placed on a channel at all (Size discipline, §06). */ -function columnAllowedOnChannel(channel: ChannelName, colType: ColumnType): boolean { - return isChannelTypeAllowed(channel, defaultFieldType(colType)); -} - /** True when the mapping is the field-less Count-of-records measure. */ function isCount(mapping: ChannelMapping | null): boolean { return !!mapping && mapping.aggregate === 'count' && mapping.field === undefined; } -function ChannelBlock({ channel }: { channel: ChannelName }) { - const baseColumns = useChartBuilderStore((s) => s.columns); - const calculates = useChartBuilderStore((s) => s.config.calculates); - // Effective columns = the dataset's columns plus any calculated fields, so a derived - // field is selectable on a channel like any real column. - const columns = useMemo( - () => effectiveColumns(baseColumns, calculates), - [baseColumns, calculates], - ); - const mapping = useChartBuilderStore((s) => s.config.encodings[channel] ?? null); +/** Past this many columns the field shelf splits into Dimensions / Measures groups; + * below it (and unless both groups are non-empty) it stays a single flat list. */ +const FIELD_SHELF_SPLIT_MIN = 7; + +/** The field types a mapping may cycle through on its channel (its column's valid + * types, narrowed by the channel — e.g. Size keeps only the measure types). */ +function channelTypeOptions( + channel: ChannelName, + mapping: ChannelMapping, + columns: BuilderColumns, +): FieldType[] { + if (mapping.field === undefined) return []; + const colType = columns.columnTypes.find((c) => c.name === mapping.field)?.type ?? 'string'; + return validFieldTypes(colType).filter((t) => isChannelTypeAllowed(channel, t)); +} + +/** + * A bound channel rendered as a Tableau-style **pill**: a leading type chip, the field + * (or "Count") label, and a remove (×). The type chip *is the control* — clicking it + * cycles the field's type within the set valid for this channel (disabled when only one + * type applies, e.g. a date). Any applicable transforms (aggregate / bin / granularity) + * sit in a compact row beneath the pill. A **constant** binding (Colour/Size only) + * shows the value editor instead — a colour picker or a number — emitting `{ value }`. + */ +function ChannelPill({ + channel, + mapping, + columns, +}: { + channel: ChannelName; + mapping: ChannelMapping; + columns: BuilderColumns; +}) { const setChannelColumn = useChartBuilderStore((s) => s.setChannelColumn); const setChannelType = useChartBuilderStore((s) => s.setChannelType); const setChannelAggregate = useChartBuilderStore((s) => s.setChannelAggregate); const setChannelBin = useChartBuilderStore((s) => s.setChannelBin); const setChannelTimeUnit = useChartBuilderStore((s) => s.setChannelTimeUnit); + const setChannelConstant = useChartBuilderStore((s) => s.setChannelConstant); - const colTypeOf = (name: string): ColumnType => - columns.columnTypes.find((c) => c.name === name)?.type ?? 'string'; + const clearLabel = `Remove ${CHANNEL_LABELS[channel]}`; + const clear = () => setChannelColumn(channel, null); - // The fixed N|O|Q|T control: a column's invalid types and types disallowed on this - // channel (e.g. a category on Size) are disabled, never hidden, so the control keeps - // one shape on every channel (APG radio with disabled options). - const typeSegments: ReadonlyArray> = useMemo(() => { - const valid = mapping?.field !== undefined ? validFieldTypes(colTypeOf(mapping.field)) : []; - return TYPE_ORDER.filter((t) => FIELD_TYPES.includes(t)).map((t) => ({ - value: t, - label: TYPE_ABBR[t], - title: titleCase(t), - disabled: !(valid.includes(t) && isChannelTypeAllowed(channel, t)), - })); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [channel, mapping?.field, columns]); + // A constant value (the Property model) — Colour or Size only. + if (isValueMapping(mapping)) { + const isColor = channel === 'color'; + return ( +
+ value + {isColor ? ( + setChannelConstant(channel, e.target.value)} + /> + ) : ( + setChannelConstant(channel, e.target.value)} + /> + )} + +
+ ); + } - const selectValue = - mapping === null ? '' : isCount(mapping) ? COUNT_FIELD : (mapping.field ?? ''); + const count = isCount(mapping); + const typeOptions = count ? [] : channelTypeOptions(channel, mapping, columns); + const canCycle = typeOptions.length > 1; + const currentType: FieldType = count ? 'quantitative' : mapping.type; + 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 = + !count && + (supportsAggregate(mapping.type) || + supportsBin(mapping.type) || + supportsTimeUnit(mapping.type)); return ( -
-
- {CHANNEL_LABELS[channel]} - + {TYPE_ABBR[currentType]} + + + {label} + +
- {mapping && !isCount(mapping) && ( -
- setChannelType(channel, t)} - className={styles.typeSeg} - /> - + {hasTransforms && ( +
{supportsAggregate(mapping.type) && (
-
-
- Encoding - -
- {CHANNELS.map((channel) => ( - - ))} -
+ + {(canSort || canStack) && (
@@ -1001,7 +1229,9 @@ export function ChartBuilderModal() { )}
- Size (optional) + {/* "Chart size", not just "Size" — the Marks card now has a Size *encoding* + channel; this is the rendered chart's width/height. */} + Chart size (optional)
- +
+ + +
); } diff --git a/src/app/stores/ChartBuilderStore.test.ts b/src/app/stores/ChartBuilderStore.test.ts index fa8c244..621d01f 100644 --- a/src/app/stores/ChartBuilderStore.test.ts +++ b/src/app/stores/ChartBuilderStore.test.ts @@ -260,3 +260,81 @@ describe('createSnippet', () => { expect(useSnippetStore.getState().snippets).toHaveLength(0); }); }); + +describe('field-first assignment (assignField / focusChannel)', () => { + test('a clicked field lands on the first empty channel that accepts it', () => { + const id = seedDataset('Shop', [{ region: 'E', sales: 5, qty: 2 }]); + cb().init(id); // region→X, count→Y (smart default for one category + measures) + cb().setChannelColumn('x', null); + cb().setChannelColumn('y', null); + + cb().assignField('region'); // first empty channel is X + expect(cb().config.encodings.x).toEqual({ field: 'region', type: 'nominal' }); + cb().assignField('sales'); // X taken → Y + expect(cb().config.encodings.y).toEqual({ field: 'sales', type: 'quantitative' }); + }); + + test('the armed channel takes the field, then disarms', () => { + const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]); + cb().init(id); + cb().focusChannel('color'); + expect(cb().activeChannel).toBe('color'); + cb().assignField('region'); + expect(cb().config.encodings.color).toEqual({ field: 'region', type: 'nominal' }); + expect(cb().activeChannel).toBeNull(); // armed slot cleared after assignment + }); + + test('skips a channel that cannot take the column (Size needs a measure)', () => { + const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]); + cb().init(id); + cb().focusChannel('size'); + cb().assignField('region'); // a category can't go on Size → falls through to an empty axis + expect(cb().config.encodings.size ?? null).toBeNull(); + // It landed on the first axis that accepts it instead of being dropped. + const onAxis = + cb().config.encodings.x?.field === 'region' || cb().config.encodings.y?.field === 'region'; + expect(onAxis).toBe(true); + }); + + test('Count of records assigns as a field-less count measure', () => { + const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]); + cb().init(id); + cb().setChannelColumn('x', null); + cb().setChannelColumn('y', null); + cb().focusChannel('y'); + cb().assignField(COUNT_FIELD); + expect(cb().config.encodings.y).toEqual({ type: 'quantitative', aggregate: 'count' }); + }); +}); + +describe('constant values (setChannelConstant — the Property model)', () => { + test('sets a fixed colour on Color, emitted as a constant', () => { + const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]); + cb().init(id); + cb().setChannelConstant('color', '#c0392b'); + expect(cb().config.encodings.color).toMatchObject({ value: '#c0392b' }); + }); + + test('coerces a Size constant to a number', () => { + const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]); + cb().init(id); + cb().setChannelConstant('size', '120'); + expect(cb().config.encodings.size?.value).toBe(120); + }); + + test('preserves the prior field type so toggling back to a field restores it', () => { + const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]); + cb().init(id); + cb().setChannelColumn('color', 'region'); // nominal field + cb().setChannelConstant('color', '#000000'); + expect(cb().config.encodings.color).toEqual({ value: '#000000', type: 'nominal' }); + }); + + test('refuses a constant on a positional channel (X/Y stay field-only)', () => { + const id = seedDataset('Shop', [{ region: 'E', sales: 5 }]); + cb().init(id); + const before = cb().config.encodings.x; + cb().setChannelConstant('x', '50'); + expect(cb().config.encodings.x).toBe(before); // unchanged — no-op + }); +}); diff --git a/src/app/stores/ChartBuilderStore.ts b/src/app/stores/ChartBuilderStore.ts index e10d053..f9bd977 100644 --- a/src/app/stores/ChartBuilderStore.ts +++ b/src/app/stores/ChartBuilderStore.ts @@ -15,13 +15,17 @@ import { create } from 'zustand'; import { + CHANNELS, buildSnippetSpecText, + channelAcceptsValue, + coerceChannelValue, defaultBuilderConfig, defaultFieldType, effectiveColumns, generateChartName, isBuilderConfigValid, isChannelTypeAllowed, + isColumnAllowedOnChannel, pruneEncodings, supportsAggregate, supportsBin, @@ -78,6 +82,13 @@ export interface ChartBuilderState { rowCount: number | null; /** The working configuration the preview and the produced spec read from. */ config: BuilderConfig; + /** + * The channel "armed" to receive the next clicked field (field-first assignment, + * spec §06 → Encoding). Clicking an empty channel slot arms it; clicking a field in + * the shelf then fills it. Null when no slot is armed (a clicked field lands on the + * first empty channel that accepts it). + */ + activeChannel: ChannelName | null; /** Load a dataset and pre-populate a smart default config (spec §06 → Opening). */ init: (datasetId: number | null) => void; @@ -87,6 +98,24 @@ export interface ChartBuilderState { * seeds the channel's default type and clears any prior transforms. */ setChannelColumn: (channel: ChannelName, columnName: string | null) => void; + /** + * Arm (or disarm with `null`) a channel slot to receive the next clicked field — + * the field-first assignment focus model. + */ + focusChannel: (channel: ChannelName | null) => void; + /** + * 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 + * channel can take it. Clears the armed slot. + */ + assignField: (columnName: string) => void; + /** + * 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 + * accept one; the raw input is coerced by channel (Size → number). Preserves the + * prior field type so toggling back to a field restores it. + */ + setChannelConstant: (channel: ChannelName, raw: string) => void; setChannelType: (channel: ChannelName, type: FieldType) => void; /** Set/clear a channel's aggregate (sum/mean/…); `undefined` clears it. */ setChannelAggregate: (channel: ChannelName, aggregate: AggregateOp | undefined) => void; @@ -146,11 +175,47 @@ function updateEncoding( return { config: { ...s.config, encodings: { ...s.config.encodings, [channel]: mapping } } }; } +/** + * The fresh mapping for a column placed on a channel: a field-less count for the + * sentinel, else the column with a channel-appropriate field type (its natural type if + * allowed here, else the first valid type that is — the Size guard). A fresh mapping + * carries no transforms. Shared by direct (`setChannelColumn`) and field-first + * (`assignField`) assignment so both land on the same mapping. + */ +function mappingForColumn( + s: ChartBuilderState, + channel: ChannelName, + columnName: string, +): ChannelMapping { + if (columnName === COUNT_FIELD) return { type: 'quantitative', aggregate: 'count' }; + const colType = columnType(effCols(s), columnName); + const valid = validFieldTypes(colType); + const type = valid.find((t) => isChannelTypeAllowed(channel, t)) ?? defaultFieldType(colType); + return { field: columnName, type }; +} + +/** + * Which channel a clicked field lands on (field-first assignment): the armed channel + * if it accepts the column, else the first empty channel that does, in canonical order. + * Returns null when nothing can take it (so a click is a no-op rather than clobbering a + * full set). The count sentinel reads as a measure for placement. + */ +function chooseTargetChannel(s: ChartBuilderState, columnName: string): ChannelName | null { + const colType: ColumnType = + columnName === COUNT_FIELD ? 'number' : columnType(effCols(s), columnName); + const accepts = (ch: ChannelName) => isColumnAllowedOnChannel(ch, colType); + const active = s.activeChannel; + if (active && accepts(active)) return active; + for (const ch of CHANNELS) if (!s.config.encodings[ch] && accepts(ch)) return ch; + return null; +} + export const useChartBuilderStore = create((set, get) => ({ datasetId: null, columns: EMPTY_COLUMNS, rowCount: null, config: EMPTY_CONFIG, + activeChannel: null, init: (datasetId) => { const dataset = @@ -158,7 +223,13 @@ export const useChartBuilderStore = create((set, get) => ({ ? undefined : useDatasetStore.getState().datasets.find((d) => d.id === datasetId); if (!dataset) { - set({ datasetId: null, columns: EMPTY_COLUMNS, rowCount: null, config: EMPTY_CONFIG }); + set({ + datasetId: null, + columns: EMPTY_COLUMNS, + rowCount: null, + config: EMPTY_CONFIG, + activeChannel: null, + }); return; } const columns: BuilderColumns = { @@ -171,6 +242,7 @@ export const useChartBuilderStore = create((set, get) => ({ columns, rowCount: dataset.rowCount, config: defaultBuilderConfig(dataset.name, columns), + activeChannel: null, }); }, @@ -178,26 +250,40 @@ export const useChartBuilderStore = create((set, get) => ({ setChannelColumn: (channel, columnName) => set((s) => { - let mapping: ChannelMapping | null; - if (columnName === null) { - mapping = null; - } else if (columnName === COUNT_FIELD) { - // The field-less "Count of records" measure. - mapping = { type: 'quantitative', aggregate: 'count' }; - } else { - // Default to the column's natural type, but if that type isn't allowed on - // this channel (e.g. a category on Size), fall back to the first valid type - // that is — the UI also disables unsuitable columns, this is the guard. - // Effective columns include calculated fields (which default to numeric). - const colType = columnType(effCols(s), columnName); - const valid = validFieldTypes(colType); - const type = - valid.find((t) => isChannelTypeAllowed(channel, t)) ?? defaultFieldType(colType); - mapping = { field: columnName, type }; // a fresh mapping clears prior transforms - } + // null = "None"; otherwise a fresh, transform-free mapping (count or typed field, + // its type clamped to one the channel allows). Effective columns include the + // config's calculated fields. + const mapping = columnName === null ? null : mappingForColumn(s, channel, columnName); return { config: { ...s.config, encodings: { ...s.config.encodings, [channel]: mapping } } }; }), + focusChannel: (channel) => set({ activeChannel: channel }), + + assignField: (columnName) => + set((s) => { + const target = chooseTargetChannel(s, columnName); + if (!target) return { activeChannel: null }; // nothing can take it → just disarm + const mapping = mappingForColumn(s, target, columnName); + return { + activeChannel: null, + config: { ...s.config, encodings: { ...s.config.encodings, [target]: mapping } }, + }; + }), + + setChannelConstant: (channel, raw) => + set((s) => { + if (!channelAcceptsValue(channel)) return s; // only Color/Size hold a constant + const current = s.config.encodings[channel]; + // Preserve the prior field type so a later toggle back to a field restores it; + // default by channel when there was none. A constant carries no field/transforms. + const type: FieldType = current?.type ?? (channel === 'size' ? 'quantitative' : 'nominal'); + const mapping: ChannelMapping = { value: coerceChannelValue(channel, raw), type }; + return { + activeChannel: null, + config: { ...s.config, encodings: { ...s.config.encodings, [channel]: mapping } }, + }; + }), + setChannelType: (channel, type) => set((s) => { const current = s.config.encodings[channel]; @@ -389,7 +475,13 @@ export const useChartBuilderStore = create((set, get) => ({ }, reset: () => - set({ datasetId: null, columns: EMPTY_COLUMNS, rowCount: null, config: EMPTY_CONFIG }), + set({ + datasetId: null, + columns: EMPTY_COLUMNS, + rowCount: null, + config: EMPTY_CONFIG, + activeChannel: null, + }), })); /** diff --git a/src/core/chart-builder.test.ts b/src/core/chart-builder.test.ts index 03cedc9..7dc458e 100644 --- a/src/core/chart-builder.test.ts +++ b/src/core/chart-builder.test.ts @@ -4,6 +4,7 @@ import { validFieldTypes, defaultMark, isChannelTypeAllowed, + isColumnAllowedOnChannel, supportsAggregate, supportsBin, supportsTimeUnit, @@ -13,6 +14,10 @@ import { builderWarnings, defaultBuilderConfig, isBuilderConfigValid, + isValueMapping, + channelAcceptsValue, + defaultChannelValue, + coerceChannelValue, buildChartSpec, buildSnippetSpecText, generateChartName, @@ -106,6 +111,23 @@ describe('isChannelTypeAllowed (Size discipline)', () => { }); }); +describe('isColumnAllowedOnChannel (field-shelf placement by default type)', () => { + it('lets Size take only a numeric column (its default type is the magnitude)', () => { + expect(isColumnAllowedOnChannel('size', 'number')).toBe(true); + expect(isColumnAllowedOnChannel('size', 'string')).toBe(false); + expect(isColumnAllowedOnChannel('size', 'date')).toBe(false); + expect(isColumnAllowedOnChannel('size', 'boolean')).toBe(false); + }); + + it('lets X/Y/Color take any column type', () => { + for (const ch of ['x', 'y', 'color'] as const) { + for (const t of ['number', 'string', 'date', 'boolean'] as const) { + expect(isColumnAllowedOnChannel(ch, t)).toBe(true); + } + } + }); +}); + describe('builderWarnings (Tier B advisories)', () => { it('warns when a line/area mark is missing an axis', () => { const w = builderWarnings({ @@ -690,6 +712,80 @@ describe('buildChartSpec', () => { }); }); +describe('value channels (constant colour / size — the Property model)', () => { + it('emits `{ value }` and ignores field/type/transforms when a constant is set', () => { + const spec = buildChartSpec({ + datasetName: 'D', + mark: 'point', + encodings: { + x: { field: 'category', type: 'nominal' }, + // A constant carries a (preserved-but-ignored) type and even a stale field; + // the assembler still emits only `{ value }`. + color: { value: '#c0392b', type: 'nominal', field: 'category', aggregate: 'sum' }, + size: { value: 100, type: 'quantitative' }, + }, + }); + const enc = spec.encoding as Record>; + expect(enc.color).toEqual({ value: '#c0392b' }); + expect(enc.size).toEqual({ value: 100 }); + }); + + it('counts a constant channel as mapped (saveable) and preserves channel order', () => { + const config: BuilderConfig = { + datasetName: 'D', + mark: 'point', + encodings: { color: { value: 'steelblue', type: 'nominal' } }, + }; + expect(isBuilderConfigValid(config)).toBe(true); + expect(Object.keys(buildChartSpec(config).encoding as object)).toEqual(['color']); + }); + + it('a constant Colour is not a series: no stacking, no area-split hint', () => { + const base: BuilderConfig = { + datasetName: 'D', + mark: 'bar', + encodings: { + x: { field: 'category', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + color: { value: '#4c78a8', type: 'nominal' }, + }, + }; + // A field-bound Colour would enable stacking and (on area) flag the split hint; + // a constant Colour produces neither — it draws every mark one fixed colour. + expect(supportsStack(base)).toBe(false); + const areaWarnings = builderWarnings({ ...base, mark: 'area' }); + expect(areaWarnings.some((w) => w.message.includes('per-series change'))).toBe(false); + + // Sanity: the same config with a *field* Colour does enable stacking. + const seriesColor: BuilderConfig = { + ...base, + encodings: { ...base.encodings, color: { field: 'category', type: 'nominal' } }, + }; + expect(supportsStack(seriesColor)).toBe(true); + }); + + it('describes the Property-model helpers', () => { + expect(isValueMapping({ value: 10, type: 'quantitative' })).toBe(true); + expect(isValueMapping({ field: 'value', type: 'quantitative' })).toBe(false); + + expect(channelAcceptsValue('color')).toBe(true); + expect(channelAcceptsValue('size')).toBe(true); + expect(channelAcceptsValue('x')).toBe(false); + expect(channelAcceptsValue('y')).toBe(false); + + expect(typeof defaultChannelValue('color')).toBe('string'); + expect(defaultChannelValue('size')).toBe(100); + }); + + it('coerces a constant value by channel (size → number, others → string)', () => { + expect(coerceChannelValue('size', '40')).toBe(40); + expect(coerceChannelValue('size', '')).toBe(''); // blank stays raw, not NaN + expect(coerceChannelValue('size', 'big')).toBe('big'); // non-numeric stays raw + expect(coerceChannelValue('color', '#ff0000')).toBe('#ff0000'); + expect(coerceChannelValue('color', 'red')).toBe('red'); + }); +}); + describe('transforms — aggregate / bin / timeUnit', () => { it('emits a field-less count encoding', () => { const spec = buildChartSpec({ diff --git a/src/core/chart-builder.ts b/src/core/chart-builder.ts index dd595d4..725c80e 100644 --- a/src/core/chart-builder.ts +++ b/src/core/chart-builder.ts @@ -152,15 +152,30 @@ export interface BuilderCalculate { } /** - * One channel's mapping: a dataset column `field` plus its `type`, with optional - * transforms. `field` is omitted only for a `count` aggregate (which counts records - * rather than reducing a column). A channel left on "None" is `null` in the config - * (omitted from the spec). + * One channel's binding. Two kinds share this shape — the **Property model**: one + * control that holds either a field or a constant. + * + * - **field** — a dataset column `field` plus its `type`, with optional transforms. + * `field` is omitted only for a `count` aggregate (which counts records rather + * than reducing a column). + * - **value** — a fixed constant (Vega-Lite `{ value }`): a literal colour or size + * applied to every mark, with no field/type/transform. `isValueMapping` + * discriminates on `value` being present; the channel's `type` is preserved (but + * ignored) while a value is set, so toggling back to a field restores it. + * + * A channel left on "None" is `null` in the config (omitted from the spec). */ export interface ChannelMapping { - /** The dataset column. Omitted only when `aggregate === 'count'`. */ + /** + * A constant value (Vega-Lite `{ value }`) — a fixed colour/size applied to every + * mark. When set, this channel is a CONSTANT: `field` and the transforms are not + * emitted (`encodingObject` returns `{ value }`). Only colour/size offer it in the + * UI (`channelAcceptsValue`), but the assembler handles it on any channel. + */ + value?: string | number | boolean; + /** The dataset column. Omitted for a `count` aggregate or when `value` is set. */ field?: string; - /** The Vega-Lite field type (see `validFieldTypes`). */ + /** The Vega-Lite field type (see `validFieldTypes`); ignored while `value` is set. */ type: FieldType; /** Aggregation op; `count` is field-less, the rest reduce a quantitative field. */ aggregate?: AggregateOp; @@ -250,6 +265,17 @@ export function isChannelTypeAllowed(channel: ChannelName, type: FieldType): boo return true; } +/** + * Whether a dataset column may be placed on a channel at all, judged by its **default** + * field type (spec §06 → Size discipline). X/Y/Color accept any column; Size accepts a + * column only when its natural type reads as a magnitude (a numeric → Quantitative). + * The channel's type control narrows further per field. Used to disable unsuitable + * columns in the field shelf and to pick an auto-assign target when a field is clicked. + */ +export function isColumnAllowedOnChannel(channel: ChannelName, columnType: ColumnType): boolean { + return isChannelTypeAllowed(channel, defaultFieldType(columnType)); +} + /** Whether a non-count aggregate (sum/mean/…) can apply to this field type. */ export function supportsAggregate(type: FieldType): boolean { return type === 'quantitative'; @@ -265,6 +291,50 @@ export function supportsTimeUnit(type: FieldType): boolean { return type === 'temporal'; } +/** + * Whether a channel mapping is a **constant value** (Vega-Lite `{ value }`) rather + * than a field binding — the discriminator of the Property model. A constant has no + * field, type, or transform; it colours/sizes every mark the same. + */ +export function isValueMapping(mapping: ChannelMapping): boolean { + return mapping.value !== undefined; +} + +/** + * Whether a channel offers the constant-value control in the UI. A fixed **colour** + * or **size** is both common and awkward in JSON (the promotion test), so it earns a + * control; a constant X/Y position is not useful, so X/Y stay field-only. The + * assembler emits a value on any channel — this only gates where the toggle appears, + * and generalizes to channels added later (e.g. opacity). + */ +export function channelAcceptsValue(channel: ChannelName): boolean { + return channel === 'color' || channel === 'size'; +} + +/** + * A sensible starting constant when a channel is first switched to value mode: + * Vega-Lite's default categorical blue for colour, a clearly-visible 100 for size. + * The user adjusts it from there. + */ +export function defaultChannelValue(channel: ChannelName): string | number { + return channel === 'size' ? 100 : '#4c78a8'; +} + +/** + * Coerce a constant-value text input to the JS type its channel expects, so the + * emitted `{ value }` carries the right type: **size** is a magnitude → a number (a + * blank/non-numeric entry falls back to the raw string rather than NaN); every other + * channel keeps the string (a colour is a CSS string). Mirrors the filter shelf's + * `coerceFilterValue` discipline. + */ +export function coerceChannelValue(channel: ChannelName, raw: string): string | number { + if (channel === 'size') { + const n = Number(raw); + return raw.trim() !== '' && Number.isFinite(n) ? n : raw; + } + return raw; +} + /** * The mark that best fits the X/Y field-type shape (spec §06 → Tier B, smart * default mark) — the research's strongest convergence (Draco mark-by-shape soft @@ -435,6 +505,8 @@ function effectiveType(mapping: ChannelMapping): FieldType { /** True when a mapping reads as a continuous measure (count/aggregate or continuous type). */ function isMeasureMapping(mapping: ChannelMapping): boolean { + // A constant value encodes no data, so it is never a measure. + if (isValueMapping(mapping)) return false; return isContinuous(effectiveType(mapping)); } @@ -467,11 +539,22 @@ export function sortableCategoryChannel(config: BuilderConfig): 'x' | 'y' | unde function stackMeasureChannel(config: BuilderConfig): 'x' | 'y' | undefined { for (const channel of ['x', 'y'] as const) { const mapping = config.encodings[channel]; - if (mapping && effectiveType(mapping) === 'quantitative') return channel; + if (mapping && !isValueMapping(mapping) && effectiveType(mapping) === 'quantitative') + return channel; } return undefined; } +/** + * Whether Color is bound to a **field** (a real per-value series + legend) rather + * than a constant. Stacking and the area-split hint care about a colour *series*; a + * fixed colour produces neither, so both treat a constant Color as no colour at all. + */ +function colorIsSeries(config: BuilderConfig): boolean { + const color = config.encodings.color; + return !!color && !isValueMapping(color); +} + /** * Whether sorting can be offered for this config (a clear category-vs-measure axis * pair exists). The UI shows the Sort control only when true. @@ -487,7 +570,7 @@ export function supportsSort(config: BuilderConfig): boolean { export function supportsStack(config: BuilderConfig): boolean { return ( (config.mark === 'bar' || config.mark === 'area') && - !!config.encodings.color && + colorIsSeries(config) && stackMeasureChannel(config) !== undefined ); } @@ -649,7 +732,7 @@ export function builderWarnings( // "seeing change in components can be very difficult"). // (Stacking turns overlapping series into a cumulative part-to-whole, a valid read, // so a stacked area is not flagged — applying the [Stack] fix below clears this.) - if (mark === 'area' && config.encodings.color && !config.stack) { + if (mark === 'area' && colorIsSeries(config) && !config.stack) { const fixes: BuilderWarningFix[] = []; if (supportsStack(config)) { fixes.push({ label: 'Stack', apply: (c) => ({ ...c, stack: 'zero' }) }); @@ -921,7 +1004,12 @@ export function pruneEncodings(config: BuilderConfig, base: BuilderColumns): Bui const encodings = { ...config.encodings }; for (const channel of CHANNELS) { const mapping = encodings[channel]; - if (mapping && mapping.field !== undefined && !available.has(mapping.field)) { + if ( + mapping && + !isValueMapping(mapping) && + mapping.field !== undefined && + !available.has(mapping.field) + ) { encodings[channel] = null; changed = true; } @@ -934,6 +1022,10 @@ export type ChartSpec = Record; /** Build one channel's Vega-Lite encoding object from its mapping + transforms. */ function encodingObject(mapping: ChannelMapping): Record { + // A constant value: `{ value }` — a fixed colour/size, no field/type/transform. + if (mapping.value !== undefined) { + return { value: mapping.value }; + } // A field-less count: `{ aggregate: 'count', type: 'quantitative' }`. if (mapping.aggregate === 'count') { return { aggregate: 'count', type: 'quantitative' }; @@ -1014,6 +1106,7 @@ function markLabel(mark: MarkType): string { /** A human phrase for what a channel encodes, e.g. "sum of revenue", "count". */ function describeMapping(mapping: ChannelMapping): string { + if (mapping.value !== undefined) return 'a constant'; if (mapping.aggregate === 'count') return 'count'; const field = mapping.field ?? ''; if (mapping.aggregate) return `${mapping.aggregate} of ${field}`;