Chart builder: intent front door, Heatmap mark, role-aware guidance

This commit is contained in:
2026-06-13 12:08:11 +03:00
parent 4e5108f434
commit 0470389b41
11 changed files with 1129 additions and 37 deletions
+11 -1
View File
@@ -134,9 +134,19 @@ Review all changes in scope. If changes span multiple patterns below, apply all
case, active voice, no "please", no exclamation marks in errors). If/when an i18n layer
exists, route strings through it instead of hardcoding.
15. **Chart-builder guidance reasons over role, not raw type** (`src/core/chart-builder.ts`):
a `builderWarnings` rule (or any measure/dimension decision) must ask the post-transform
**role** via the shared predicates (`isMeasureMapping`, `isReorderableCategory`) — never
test `effectiveType(m) === 'quantitative'` directly for measure-ness. `bin` makes a field a
discretized _dimension_ (mirrors Vega-Lite's `isDiscrete`); `aggregate` makes it a _measure_.
Reasoning over raw type is what made a histogram trip the two-measures→scatter nudge
(eng-council 2026-06-13; arch 10 §5). A new taste-heuristic warning should also be
high-precision: prefer structural/data-driven hints; lean on the intent front door + smart
defaults for positive guidance rather than enumerating bad combinations.
### Output
15. **Summary**: respond with a summary of changes — choices made due to these instructions,
16. **Summary**: respond with a summary of changes — choices made due to these instructions,
choices where multiple approaches existed, and non-obvious architectural assumptions the
user should know but might not spot in the diff. If the summary mentions an observation you
chose not to fix (rule #8), confirm a `// TODO:` breadcrumb was placed at the code site.
@@ -504,6 +504,27 @@ _acts_ rather than navigates is mis-dressed: such actions are **ghost buttons**
verb-first labels (Carbon links-vs-buttons; "Use a constant"). _(Consulted via /council →
NN/g #1/#3/#4, Carbon button/link usage. This bullet is the contract.)_
**Resolved — the intent front door is an APG toolbar of toggle chips (Tier C "do it for me").**
The Chart Builder's _"What do you want to show?"_ strip (spec §06 → Intent) is a **WAI-ARIA
`toolbar`** (one tab stop, roving tabindex, `aria-labelledby` the visible heading) of chips —
**not** seven independently-tabbable buttons (the pane-toggle precedent above). Each chip is a
**toggle button** (`aria-pressed`) whose pressed state is **derived from the configuration**
(the chip whose recommended layout the live chart matches), never stored — so a hand-edit
resolves to "Custom" (none pressed) for free. Arrow keys **move focus only**; **Enter/Space
applies** — because applying an intent reshapes the whole chart, a radiogroup's select-on-arrow
would do that on every keypress (so this is a toolbar, not a `SegmentedControl` radiogroup).
Selection shows as an **accent ring, never an accent fill** (fill stays the primary-action
signal — arch 09 §3.3). Intents the dataset can't satisfy are **disabled via `aria-disabled`
and kept arrow-reachable** (APG: focusable disabled controls "where discoverability of a
function is crucial"), with the reason in the chip's accessible name (`aria-label`
"Correlation — needs two number columns") plus a `title` for sighted hover — **never hidden**
(Tableau _Show Me_). The chips are framed as **intents, not chart shapes** (FT Visual
Vocabulary / Datawrapper organize by intent); _Heatmap_ is the one chart-type label retained
— a deliberate divergence for recognizability that also mirrors the mark selector's _Heatmap_
label, so the intent and the mark read as one thing (revisit if it confuses). _(Consulted via /council →
APG toolbar + button(toggle); FT Visual Vocabulary / Datawrapper intent framing; NN/g #6
recognition. This bullet is the contract.)_
**Resolved — an error names the right fix, not a boilerplate one.** Don't staple a generic
remedy onto every failure. A missing dataset reference is **not** a JSON/spec syntax problem,
so the preview gives it a tailored, fixable line — _"Dataset «X» not found. Create it from
+46 -2
View File
@@ -25,6 +25,49 @@
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-13 (guidance: reason over role, not raw type)** — closed a **false-positive class**
in `builderWarnings` (eng-council + council consult). A histogram (bar, binned-Q X, count Y)
tripped "two measures → scatter" because `isMeasureMapping`/`effectiveType` ignored `bin`.
Fix: `isMeasureMapping` is now **bin-aware** (a binned field is a discretized dimension —
mirrors Vega-Lite's own `isDiscrete(fieldDef)`); a new `isReorderableCategory` keeps Sort
from being offered on a histogram's binned axis (the council-flagged regression); the scatter
rule routes through the role predicate + a positive mark list (deletes the `mark !== 'rect'`
bolt-on); `stackMeasureChannel` excludes binned. Pinned with regression tests (no
scatter/Sort on a histogram; real bar still sorts; two raw measures still nudge). Promoted to
**/alignment check #15** ("reason over role, not raw type"). **Not done (deliberately):**
intent-gating the taste warnings — verified no current intent layout trips one, so the gate
would be dead code; recorded as a standing principle in `builderWarnings` instead. The
broader posture (curate good via the front door > enumerate bad via warnings) is the council's
recommended bottom for the combinatorial-warning worry. Verified: typecheck + test (913) +
eslint + build.
- **2026-06-13 (3A + heatmap mark)** — **the intent-first front door shipped (Tier C), and
the mark set gained `rect` (Heatmap).**
- **Heatmap mark** — `rect` added to `MARK_TYPES` (six marks; labelled **Heatmap** in the
picker, which now wraps to two rows in the 320360px pane). Guidance rewired: both-axes
rule covers heatmaps; a new hint nudges a Colour measure on a two-axis heatmap with a
one-click **Colour by count**; `rect` is exempt from the two-measures→scatter nudge (a
binned 2-D histogram is valid). Never the auto-default mark; names read "Heatmap of …".
Spec §06 mark-type + guidance updated.
- **3A intent front door** — a **persistent strip** at the top of the config pane (the
interaction model chosen with the user over a replace-on-open screen — "shows all
controls + a do-it-for-me", the Tableau _Show Me_ parallel). Core (`chart-builder.ts`):
`CHART_INTENTS` (Compare/Ranking/Change-over-time/Correlation/Distribution/Part-to-whole/
Heatmap), `intentLayout` (intent × column-roles → mark + channels), `intentApplicable`
(Show-Me gating), `applyIntent` (reshape, keep dataset/transforms/title), and
`activeIntent` (structural match → the live chart's intent highlights with **no stored
state**; lights the smart default on open). Store: `setIntent`. UI: an APG **toolbar** of
toggle chips (roving tabindex, arrows move / Enter applies, accent-ring selection,
`aria-disabled`+reason on inapplicable intents).
- **Council** run on the front-door copy/flow → toolbar (not radiogroup — select-on-arrow
would reshape the chart), disabled-focusable-with-reason, intent-framing (Heatmap kept as
the one chart-type label, mirroring the mark). Recorded in `architecture/10` §5; spec §06
gained an **Intent (the front door)** subsection.
- **Verified:** `typecheck` + `test` (909, +17: intent core/store, heatmap guidance/naming)
- `eslint` + `build`. **Owed:** a visual pass on the live strip (chips, disabled states,
keyboard) + a heatmap rendered from real data. **Open (user's call):** whether the
_Heatmap_ chip should read as an intent phrase instead (council's intent-framing point).
- **2026-06-12 (3D)** — **entry points & discoverability shipped. Up next: 3A (the
front door now has somewhere to be found).**
- **Library creation surface forked:** primary **Build Chart** (accent, takes the
@@ -557,9 +600,10 @@ Phase 1 1A actionable hints ✓ done
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 3D entry points & discoverability ✓ done (2026-06-12)
3A intent-first front door (Tier C) ← next; built on 2B; the defining feature
3B starter examples
3A intent-first front door (Tier C) ✓ done (2026-06-13); persistent strip
3B starter examples ← next; pairs with 3C
3C open in builder (strict hydration) ← added 2026-06-11; pairs with 3B
Marks +rect (Heatmap) ✓ done (2026-06-13)
Phase 4 (gated) theta/facets/styling-overrides/undo/lookup — decide after Phase 3
Also shipped (builder UX/perf, from dogfooding): near-fullscreen modal, internal-scroll
+17 -5
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 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.
> **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). On top of this "smart + guarded" base sits the **intent-first front door** (Tier C — _what do you want to show?_, see _Intent_ below): an on-ramp that recommends a whole chart from the user's stated intent, without replacing the mark-first builder beneath it.
## Opening
@@ -25,6 +25,17 @@ Which data the chart builds from is itself a builder choice. A **Dataset** picke
With an empty dataset library the builder shows a **no-datasets state** instead of controls: it says what the builder does and offers one primary next step — **Add a dataset**, which opens the _Datasets_ manager on its create form. The guided path never dead-ends.
### Intent (the front door)
A persistent **"What do you want to show?"** strip sits at the top of the configuration pane, under the dataset picker — the builder's guided on-ramp (Tier C). It offers a small set of analytic **intents**, each of which, when chosen, **sets the whole chart up for you** ("do it for me", modelled on Tableau's _Show Me_):
- The intents map the FT Visual Vocabulary / Datawrapper taxonomy onto the builder's marks and channels: **Compare** (magnitude across categories → a bar of counts), **Ranking** (the same, sorted by value), **Change over time** (a line of the first measure over a date), **Correlation** (a scatter of two measures), **Distribution** (a histogram — a binned measure vs count), **Part-to-whole** (a stacked bar split by a second category), and **Heatmap** (a two-category grid shaded by count).
- Picking an intent reshapes the chart — its **mark, encodings, and sort/stack** — to that intent's recommended layout, derived from the dataset's column roles. It **keeps** the dataset, the data transforms (filters / calculated fields), and the chart properties (title/subtitle/size): those are orthogonal to _what kind of chart_.
- The strip is an **on-ramp, not a gate**: it seeds the mark-first builder, which the user can then adjust freely or ignore entirely (and can always drop to Monaco). The chosen intent is builder-local steering — it **never enters the produced spec** (the JSON stays the document).
- On open, the strip **pre-highlights the intent matching the data-aware default** (so a category-vs-count default opens on _Compare_). The highlight is **derived from the configuration, not stored**: the chip whose recommended layout the live chart currently matches stays highlighted; once the user edits away from any recommended layout, none is highlighted — a **Custom** chart.
- Intents the dataset **cannot satisfy** are **disabled** (Tableau _Show Me_): a _Correlation_ needs two number columns, a _Heatmap_ or _Part-to-whole_ needs two category columns, _Change over time_ needs a date, and so on. A disabled chip stays perceivable and carries the reason in its accessible name; it is never hidden.
- Keyboard/focus follow [`architecture/10`](../architecture/10-interaction-and-feedback.md) §5 (an APG **toolbar**: one tab stop, a roving tabindex, arrow keys move focus, Enter/Space applies the intent — so navigation never reshapes the chart by accident).
## Layout
A two-pane modal:
@@ -68,8 +79,8 @@ The section is ordered **input → shaping** so the distinction reads at a glanc
### Mark type
- Single selection from an exact set of five mark types: **Bar, Line, Point, Area, Circle**.
- On open, the mark **defaults to the type that best fits the pre-populated X/Y field-type shape** (Tier B smart default): a temporal axis against a measure → **Line**; two measures → **Point**; a category against a measure → **Bar**; two categories → **Point**; and **Bar** as the fallback when only one axis (or none) is mapped. The user can switch to any of the five afterward.
- Single selection from an exact set of six mark types: **Bar, Line, Point, Area, Circle, Heatmap**. **Heatmap** is the Vega-Lite `rect` mark — an X×Y grid of cells shaded by a Colour measure; it is labelled by the chart it makes rather than its geometry, since "Rect" is opaque to the no-JSON audience.
- On open, the mark **defaults to the type that best fits the pre-populated X/Y field-type shape** (Tier B smart default): a temporal axis against a measure → **Line**; two measures → **Point**; a category against a measure → **Bar**; two categories → **Point**; and **Bar** as the fallback when only one axis (or none) is mapped. **Heatmap is never the auto-default** — it reads only with a Colour measure, which the X/Y shape alone can't determine, so it stays a deliberate pick (guidance nudges the missing Colour). The user can switch to any of the six afterward.
- Exactly one mark type is active at any time; selecting one updates the preview.
### Encoding channels
@@ -113,9 +124,10 @@ These controls appear only when they apply:
The builder surfaces short, plain-language hints for configurations that render but read poorly — advisory only, never blocking the **Create Snippet** action (validation below is the sole gate). A hint states the _problem_; where there is an obvious remedy, it also offers one or more **one-click fix** buttons that apply the change to the configuration (e.g. _Aggregate as Sum_, _Swap X/Y_, _Switch to Point_, _Stack_, _Remove colour_). A fix is an offer, never a forced change — applying it updates the config and the hint re-derives away. Interaction/accessibility of these actions follows [`architecture/10`](../architecture/10-interaction-and-feedback.md) §5 (polite announcement, focus moved off the removed button). These follow the chart-choice research ([`docs/chart-builder-research.md`](../chart-builder-research.md)) and include, for example:
- A **Line** or **Area** mark with only one axis mapped (both axes are needed to draw it).
- A **Line**, **Area**, or **Heatmap** mark with only one axis mapped (both axes are needed to draw it).
- A **Bar/Line/Area** whose X and Y are both categories (nothing to measure).
- **Two measures** on a non-scatter mark (a scatter — Point/Circle — usually reads better).
- A **Heatmap** with both axes mapped but **no measure on Colour** (its cells have nothing to shade) — offers a one-click _Colour by count_, the canonical cross-tab heatmap. _Two measures on a heatmap are exempt from the scatter nudge below: a binned 2-D histogram is two quantitative axes shaded by count._
- **Two measures** on a non-scatter mark (a scatter — Point/Circle — usually reads better; Heatmap excepted).
- An **Area** chart split into multiple colour series (per-series change is hard to see).
- A **Bar/Line/Area** that pairs a category axis with a **raw (un-aggregated) measure** over a many-row dataset — it draws one mark, and one axis label, per row, so the category axis becomes an unreadable picket fence. The hint suggests aggregating the measure (one mark per category) or, for a bar, flipping to a horizontal bar (Swap X/Y) where long labels stay readable (FT Visual Vocabulary / Datawrapper). Only the un-aggregated case (mark-count = row-count) is detected; flagging an _aggregated_ axis that still has many distinct categories needs per-column distinct counts the profiler does not yet compute (a known gap).
@@ -58,6 +58,79 @@
gap: var(--space-3);
}
/* Intent front door (spec §06 Intent)
A persistent "what do you want to show?" strip under the dataset picker. A
quiet accent-soft wash marks it as the guided on-ramp without competing with
the primary Create action (--accent stays reserved for primary actions). */
.intentStrip {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-4);
border: var(--border-width) solid var(--border);
background: var(--accent-soft);
}
.intentQ {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.intentSub {
margin: 0;
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary);
}
.intentChips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-1);
}
.intentChip {
appearance: none;
height: var(--control-height);
border: var(--border-width) solid var(--border-strong);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 12px;
padding: 0 var(--space-3);
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
box-shadow var(--dur-fast) var(--ease);
}
.intentChip:hover {
background: var(--field);
}
/* The chip whose layout the live chart matches (council-reserved: selection uses an
accent ring, never an accent fill fill stays the primary-action signal). */
.intentChip[aria-pressed='true'] {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
/* Disabled = the dataset can't satisfy this intent: perceivable but inert (the chip's
accessible name carries the reason). */
.intentChip[aria-disabled='true'] {
color: var(--text-placeholder);
border-color: var(--border);
background: var(--bg);
cursor: not-allowed;
}
.intentChip:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
/* ── Data section: filters, calculated fields, row preview (spec §06 → Data) ──── */
.dataSection {
@@ -346,6 +419,18 @@
gap: var(--space-2);
}
/* The mark picker carries six segments too many for one 320360px row. Override the
SegmentedControl's fixed single-row height so it wraps; each option keeps the control
height so the two rows read as even segments inside the shared border. */
.markPicker {
flex-wrap: wrap;
height: auto;
}
.markPickerOption {
min-height: var(--control-height);
}
.fieldLabel {
font-size: 12px;
font-weight: 500;
@@ -475,4 +475,117 @@ describe('ChartBuilderModal', () => {
// A colour picker renders for the constant.
expect(container.querySelector('input[type="color"]')).not.toBeNull();
});
// The intent front door's core logic (which chip lights, what each applies, gating)
// is covered in core/store; these check only the parts that live in the component —
// the toolbar's roving tabindex and arrow navigation (a hand-rolled handler, not a
// shared primitive), and that a click reshapes the chart.
describe('intent front door (the "What do you want to show?" strip)', () => {
// Two categories + one date + two measures → every intent applies; enough shape to
// light a default and to exercise an applied intent (Heatmap needs two categories).
const seedSuperstore = () => {
const ds = createDataset({
name: 'Superstore',
data: [
{ region: 'E', segment: 'A', date: '2026-01-01', sales: 5, profit: 1 },
{ region: 'W', segment: 'B', date: '2026-02-01', sales: 9, profit: 3 },
],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
useChartBuilderStore.getState().init(id);
return id;
};
const chips = (): HTMLButtonElement[] =>
Array.from(container.querySelectorAll<HTMLButtonElement>('[role="toolbar"] button'));
test('renders one tab stop and lights the active intent (roving tabindex)', async () => {
seedSuperstore();
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const all = chips();
expect(all.length).toBe(7); // every intent shows (Tableau Show Me: never hidden)
// Exactly one chip is in the tab order; the rest are roving (-1).
expect(all.filter((b) => b.tabIndex === 0)).toHaveLength(1);
// The smart default for a category+count shape is Compare, so its chip is pressed.
const pressed = all.filter((b) => b.getAttribute('aria-pressed') === 'true');
expect(pressed).toHaveLength(1);
expect(pressed[0].textContent).toBe('Compare');
});
test('an inapplicable intent is disabled and names its reason', async () => {
// One category, one measure, no date and no second measure → Correlation/Time/
// Heatmap/Part-to-whole cannot apply.
const ds = createDataset({
name: 'Thin',
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();
});
const correlation = chips().find((b) => b.textContent === 'Correlation')!;
expect(correlation.getAttribute('aria-disabled')).toBe('true');
expect(correlation.getAttribute('aria-label')).toMatch(/needs two number columns/);
});
test('clicking an enabled chip reshapes the chart to that intent', async () => {
seedSuperstore();
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const heatmap = chips().find((b) => b.textContent === 'Heatmap')!;
await act(async () => {
heatmap.click();
await Promise.resolve();
});
expect(useChartBuilderStore.getState().config.mark).toBe('rect');
expect(useChartBuilderStore.getState().config.encodings.color).toEqual({
type: 'quantitative',
aggregate: 'count',
});
});
test('ArrowRight moves focus along the toolbar without applying (focus-only)', async () => {
seedSuperstore();
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const all = chips();
const start = all.findIndex((b) => b.tabIndex === 0);
const markBefore = useChartBuilderStore.getState().config.mark;
await act(async () => {
all[start].focus();
all[start].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
);
await Promise.resolve();
});
// Focus moved to the next chip; the chart is untouched (arrows navigate, Enter applies).
expect(document.activeElement).toBe(all[(start + 1) % all.length]);
expect(useChartBuilderStore.getState().config.mark).toBe(markBefore);
});
});
});
+152 -1
View File
@@ -23,14 +23,17 @@ import { useShallow } from 'zustand/react/shallow';
import type { VisualizationSpec } from 'vega-embed';
import {
CHANNELS,
CHART_INTENTS,
MARK_TYPES,
TIME_UNITS,
activeIntent,
builderWarnings,
channelAcceptsValue,
defaultChannelValue,
defaultFieldType,
effectiveColumns,
filterOpArity,
intentApplicable,
isBuilderConfigValid,
isChannelTypeAllowed,
isColumnAllowedOnChannel,
@@ -50,6 +53,7 @@ import {
type BuilderWarningFix,
type ChannelMapping,
type ChannelName,
type ChartIntent,
type FieldType,
type FilterOp,
type MarkType,
@@ -113,11 +117,46 @@ function titleCase(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/** User-facing mark labels. All but `rect` are their geometry name title-cased; `rect`
* is shown as **Heatmap** the chart it makes, since "Rect" is opaque to the no-JSON
* audience the builder serves (the spec's mark-set table notes the rectheatmap pairing). */
const MARK_LABELS: Record<MarkType, string> = {
bar: 'Bar',
line: 'Line',
point: 'Point',
area: 'Area',
circle: 'Circle',
rect: 'Heatmap',
};
const MARK_OPTIONS: ReadonlyArray<SegmentedOption<MarkType>> = MARK_TYPES.map((m) => ({
value: m,
label: titleCase(m),
label: MARK_LABELS[m],
title: m === 'rect' ? 'Heatmap (rect mark)' : undefined,
}));
/** Intent front-door copy (spec §06 Intent). Label = the chip; needs = what the
* dataset must have for the intent to apply (shown when the chip is disabled). */
const INTENT_LABELS: Record<ChartIntent, string> = {
compare: 'Compare',
ranking: 'Ranking',
time: 'Change over time',
correlation: 'Correlation',
distribution: 'Distribution',
partToWhole: 'Part-to-whole',
heatmap: 'Heatmap',
};
const INTENT_NEEDS: Record<ChartIntent, string> = {
compare: 'a category column',
ranking: 'a category column',
time: 'a date column',
correlation: 'two number columns',
distribution: 'a number column',
partToWhole: 'two category columns',
heatmap: 'two category columns',
};
const CHANNEL_LABELS: Record<ChannelName, string> = {
x: 'X',
y: 'Y',
@@ -1277,6 +1316,112 @@ function DatasetPicker({ datasetId }: { datasetId: number | null }) {
);
}
/**
* The intent-first front door (spec §06 Intent): a persistent "what do you want to
* show?" strip pinned under the dataset picker. Each chip applies a recommended mark +
* channel layout the "do it for me" (Tableau "Show Me"). Intents the dataset can't
* satisfy are disabled (with the reason in their name), and the chip whose layout the
* live chart matches stays highlighted; once the user edits away from any, none is a
* "Custom" chart. It seeds the builder; it never gates it (the user can ignore it and
* drive the channels directly). The active intent is derived from the config, so no
* intent state is stored.
*/
function IntentStrip() {
const columns = useChartBuilderStore((s) => s.columns);
const config = useChartBuilderStore((s) => s.config);
const setIntent = useChartBuilderStore((s) => s.setIntent);
const active = useMemo(() => activeIntent(config, columns), [config, columns]);
const applicable = useMemo(
() => new Set(CHART_INTENTS.filter((i) => intentApplicable(i, columns))),
[columns],
);
// APG toolbar (arch 10 §5): the chips are a single tab stop with a roving tabindex,
// NOT seven independently-tabbable buttons (the pane-toggle precedent). Arrows MOVE
// focus only — Enter/Space activates — because applying an
// intent reshapes the whole chart; a radiogroup's select-on-arrow would do that on
// every keypress. Disabled chips stay arrow-reachable so their "needs …" reason is
// discoverable (APG: focusable disabled controls where discoverability is crucial).
const btnRefs = useRef<Array<HTMLButtonElement | null>>([]);
const tabStop = useMemo(() => {
if (active) return CHART_INTENTS.indexOf(active); // an active intent is always enabled
const firstEnabled = CHART_INTENTS.findIndex((i) => applicable.has(i));
return firstEnabled >= 0 ? firstEnabled : 0;
}, [active, applicable]);
const onKeyDown = (e: React.KeyboardEvent, index: number) => {
const n = CHART_INTENTS.length;
let next: number;
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
next = (index + 1) % n;
break;
case 'ArrowLeft':
case 'ArrowUp':
next = (index - 1 + n) % n;
break;
case 'Home':
next = 0;
break;
case 'End':
next = n - 1;
break;
default:
return; // not ours — let it bubble (Enter/Space activate the native button)
}
e.preventDefault();
btnRefs.current[next]?.focus();
};
return (
<div className={styles.intentStrip}>
<span className={styles.intentQ} id="cb-intent-q">
What do you want to show?
</span>
<p className={styles.intentSub}>
{active ? (
<>
Starting point: <strong>{INTENT_LABELS[active]}</strong>. Pick another, or adjust the
controls below.
</>
) : (
<>Pick a starting point and we will set the chart up or build it yourself below.</>
)}
</p>
<div className={styles.intentChips} role="toolbar" aria-labelledby="cb-intent-q">
{CHART_INTENTS.map((intent, i) => {
const enabled = applicable.has(intent);
return (
<button
key={intent}
ref={(el) => {
btnRefs.current[i] = el;
}}
type="button"
className={styles.intentChip}
aria-pressed={active === intent}
aria-disabled={!enabled || undefined}
aria-label={
enabled ? undefined : `${INTENT_LABELS[intent]} — needs ${INTENT_NEEDS[intent]}`
}
title={enabled ? undefined : `Needs ${INTENT_NEEDS[intent]}`}
tabIndex={i === tabStop ? 0 : -1}
onKeyDown={(e) => onKeyDown(e, i)}
onClick={() => {
if (enabled) setIntent(intent);
}}
>
{INTENT_LABELS[intent]}
</button>
);
})}
</div>
</div>
);
}
/**
* The no-datasets state (spec §06 Opening; Carbon no-data empty state): says
* what the builder does and offers the one next step never a dead end. The
@@ -1377,6 +1522,8 @@ export function ChartBuilderModal() {
</div>
<DatasetPicker datasetId={datasetId} />
<IntentStrip />
<DataSection />
<div className={styles.field}>
@@ -1386,6 +1533,10 @@ export function ChartBuilderModal() {
options={MARK_OPTIONS}
value={mark}
onChange={setMark}
// Six marks don't fit one 320360px row: wrap to two, each segment kept at
// the control height (the base look is fixed-height, single-row).
className={styles.markPicker}
optionClassName={styles.markPickerOption}
/>
</div>
+17
View File
@@ -45,6 +45,23 @@ describe('init', () => {
});
});
describe('setIntent (front door "do it for me")', () => {
test('reshapes the chart to the intent layout and disarms any armed channel', () => {
const id = seedDataset('Sales', [
{ region: 'E', segment: 'A', sales: 5 },
{ region: 'W', segment: 'B', sales: 9 },
]);
cb().init(id);
cb().focusChannel('color'); // arm a channel first
cb().setIntent('heatmap');
expect(cb().config.mark).toBe('rect');
expect(cb().config.encodings.color).toEqual({ type: 'quantitative', aggregate: 'count' });
expect(cb().config.encodings.x?.field).toBeDefined();
expect(cb().config.encodings.y?.field).toBeDefined();
expect(cb().activeChannel).toBeNull();
});
});
describe('channel editing', () => {
test('mapping a column seeds a channel-appropriate type (Size stays a measure)', () => {
const id = seedDataset('Mixed', [{ name: 'A', value: 5 }]);
+13
View File
@@ -18,6 +18,7 @@
import { create } from 'zustand';
import {
CHANNELS,
applyIntent,
buildSnippetSpecText,
channelAcceptsValue,
coerceChannelValue,
@@ -40,6 +41,7 @@ import {
type BuilderColumns,
type BuilderConfig,
type BuilderFilter,
type ChartIntent,
type BuilderWarningFix,
type ChannelMapping,
type ChannelName,
@@ -111,6 +113,14 @@ export interface ChartBuilderState {
* `rebaseBuilderConfig`).
*/
switchDataset: (datasetId: number) => void;
/**
* Reshape the chart to an intent's recommended layout — the front door's "do it
* for me" (spec §06 Intent). Replaces mark + encodings + sort/stack with the
* intent's; keeps the dataset, data transforms, and chart properties. The active
* intent is derived from the config (`activeIntent`), not stored, so an edit after
* a pick reads as "Custom" with no extra state.
*/
setIntent: (intent: ChartIntent) => void;
setMark: (mark: MarkType) => void;
/**
* Map a column to a channel (null = "None", `COUNT_FIELD` = a field-less count);
@@ -310,6 +320,9 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
});
},
setIntent: (intent) =>
set((s) => ({ activeChannel: null, config: applyIntent(s.config, intent, s.columns) })),
setMark: (mark) => set((s) => ({ config: { ...s.config, mark } })),
setChannelColumn: (channel, columnName) =>
+314 -1
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import {
MARK_TYPES,
defaultFieldType,
validFieldTypes,
defaultMark,
@@ -19,6 +20,11 @@ import {
channelAcceptsValue,
defaultChannelValue,
coerceChannelValue,
CHART_INTENTS,
intentApplicable,
intentLayout,
applyIntent,
activeIntent,
buildChartSpec,
buildSnippetSpecText,
generateChartName,
@@ -199,6 +205,62 @@ describe('builderWarnings (Tier B advisories)', () => {
expect(w).toEqual([]);
});
describe('heatmap (rect mark)', () => {
it('warns when a rect mark is missing an axis (a heatmap is an X×Y grid)', () => {
const w = builderWarnings({
datasetName: 'D',
mark: 'rect',
encodings: { x: { field: 'a', type: 'nominal' } },
});
expect(w.some((m) => /Heatmaps need both an X and a Y/.test(m.message))).toBe(true);
});
it('nudges a colour measure when both axes are mapped, offering [Colour by count]', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'rect',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'segment', type: 'nominal' },
},
};
const w = builderWarnings(config);
const hint = w.find((m) => m.channel === 'color');
expect(hint?.message).toMatch(/map a measure \(or Count\) to Colour/);
const fixed = hint!.fixes!.find((f) => f.label === 'Colour by count')!.apply(config);
expect(fixed.encodings.color).toEqual({ type: 'quantitative', aggregate: 'count' });
// With a count on Colour it is a complete heatmap — the hint re-derives away.
expect(builderWarnings(fixed).some((m) => m.channel === 'color')).toBe(false);
});
it('does NOT push two-measures-to-scatter on a rect (a binned 2-D histogram is valid)', () => {
const w = builderWarnings({
datasetName: 'D',
mark: 'rect',
encodings: {
x: { field: 'a', type: 'quantitative', bin: true },
y: { field: 'b', type: 'quantitative', bin: true },
color: { type: 'quantitative', aggregate: 'count' },
},
});
expect(w.some((m) => /scatter/.test(m.message))).toBe(false);
expect(w).toEqual([]); // a binned 2-D histogram with a count colour is clean
});
it('reads a colour-measure heatmap as a clean configuration', () => {
const w = builderWarnings({
datasetName: 'D',
mark: 'rect',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'segment', type: 'nominal' },
color: { field: 'revenue', type: 'quantitative', aggregate: 'sum' },
},
});
expect(w).toEqual([]);
});
});
describe('crowded category axis (one mark per row)', () => {
const crowded = (overrides: Partial<ChannelMapping> = {}) =>
builderWarnings(
@@ -624,6 +686,194 @@ describe('defaultBuilderConfig — data-aware "safest bet" (profiled datasets)',
});
});
describe('intent-first front door', () => {
const stat = (name: string, distinct: number, capped = false) => ({
name,
distinct,
distinctCapped: capped,
numericExtent: null,
});
// Two categories (Segment 3 < Region 4), one date, two measures → every intent applies.
const superstore: BuilderColumns = {
columns: ['Segment', 'Region', 'Order Date', 'Sales', 'Profit'],
columnTypes: [
{ name: 'Segment', type: 'string' },
{ name: 'Region', type: 'string' },
{ name: 'Order Date', type: 'date' },
{ name: 'Sales', type: 'number' },
{ name: 'Profit', type: 'number' },
],
columnStats: [
stat('Segment', 3),
stat('Region', 4),
stat('Order Date', 50, true),
stat('Sales', 50, true),
stat('Profit', 50, true),
],
};
const count = { type: 'quantitative', aggregate: 'count' };
describe('intentApplicable (Tableau "Show Me" gating)', () => {
it('enables every intent on a category+date+measure dataset', () => {
for (const intent of CHART_INTENTS) {
expect(intentApplicable(intent, superstore)).toBe(true);
}
});
it('disables intents whose required column roles are missing', () => {
const oneMeasureOneCat: BuilderColumns = {
columns: ['Segment', 'Sales'],
columnTypes: [
{ name: 'Segment', type: 'string' },
{ name: 'Sales', type: 'number' },
],
columnStats: [stat('Segment', 3), stat('Sales', 50, true)],
};
expect(intentApplicable('correlation', oneMeasureOneCat)).toBe(false); // needs 2 measures
expect(intentApplicable('time', oneMeasureOneCat)).toBe(false); // needs a date
expect(intentApplicable('heatmap', oneMeasureOneCat)).toBe(false); // needs 2 categories
expect(intentApplicable('partToWhole', oneMeasureOneCat)).toBe(false);
expect(intentApplicable('compare', oneMeasureOneCat)).toBe(true);
expect(intentApplicable('distribution', oneMeasureOneCat)).toBe(true);
});
});
describe('intentLayout (intent → mark + channels)', () => {
it('maps each intent to its recommended layout', () => {
expect(intentLayout('compare', superstore)).toEqual({
mark: 'bar',
encodings: { x: { field: 'Segment', type: 'nominal' }, y: count },
});
expect(intentLayout('ranking', superstore)).toEqual({
mark: 'bar',
sort: 'descending',
encodings: { x: { field: 'Segment', type: 'nominal' }, y: count },
});
expect(intentLayout('time', superstore)).toEqual({
mark: 'line',
encodings: {
x: { field: 'Order Date', type: 'temporal' },
y: { field: 'Sales', type: 'quantitative' },
},
});
expect(intentLayout('correlation', superstore)).toEqual({
mark: 'point',
encodings: {
x: { field: 'Sales', type: 'quantitative' },
y: { field: 'Profit', type: 'quantitative' },
},
});
expect(intentLayout('distribution', superstore)).toEqual({
mark: 'bar',
encodings: { x: { field: 'Sales', type: 'quantitative', bin: true }, y: count },
});
expect(intentLayout('partToWhole', superstore)).toEqual({
mark: 'bar',
stack: 'zero',
encodings: {
x: { field: 'Segment', type: 'nominal' },
y: count,
color: { field: 'Region', type: 'nominal' },
},
});
expect(intentLayout('heatmap', superstore)).toEqual({
mark: 'rect',
encodings: {
x: { field: 'Segment', type: 'nominal' },
y: { field: 'Region', type: 'nominal' },
color: count,
},
});
});
});
describe('applyIntent', () => {
it('reshapes mark/encodings/sort/stack but keeps dataset, transforms, and title', () => {
const start: BuilderConfig = {
datasetName: 'Superstore',
mark: 'point',
encodings: { x: { field: 'Sales', type: 'quantitative' } },
title: 'My chart',
calculates: [{ id: 'c1', expr: 'datum.Sales * 2', as: 'double' }],
filters: [{ id: 'f1', mode: 'expression', expr: 'datum.Sales > 0' }],
};
const out = applyIntent(start, 'heatmap', superstore);
expect(out.mark).toBe('rect');
expect(out.encodings).toEqual({
x: { field: 'Segment', type: 'nominal' },
y: { field: 'Region', type: 'nominal' },
color: count,
size: null,
});
expect(out.datasetName).toBe('Superstore');
expect(out.title).toBe('My chart');
expect(out.calculates).toBe(start.calculates);
expect(out.filters).toBe(start.filters);
});
it('clears a stale channel and sort/stack when switching intents', () => {
const heat = applyIntent(
{ datasetName: 'D', mark: 'bar', encodings: {} },
'heatmap',
superstore,
);
expect(heat.encodings.color).toEqual(count);
const ranked = applyIntent(heat, 'ranking', superstore);
expect(ranked.encodings.color).toBeNull(); // heatmap's colour is dropped
expect(ranked.sort).toBe('descending');
const compared = applyIntent(ranked, 'compare', superstore);
expect(compared.sort).toBeUndefined(); // ranking's sort is dropped
});
});
describe('activeIntent (which chip the live chart lights)', () => {
it('lights the smart default on open (the coupling guard)', () => {
// The data-aware default IS an intent layout, so the strip auto-highlights it.
expect(activeIntent(defaultBuilderConfig('D', superstore), superstore)).toBe('compare');
const dateShape: BuilderColumns = {
columns: ['Order ID', 'Order Date', 'Sales'],
columnTypes: [
{ name: 'Order ID', type: 'string' },
{ name: 'Order Date', type: 'date' },
{ name: 'Sales', type: 'number' },
],
columnStats: [
stat('Order ID', 50, true),
stat('Order Date', 50, true),
stat('Sales', 50, true),
],
};
expect(activeIntent(defaultBuilderConfig('D', dateShape), dateShape)).toBe('time');
const scatterShape: BuilderColumns = {
columns: ['Order ID', 'Sales', 'Profit'],
columnTypes: [
{ name: 'Order ID', type: 'string' },
{ name: 'Sales', type: 'number' },
{ name: 'Profit', type: 'number' },
],
columnStats: [
stat('Order ID', 50, true),
stat('Sales', 50, true),
stat('Profit', 50, true),
],
};
expect(activeIntent(defaultBuilderConfig('D', scatterShape), scatterShape)).toBe(
'correlation',
);
});
it('reflects the picked intent, then reads Custom after a hand edit', () => {
const base = defaultBuilderConfig('D', superstore);
expect(activeIntent(applyIntent(base, 'heatmap', superstore), superstore)).toBe('heatmap');
// An edit that no intent produces (an area mark) → no chip lit.
const edited: BuilderConfig = { ...base, mark: 'area' };
expect(activeIntent(edited, superstore)).toBeNull();
});
});
});
describe('isBuilderConfigValid', () => {
const base: BuilderConfig = { datasetName: 'D', mark: 'bar', encodings: {} };
@@ -689,7 +939,7 @@ describe('buildChartSpec', () => {
});
it('carries every mark type through to the spec', () => {
for (const mark of ['bar', 'line', 'point', 'area', 'circle'] as const) {
for (const mark of MARK_TYPES) {
const spec = buildChartSpec({
datasetName: 'D',
mark,
@@ -932,6 +1182,69 @@ describe('transforms — aggregate / bin / timeUnit', () => {
}),
).toBe('Bar chart of unique customer by region');
});
it('names a heatmap "Heatmap of …" (not "Rect chart of …")', () => {
expect(
generateChartName({
datasetName: 'D',
mark: 'rect',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'segment', type: 'nominal' },
color: { field: 'revenue', type: 'quantitative', aggregate: 'sum' },
},
}),
).toBe('Heatmap of segment by region');
});
});
describe('role-aware guidance (bin is a dimension, not a measure)', () => {
// A 1-D histogram: bar, binned quantitative X, count Y. Both axes are quantitative by
// *type*, but the binned X is a discretized dimension by *role* — the false-positive
// class the role fix closes (eng-council 2026-06-13).
const histogram: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'amount', type: 'quantitative', bin: true },
y: { type: 'quantitative', aggregate: 'count' },
},
};
it('does not nudge a histogram toward a scatter (the binned axis is a dimension)', () => {
expect(builderWarnings(histogram).some((m) => /scatter/.test(m.message))).toBe(false);
expect(builderWarnings(histogram)).toEqual([]); // a clean histogram has no hints
});
it('does not offer Sort on a histogram (binned bins carry an inherent order)', () => {
expect(sortableCategoryChannel(histogram)).toBeUndefined();
expect(supportsSort(histogram)).toBe(false);
});
it('still offers Sort on a real category-vs-measure bar', () => {
const bar: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { type: 'quantitative', aggregate: 'count' },
},
};
expect(sortableCategoryChannel(bar)).toBe('x');
expect(supportsSort(bar)).toBe(true);
});
it('still nudges two RAW quantitative measures on a bar toward a scatter', () => {
const twoRaw: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'sales', type: 'quantitative' },
y: { field: 'profit', type: 'quantitative' },
},
};
expect(builderWarnings(twoRaw).some((m) => /scatter/.test(m.message))).toBe(true);
});
});
describe('sort (ranking)', () => {
+340 -27
View File
@@ -29,8 +29,9 @@ import { VEGA_LITE_SCHEMA_URL } from './snippet';
import { validateExpression } from './expr-validate';
import { escapeVegaField } from './rendering';
/** The five mark types the builder offers, in selector order (spec §06). */
export const MARK_TYPES = ['bar', 'line', 'point', 'area', 'circle'] as const;
/** The six mark types the builder offers, in selector order (spec §06). `rect` is the
* heatmap mark an X×Y grid of cells shaded by a Colour measure. */
export const MARK_TYPES = ['bar', 'line', 'point', 'area', 'circle', 'rect'] as const;
export type MarkType = (typeof MARK_TYPES)[number];
/** The four Vega-Lite field types a channel may carry, in override-menu order. */
@@ -361,7 +362,9 @@ export function coerceChannelValue(channel: ChannelName, raw: string): string |
* - a single mapped axis, or nothing yet **Bar** (the safe default)
*
* `null` means the channel is unmapped. This is the *default*; the user can switch
* to any of the five marks afterward.
* to any of the six marks afterward. **Heatmap (`rect`) is never auto-defaulted**
* it reads only with a Colour measure, which the X/Y shape alone can't determine, so
* it stays a deliberate pick (the heatmap guidance nudges the missing Colour).
*/
export function defaultMark(xType: FieldType | null, yType: FieldType | null): MarkType {
if (xType === null || yType === null) return 'bar';
@@ -503,6 +506,237 @@ export function defaultBuilderConfig(datasetName: string, columns: BuilderColumn
return { datasetName, mark, encodings };
}
// ── Intent-first front door (spec §06 → Intent) ────────────────────────────────
//
// "What do you want to show?" — a small set of analytic intents (FT Visual
// Vocabulary / Datawrapper taxonomy) that each recommend a mark + channel layout
// from the dataset's column roles. The front door is an *on-ramp*, not a gate: it
// seeds the mark-first builder, which the user can then adjust or ignore, and the
// intent never enters the produced spec — it is builder-local steering only (the
// JSON spec stays the document). Mirrors Tableau "Show Me": intents the data can't
// satisfy are disabled, and the live chart's matching intent stays highlighted.
/** The intents the front door offers, in display order. */
export const CHART_INTENTS = [
'compare',
'ranking',
'time',
'correlation',
'distribution',
'partToWhole',
'heatmap',
] as const;
export type ChartIntent = (typeof CHART_INTENTS)[number];
/**
* Dataset columns split by the role they play in a chart. Categories are ordered
* **most-readable first** a low-cardinality category (2`CATEGORY_DEFAULT_MAX_DISTINCT`
* distinct) ahead of a constant or a high-cardinality one, then by ascending distinct
* so `categories[0]` is the same readable axis `smartDefaultEncodings` prefers. That
* shared preference is what lets `activeIntent` light the smart default on open.
*/
function columnRoles(columns: BuilderColumns): {
categories: string[];
measures: string[];
temporals: string[];
} {
const typeOf = (name: string): ColumnType =>
columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
const distinctOf = (name: string): number => {
const s = columns.columnStats?.find((x) => x.name === name);
return s && !s.distinctCapped ? s.distinct : Number.POSITIVE_INFINITY;
};
const readable = (name: string): boolean => {
const d = distinctOf(name);
return d >= 2 && d <= CATEGORY_DEFAULT_MAX_DISTINCT;
};
const measures = columns.columns.filter((n) => typeOf(n) === 'number');
const temporals = columns.columns.filter((n) => typeOf(n) === 'date');
const categories = columns.columns
.filter((n) => typeOf(n) === 'string' || typeOf(n) === 'boolean')
.sort((a, b) => {
const ra = readable(a);
const rb = readable(b);
if (ra !== rb) return ra ? -1 : 1; // readable categories first
return distinctOf(a) - distinctOf(b); // then lowest-cardinality
});
return { categories, measures, temporals };
}
/** A fresh field-less count measure (never share a reference — configs are immutable). */
function countMapping(): ChannelMapping {
return { type: 'quantitative', aggregate: 'count' };
}
/**
* Whether the dataset has the column roles an intent needs to be meaningful. The
* front door **disables** the intents that don't apply (Tableau "Show Me": an
* unavailable chart is shown but inert) rather than producing a broken chart.
*/
export function intentApplicable(intent: ChartIntent, columns: BuilderColumns): boolean {
const { categories, measures, temporals } = columnRoles(columns);
switch (intent) {
case 'compare':
case 'ranking':
return categories.length >= 1; // a category axis vs a count
case 'time':
return temporals.length >= 1;
case 'correlation':
return measures.length >= 2; // two measures to cross
case 'distribution':
return measures.length >= 1; // a measure to bin
case 'partToWhole':
case 'heatmap':
return categories.length >= 2; // two categorical dimensions
}
}
/** Keep only the channels an intent actually sets, in canonical order. */
function pruneLayout(
enc: Partial<Record<ChannelName, ChannelMapping | undefined>>,
): Partial<Record<ChannelName, ChannelMapping>> {
const out: Partial<Record<ChannelName, ChannelMapping>> = {};
for (const ch of CHANNELS) {
const m = enc[ch];
if (m) out[ch] = m;
}
return out;
}
/**
* The mark + channel layout an intent recommends for these columns (spec §06
* Intent). A *partial* config only the channels the intent sets, plus the mark and
* any sort/stack; `applyIntent` merges it onto the working config. Best-effort when a
* preferred column is absent (the UI disables fully-inapplicable intents via
* `intentApplicable`, so a returned partial is always at least renderable).
*/
// TODO: exported only for its direct unit test — `applyIntent`/`activeIntent` are its
// sole production callers, both in this module. If no external caller appears, drop the
// export and assert the layout table through `applyIntent` (the file's convention for
// internal helpers like `smartDefaultEncodings`).
export function intentLayout(
intent: ChartIntent,
columns: BuilderColumns,
): Pick<BuilderConfig, 'mark' | 'sort' | 'stack'> & {
encodings: Partial<Record<ChannelName, ChannelMapping>>;
} {
const { categories, measures, temporals } = columnRoles(columns);
const cat = (i: number): ChannelMapping | undefined =>
categories[i] !== undefined ? { field: categories[i], type: 'nominal' } : undefined;
const measure = (i: number): ChannelMapping | undefined =>
measures[i] !== undefined ? { field: measures[i], type: 'quantitative' } : undefined;
const temporal = (i: number): ChannelMapping | undefined =>
temporals[i] !== undefined ? { field: temporals[i], type: 'temporal' } : undefined;
switch (intent) {
case 'compare': // magnitude across categories → a tidy bar of counts
return { mark: 'bar', encodings: pruneLayout({ x: cat(0), y: countMapping() }) };
case 'ranking': // the same, ordered by value
return {
mark: 'bar',
sort: 'descending',
encodings: pruneLayout({ x: cat(0), y: countMapping() }),
};
case 'time': // a trend over time → a line of the first measure (or a count)
return {
mark: 'line',
encodings: pruneLayout({ x: temporal(0), y: measure(0) ?? countMapping() }),
};
case 'correlation': // two measures crossed → a scatter
return { mark: 'point', encodings: pruneLayout({ x: measure(0), y: measure(1) }) };
case 'distribution': {
// the spread of one measure → a histogram (binned measure × count)
const m = measure(0);
return {
mark: 'bar',
encodings: pruneLayout({ x: m ? { ...m, bin: true } : undefined, y: countMapping() }),
};
}
case 'partToWhole': // shares of a total → a stacked bar by a second category
return {
mark: 'bar',
stack: 'zero',
encodings: pruneLayout({ x: cat(0), y: countMapping(), color: cat(1) }),
};
case 'heatmap': // a two-category grid shaded by count
return {
mark: 'rect',
encodings: pruneLayout({ x: cat(0), y: cat(1), color: countMapping() }),
};
}
}
/**
* Reshape the working config to an intent's recommended layout (spec §06 Intent),
* the front door's "do it for me". Replaces mark + encodings + sort + stack with the
* intent's; **keeps** the dataset, the data transforms (filters / calculated fields)
* and the chart-level title/subtitle/size all orthogonal to *what kind of chart*.
* Pure; the store calls it when the user picks an intent.
*/
export function applyIntent(
config: BuilderConfig,
intent: ChartIntent,
columns: BuilderColumns,
): BuilderConfig {
const layout = intentLayout(intent, columns);
const next: BuilderConfig = {
...config,
mark: layout.mark,
encodings: { x: null, y: null, color: null, size: null, ...layout.encodings },
};
if (layout.sort) next.sort = layout.sort;
else delete next.sort;
if (layout.stack) next.stack = layout.stack;
else delete next.stack;
return next;
}
/** Deep-equal two channel mappings (or nulls) on every field the builder emits. */
function sameMapping(a: ChannelMapping | null, b: ChannelMapping | null): boolean {
if (a === null || b === null) return a === b;
return (
a.field === b.field &&
a.type === b.type &&
a.aggregate === b.aggregate &&
!!a.bin === !!b.bin &&
a.timeUnit === b.timeUnit &&
a.value === b.value
);
}
/** Whether the config's mark/encodings/sort/stack match an intent's layout exactly. */
function configMatchesIntent(
config: BuilderConfig,
intent: ChartIntent,
columns: BuilderColumns,
): boolean {
const layout = intentLayout(intent, columns);
if (config.mark !== layout.mark) return false;
if ((config.sort ?? null) !== (layout.sort ?? null)) return false;
if ((config.stack ?? null) !== (layout.stack ?? null)) return false;
const want = { x: null, y: null, color: null, size: null, ...layout.encodings };
for (const ch of CHANNELS) {
if (!sameMapping(config.encodings[ch] ?? null, want[ch] ?? null)) return false;
}
return true;
}
/**
* The intent whose recommended layout the current config matches, or `null`
* ("Custom") once the user has edited away from any. Lets the front-door strip
* highlight the live chart's intent and auto-light the smart default on open
* **without storing the intent** (the config stays the single source of truth, so a
* dataset switch or a hand-built layout resolves correctly too). A structural match,
* preferring the first applicable intent on the rare tie.
*/
export function activeIntent(config: BuilderConfig, columns: BuilderColumns): ChartIntent | null {
return (
CHART_INTENTS.find(
(intent) => intentApplicable(intent, columns) && configMatchesIntent(config, intent, columns),
) ?? null
);
}
/** The channels actually mapped (a column field, or a field-less count), in order. */
function mappedChannels(config: BuilderConfig): Array<[ChannelName, ChannelMapping]> {
return CHANNELS.flatMap((channel) => {
@@ -520,10 +754,21 @@ function effectiveType(mapping: ChannelMapping): FieldType {
: mapping.type;
}
/** True when a mapping reads as a continuous measure (count/aggregate or continuous type). */
/**
* Whether a mapping reads as a continuous **measure** the post-transform *role*, not
* the raw field type. A constant encodes no data; a **binned** field is a discretized
* *dimension* (the same notion as Vega-Lite's own `isDiscrete(fieldDef)`, which returns
* true for a binned quantitative); everything else continuous (raw quantitative/temporal,
* or a count/sum/etc. aggregate) is a measure. Bin and aggregate are mutually exclusive,
* so the bin guard never hides a real aggregate measure.
*
* The builder's guidance must ask this *role* question, never `effectiveType === 'quantitative'`
* directly a binned axis is quantitative by type but a dimension by role; conflating the two
* makes a histogram (binned X + count Y) trip the two-measuresscatter nudge (arch 10 §5).
*/
function isMeasureMapping(mapping: ChannelMapping): boolean {
// A constant value encodes no data, so it is never a measure.
if (isValueMapping(mapping)) return false;
if (mapping.bin) return false; // a binned field is a discretized dimension, not a measure
return isContinuous(effectiveType(mapping));
}
@@ -537,26 +782,46 @@ export function isBuilderConfigValid(config: BuilderConfig): boolean {
}
/**
* The categorical positional channel to sort, when exactly one of X/Y is a discrete
* category and the other is a measure (spec §06 Ranking). Returns the channel to
* carry `sort`, or undefined when sorting doesn't apply (no clear category axis).
* Whether a mapping is a **reorderable** category axis one whose order is arbitrary,
* so ranking it by the measure is meaningful. Nominal/ordinal only, and explicitly
* **not** a binned or temporal axis: those carry an inherent order (you don't reorder
* histogram bins or a timeline by frequency). This is a *different* question from
* `isMeasureMapping` a binned field is neither a measure nor a reorderable category
* which is why sort and the scatter nudge can't share one predicate.
*/
function isReorderableCategory(mapping: ChannelMapping): boolean {
if (isValueMapping(mapping) || mapping.bin) return false;
const t = effectiveType(mapping);
return t === 'nominal' || t === 'ordinal';
}
/**
* The categorical positional channel to sort, when exactly one of X/Y is a **reorderable
* category** and the other is a measure (spec §06 Ranking). Returns the channel to
* carry `sort`, or undefined when sorting doesn't apply (no clear, reorderable category
* axis so a histogram's binned axis is never offered a Sort).
*/
export function sortableCategoryChannel(config: BuilderConfig): 'x' | 'y' | undefined {
const x = config.encodings.x ?? null;
const y = config.encodings.y ?? null;
if (!x || !y) return undefined;
const xMeasure = isMeasureMapping(x);
const yMeasure = isMeasureMapping(y);
if (xMeasure && !yMeasure) return 'y';
if (yMeasure && !xMeasure) return 'x';
if (isReorderableCategory(x) && isMeasureMapping(y)) return 'x';
if (isReorderableCategory(y) && isMeasureMapping(x)) return 'y';
return undefined;
}
/** The quantitative positional channel (x or y) that stacking applies to, if any. */
/** The quantitative positional channel (x or y) that stacking applies to, if any. A
* binned axis is a dimension, never the stack measure (so a binned bar with a colour
* series stacks the count axis, not the bins). */
function stackMeasureChannel(config: BuilderConfig): 'x' | 'y' | undefined {
for (const channel of ['x', 'y'] as const) {
const mapping = config.encodings[channel];
if (mapping && !isValueMapping(mapping) && effectiveType(mapping) === 'quantitative')
if (
mapping &&
!isValueMapping(mapping) &&
!mapping.bin &&
effectiveType(mapping) === 'quantitative'
)
return channel;
}
return undefined;
@@ -707,14 +972,51 @@ export function builderWarnings(
const y = config.encodings.y ?? null;
const { mark } = config;
// Line/area are two-axis marks: a single mapped axis can't draw a meaningful line
// or band (Draco hard.lp:91 line_area requires both x and y).
if ((mark === 'line' || mark === 'area') && (x === null || y === null)) {
// Measure/dimension is asked over the post-transform ROLE (`isMeasureMapping`), never
// raw `effectiveType`, so a binned axis (a discretized dimension) doesn't masquerade as
// a measure here (arch 10 §5).
//
// A config that matches an applicable intent is known-good, so the "taste" heuristics
// (two-measures→scatter, area-split) could stand down for it via
// `&& activeIntent(config, columns) === null`. That gate is omitted because no current
// intent layout (see `intentLayout`) produces a config that trips a taste rule, so it
// would be a dead branch; add it when a future intent or taste rule would otherwise
// conflict.
// Line/area/rect are two-axis marks: a single mapped axis can't draw a meaningful
// line or band (Draco hard.lp:91 line_area requires both x and y), and a heatmap is
// an X×Y cell grid by definition.
if ((mark === 'line' || mark === 'area' || mark === 'rect') && (x === null || y === null)) {
const noun = mark === 'rect' ? 'Heatmaps' : mark === 'line' ? 'Line charts' : 'Area charts';
warnings.push({
message: `${mark === 'line' ? 'Line' : 'Area'} charts need both an X and a Y axis.`,
message: `${noun} need both an X and a Y axis.`,
});
}
// A heatmap (rect) shades each X×Y cell by a value: without a measure on Colour the
// cells are uniform (or, with a categorical Colour, overlapping blocks) — not a
// heatmap. Nudge toward a Colour measure and offer Count as the always-available
// one (a cross-tab / 2-D-histogram count is the canonical heatmap). Gated on both
// axes present so it doesn't pile onto the both-axes hint above.
if (mark === 'rect' && x !== null && y !== null) {
const heatColor = config.encodings.color ?? null;
if (!heatColor || !isMeasureMapping(heatColor)) {
warnings.push({
channel: 'color',
message: 'A heatmap shades its cells by a value — map a measure (or Count) to Colour.',
fixes: [
{
label: 'Colour by count',
apply: (c) => ({
...c,
encodings: { ...c.encodings, color: { type: 'quantitative', aggregate: 'count' } },
}),
},
],
});
}
}
// Bar/line/area need a measure on one axis; two categories give nothing to compare
// (Draco soft.lp:47 only_discrete — the loudest nudge; hard.lp:97/:100 for bar).
if (
@@ -729,15 +1031,20 @@ export function builderWarnings(
});
}
// Two measures on a non-scatter mark: a line/bar/area over two quantitative axes
// misleads; a scatter is the conventional choice (Draco soft.lp c_c weights).
// Two *quantitative measures* on a bar/line/area: a scatter is the conventional choice
// (Draco soft.lp c_c weights; Datawrapper). Asked over the post-transform **role**
// (`isMeasureMapping`), not raw type, so a binned axis — a discretized dimension — never
// counts: that's what excludes a histogram (binned X + count) and a 2-D-histogram rect.
// The mark is a positive list (bar/line/area) — point/circle already are scatters, and a
// rect's right nudge is "bin + colour by count", handled by the heatmap hint above.
if (
(mark === 'bar' || mark === 'line' || mark === 'area') &&
x !== null &&
y !== null &&
isMeasureMapping(x) &&
effectiveType(x) === 'quantitative' &&
effectiveType(y) === 'quantitative' &&
mark !== 'point' &&
mark !== 'circle'
isMeasureMapping(y) &&
effectiveType(y) === 'quantitative'
) {
warnings.push({
message: 'Two measures usually read best as a scatter.',
@@ -1156,6 +1463,12 @@ function markLabel(mark: MarkType): string {
return mark.charAt(0).toUpperCase() + mark.slice(1);
}
/** The chart-type noun for a generated name: "Heatmap" for `rect` (its `markLabel`
* "Rect" is jargon), "<Mark> chart" for the rest. */
function markNoun(mark: MarkType): string {
return mark === 'rect' ? 'Heatmap' : `${markLabel(mark)} chart`;
}
/** 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';
@@ -1177,11 +1490,11 @@ export function generateChartName(config: BuilderConfig): string {
// A user-written chart title is the best possible name — prefer it verbatim.
const title = config.title?.trim();
if (title) return title;
const mark = markLabel(config.mark);
const noun = markNoun(config.mark);
const x = config.encodings.x;
const y = config.encodings.y;
if (x && y) return `${mark} chart of ${describeMapping(y)} by ${describeMapping(x)}`;
if (x && y) return `${noun} of ${describeMapping(y)} by ${describeMapping(x)}`;
const only = mappedChannels(config)[0];
if (only) return `${mark} chart of ${describeMapping(only[1])}`;
return `${mark} chart of ${config.datasetName}`;
if (only) return `${noun} of ${describeMapping(only[1])}`;
return `${noun} of ${config.datasetName}`;
}