Chart builder: field-first shelf + value-or-field channels

This commit is contained in:
2026-06-11 23:56:40 +03:00
parent 9ebe398e75
commit 4dcff4601d
11 changed files with 1192 additions and 184 deletions
@@ -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)
+26 -3
View File
@@ -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
+18 -16
View File
@@ -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_).
+30
View File
@@ -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.
+342 -57
View File
@@ -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;
}
@@ -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(<ChartBuilderModal />);
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(<ChartBuilderModal />);
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();
});
});
+313 -80
View File
@@ -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<ChannelName, string> = {
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<FieldType, string> = {
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';
// 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<SegmentedOption<FieldType>> = 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]);
const selectValue =
mapping === null ? '' : isCount(mapping) ? COUNT_FIELD : (mapping.field ?? '');
const clearLabel = `Remove ${CHANNEL_LABELS[channel]}`;
const clear = () => setChannelColumn(channel, null);
// A constant value (the Property model) — Colour or Size only.
if (isValueMapping(mapping)) {
const isColor = channel === 'color';
return (
<div className={styles.channel}>
<div className={styles.channelTop}>
<span className={styles.channelLabel}>{CHANNEL_LABELS[channel]}</span>
<select
className={styles.select}
aria-label={`${CHANNEL_LABELS[channel]} column`}
value={selectValue}
onChange={(e) => setChannelColumn(channel, e.target.value === '' ? null : e.target.value)}
>
<option value="">None</option>
<option value={COUNT_FIELD}>Count of records</option>
{columns.columns.map((name) => {
const allowed = columnAllowedOnChannel(channel, colTypeOf(name));
return (
<option key={name} value={name} disabled={!allowed}>
{name} · {typeBadge(colTypeOf(name))}
{allowed ? '' : ' (needs a measure)'}
</option>
<div className={`${styles.pill} ${styles.pillConst}`}>
<span className={styles.pillTag}>value</span>
{isColor ? (
<input
type="color"
className={styles.constColor}
aria-label={`${CHANNEL_LABELS[channel]} constant colour`}
value={typeof mapping.value === 'string' ? mapping.value : '#000000'}
onChange={(e) => setChannelConstant(channel, e.target.value)}
/>
) : (
<input
type="number"
className={styles.constNumber}
aria-label={`${CHANNEL_LABELS[channel]} constant size`}
value={String(mapping.value ?? '')}
onChange={(e) => setChannelConstant(channel, e.target.value)}
/>
)}
<button type="button" className={styles.pillRemove} aria-label={clearLabel} onClick={clear}>
<Icon name="close" />
</button>
</div>
);
})}
</select>
}
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 (
<div className={styles.pillWrap}>
<div className={styles.pill}>
{/* TODO(ux-second-pass): the type chip cycles N→O→Q→T — no direct pick for
keyboard/SR users. Cycle vs. explicit radio is parked for a batched council
review (docs/ux-second-pass.md). */}
<button
type="button"
className={styles.pillType}
aria-label={`Field type: ${titleCase(currentType)}${canCycle ? ' — activate to change' : ''}`}
disabled={!canCycle}
onClick={cycleType}
>
{TYPE_ABBR[currentType]}
</button>
<span className={styles.pillName} title={label}>
{label}
</span>
<button type="button" className={styles.pillRemove} aria-label={clearLabel} onClick={clear}>
<Icon name="close" />
</button>
</div>
{mapping && !isCount(mapping) && (
<div className={styles.channelControls}>
<SegmentedControl
label={`${CHANNEL_LABELS[channel]} field type`}
options={typeSegments}
value={mapping.type}
onChange={(t) => setChannelType(channel, t)}
className={styles.typeSeg}
/>
{hasTransforms && (
<div className={styles.pillControls}>
{supportsAggregate(mapping.type) && (
<label className={styles.transform}>
<span className={styles.miniLabel}>Aggregate</span>
@@ -344,6 +385,203 @@ function ChannelBlock({ channel }: { channel: ChannelName }) {
);
}
/**
* One encoding target. When the channel is bound it shows its `ChannelPill`; when empty
* it is an **assign target** — a button that arms the channel (field-first: click it,
* then click a field in the shelf to fill it), plus an "or constant" affordance on the
* channels that take a fixed value (Colour/Size). `hint` is the empty-state prompt.
*/
function ChannelSlot({ channel, hint }: { channel: ChannelName; hint?: string }) {
const baseColumns = useChartBuilderStore((s) => s.columns);
const calculates = useChartBuilderStore((s) => s.config.calculates);
const columns = useMemo(
() => effectiveColumns(baseColumns, calculates),
[baseColumns, calculates],
);
const mapping = useChartBuilderStore((s) => s.config.encodings[channel] ?? null);
const active = useChartBuilderStore((s) => s.activeChannel === channel);
const focusChannel = useChartBuilderStore((s) => s.focusChannel);
const setChannelConstant = useChartBuilderStore((s) => s.setChannelConstant);
if (mapping) {
return <ChannelPill channel={channel} mapping={mapping} columns={columns} />;
}
return (
<div className={`${styles.slot} ${active ? styles.slotActive : ''}`}>
<button
type="button"
className={styles.slotAssign}
aria-pressed={active}
aria-label={`${CHANNEL_LABELS[channel]}: ${
active ? 'pick a field from the list' : 'arm to assign a field'
}`}
onClick={() => focusChannel(active ? null : channel)}
>
{active ? 'Pick a field…' : (hint ?? 'Add a field')}
</button>
{channelAcceptsValue(channel) && (
<button
type="button"
className={styles.slotConst}
onClick={() => setChannelConstant(channel, String(defaultChannelValue(channel)))}
>
or constant
</button>
)}
</div>
);
}
/**
* 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
* glyph. Clicking a field assigns it to the armed channel, else the first empty channel
* that accepts it (`assignField`). Past `FIELD_SHELF_SPLIT_MIN` columns it 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() {
const baseColumns = useChartBuilderStore((s) => s.columns);
const calculates = useChartBuilderStore((s) => s.config.calculates);
const encodings = useChartBuilderStore((s) => s.config.encodings);
const assignField = useChartBuilderStore((s) => s.assignField);
const columns = useMemo(
() => effectiveColumns(baseColumns, calculates),
[baseColumns, calculates],
);
const assigned = useMemo(() => {
const set = new Set<string>();
for (const ch of CHANNELS) {
const m = encodings[ch];
if (m?.field) set.add(m.field);
}
return set;
}, [encodings]);
const colTypeOf = (name: string): ColumnType =>
columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
const fieldButton = (name: string) => (
<button
key={name}
type="button"
className={`${styles.shelfField} ${assigned.has(name) ? styles.shelfFieldUsed : ''}`}
onClick={() => assignField(name)}
>
<span className={styles.shelfGlyph} aria-hidden="true">
{TYPE_ABBR[defaultFieldType(colTypeOf(name))]}
</span>
<span className={styles.shelfFieldName}>{name}</span>
</button>
);
const countButton = (
<button
key="__count"
type="button"
className={styles.shelfField}
onClick={() => assignField(COUNT_FIELD)}
>
<span className={styles.shelfGlyph} aria-hidden="true">
</span>
<span className={styles.shelfFieldName}>Count of records</span>
</button>
);
const dimensions = columns.columns.filter((n) => colTypeOf(n) !== 'number');
const measures = columns.columns.filter((n) => colTypeOf(n) === 'number');
const split =
columns.columns.length >= FIELD_SHELF_SPLIT_MIN && dimensions.length > 0 && measures.length > 0;
return (
<div className={styles.fieldShelf}>
<span className={styles.fieldLabel}>Fields</span>
{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}>
{columns.columns.map(fieldButton)}
{countButton}
</div>
)}
</div>
);
}
/** The non-positional encodings (Colour, Size) — Tableau's "Marks" card. Each is a
* `ChannelSlot`, so it takes a field or a constant. */
function MarksCard() {
return (
<div className={styles.marksCard}>
<span className={styles.fieldLabel}>Marks</span>
<div className={styles.marksRow}>
<span className={styles.channelLabel}>{CHANNEL_LABELS.color}</span>
<ChannelSlot channel="color" />
</div>
<div className={styles.marksRow}>
<span className={styles.channelLabel}>{CHANNEL_LABELS.size}</span>
<ChannelSlot channel="size" />
</div>
</div>
);
}
/** A reserved, non-interactive shelf slot for faceting (small multiples) — Phase 4.
* Shown so the layout telegraphs where row/column faceting will live. */
function FacetSlot({ kind }: { kind: 'column' | 'row' }) {
return (
<div className={styles.facetSlot} title="Faceting → small multiples (coming later)">
<span>+ {kind} facet</span>
<span className={styles.facetTag}>later</span>
</div>
);
}
/**
* The on-chart **Columns** (X) and **Rows** (Y) shelves stacked above the preview,
* Tableau-style — position is a property of the chart, so its controls sit on the
* chart. Each shelf holds the axis slot plus a reserved faceting placeholder. A Swap
* X/Y action flips the two axes.
*/
function OnChartShelves() {
const swapXY = useChartBuilderStore((s) => s.swapXY);
return (
<div className={styles.shelves}>
<div className={styles.shelvesHead}>
<span className={styles.fieldLabel}>Axes</span>
<button type="button" className={styles.swap} onClick={swapXY}>
Swap X/Y
</button>
</div>
<div className={styles.shelfStrip}>
<span className={styles.shelfName}>Columns</span>
<div className={styles.shelfSlots}>
<ChannelSlot channel="x" hint="Add a field for the X axis" />
<FacetSlot kind="column" />
</div>
</div>
<div className={styles.shelfStrip}>
<span className={styles.shelfName}>Rows</span>
<div className={styles.shelfSlots}>
<ChannelSlot channel="y" hint="Add a field for the Y axis" />
<FacetSlot kind="row" />
</div>
</div>
</div>
);
}
/**
* Inline feedback for an expression input (filter expression / calculated field):
* a parse error takes priority, else a soft warning for `datum.<field>` references
@@ -881,7 +1119,6 @@ export function ChartBuilderModal() {
const sort = useChartBuilderStore((s) => s.config.sort);
const stack = useChartBuilderStore((s) => s.config.stack);
const setMark = useChartBuilderStore((s) => s.setMark);
const swapXY = useChartBuilderStore((s) => s.swapXY);
const setSort = useChartBuilderStore((s) => s.setSort);
const setStack = useChartBuilderStore((s) => s.setStack);
const setWidth = useChartBuilderStore((s) => s.setWidth);
@@ -961,17 +1198,8 @@ export function ChartBuilderModal() {
/>
</div>
<div className={styles.channels}>
<div className={styles.channelsHeader}>
<span className={styles.fieldLabel}>Encoding</span>
<button type="button" className={styles.swap} onClick={swapXY}>
Swap X/Y
</button>
</div>
{CHANNELS.map((channel) => (
<ChannelBlock key={channel} channel={channel} />
))}
</div>
<FieldShelf />
<MarksCard />
{(canSort || canStack) && (
<div className={styles.chartControls}>
@@ -1001,7 +1229,9 @@ export function ChartBuilderModal() {
)}
<div className={styles.dimensions}>
<span className={styles.fieldLabel}>Size (optional)</span>
{/* "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>
@@ -1081,7 +1311,10 @@ export function ChartBuilderModal() {
</div>
</div>
<div className={styles.previewSide}>
<OnChartShelves />
<BuilderPreview />
</div>
</div>
);
}
+78
View File
@@ -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
});
});
+111 -19
View File
@@ -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<ChartBuilderState>((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<ChartBuilderState>((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<ChartBuilderState>((set, get) => ({
columns,
rowCount: dataset.rowCount,
config: defaultBuilderConfig(dataset.name, columns),
activeChannel: null,
});
},
@@ -178,26 +250,40 @@ export const useChartBuilderStore = create<ChartBuilderState>((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<ChartBuilderState>((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,
}),
}));
/**
+96
View File
@@ -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<string, Record<string, unknown>>;
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({
+103 -10
View File
@@ -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<string, unknown>;
/** Build one channel's Vega-Lite encoding object from its mapping + transforms. */
function encodingObject(mapping: ChannelMapping): Record<string, unknown> {
// 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}`;