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 case, active voice, no "please", no exclamation marks in errors). If/when an i18n layer
exists, route strings through it instead of hardcoding. 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 ### 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 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 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. 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 → 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.)_ 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 **Resolved — an error names the right fix, not a boilerplate one.** Don't staple a generic
remedy onto every failure. A missing dataset reference is **not** a JSON/spec syntax problem, remedy onto every failure. A missing dataset reference is **not** a JSON/spec syntax problem,
so the preview gives it a tailored, fixable line — _"Dataset «X» not found. Create it from so the preview gives it a tailored, fixable line — _"Dataset «X» not found. Create it from
+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 Newest first. The at-a-glance build-order tracker is §4; per-item detail is §3. This log is
the quick "where are we" — read it first. the quick "where are we" — read it first.
- **2026-06-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 - **2026-06-12 (3D)** — **entry points & discoverability shipped. Up next: 3A (the
front door now has somewhere to be found).** front door now has somewhere to be found).**
- **Library creation surface forked:** primary **Build Chart** (accent, takes the - **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 Phase 2 2A value-or-field channels (Property model) ✓ done
2B field shelf + in-place type cycling ✓ done (field-first + on-chart shelves) 2B field shelf + in-place type cycling ✓ done (field-first + on-chart shelves)
Phase 3 3D entry points & discoverability ✓ done (2026-06-12) Phase 3 3D entry points & discoverability ✓ done (2026-06-12)
3A intent-first front door (Tier C) ← next; built on 2B; the defining feature 3A intent-first front door (Tier C) ✓ done (2026-06-13); persistent strip
3B starter examples 3B starter examples ← next; pairs with 3C
3C open in builder (strict hydration) ← added 2026-06-11; pairs with 3B 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 Phase 4 (gated) theta/facets/styling-overrides/undo/lookup — decide after Phase 3
Also shipped (builder UX/perf, from dogfooding): near-fullscreen modal, internal-scroll Also shipped (builder UX/perf, from dogfooding): near-fullscreen modal, internal-scroll
+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_. 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 ## 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. 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 ## Layout
A two-pane modal: A two-pane modal:
@@ -68,8 +79,8 @@ The section is ordered **input → shaping** so the distinction reads at a glanc
### Mark type ### Mark type
- Single selection from an exact set of five mark types: **Bar, Line, Point, Area, Circle**. - 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. The user can switch to any of the five afterward. - 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. - Exactly one mark type is active at any time; selecting one updates the preview.
### Encoding channels ### 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: 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). - 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). - 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). - 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); 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) ──── */ /* ── Data section: filters, calculated fields, row preview (spec §06 → Data) ──── */
.dataSection { .dataSection {
@@ -346,6 +419,18 @@
gap: var(--space-2); 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 { .fieldLabel {
font-size: 12px; font-size: 12px;
font-weight: 500; font-weight: 500;
@@ -475,4 +475,117 @@ describe('ChartBuilderModal', () => {
// A colour picker renders for the constant. // A colour picker renders for the constant.
expect(container.querySelector('input[type="color"]')).not.toBeNull(); 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 type { VisualizationSpec } from 'vega-embed';
import { import {
CHANNELS, CHANNELS,
CHART_INTENTS,
MARK_TYPES, MARK_TYPES,
TIME_UNITS, TIME_UNITS,
activeIntent,
builderWarnings, builderWarnings,
channelAcceptsValue, channelAcceptsValue,
defaultChannelValue, defaultChannelValue,
defaultFieldType, defaultFieldType,
effectiveColumns, effectiveColumns,
filterOpArity, filterOpArity,
intentApplicable,
isBuilderConfigValid, isBuilderConfigValid,
isChannelTypeAllowed, isChannelTypeAllowed,
isColumnAllowedOnChannel, isColumnAllowedOnChannel,
@@ -50,6 +53,7 @@ import {
type BuilderWarningFix, type BuilderWarningFix,
type ChannelMapping, type ChannelMapping,
type ChannelName, type ChannelName,
type ChartIntent,
type FieldType, type FieldType,
type FilterOp, type FilterOp,
type MarkType, type MarkType,
@@ -113,11 +117,46 @@ function titleCase(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1); 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 rect↔heatmap 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) => ({ const MARK_OPTIONS: ReadonlyArray<SegmentedOption<MarkType>> = MARK_TYPES.map((m) => ({
value: 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> = { const CHANNEL_LABELS: Record<ChannelName, string> = {
x: 'X', x: 'X',
y: 'Y', 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 * 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 * what the builder does and offers the one next step — never a dead end. The
@@ -1377,6 +1522,8 @@ export function ChartBuilderModal() {
</div> </div>
<DatasetPicker datasetId={datasetId} /> <DatasetPicker datasetId={datasetId} />
<IntentStrip />
<DataSection /> <DataSection />
<div className={styles.field}> <div className={styles.field}>
@@ -1386,6 +1533,10 @@ export function ChartBuilderModal() {
options={MARK_OPTIONS} options={MARK_OPTIONS}
value={mark} value={mark}
onChange={setMark} 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> </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', () => { describe('channel editing', () => {
test('mapping a column seeds a channel-appropriate type (Size stays a measure)', () => { test('mapping a column seeds a channel-appropriate type (Size stays a measure)', () => {
const id = seedDataset('Mixed', [{ name: 'A', value: 5 }]); const id = seedDataset('Mixed', [{ name: 'A', value: 5 }]);
+13
View File
@@ -18,6 +18,7 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { import {
CHANNELS, CHANNELS,
applyIntent,
buildSnippetSpecText, buildSnippetSpecText,
channelAcceptsValue, channelAcceptsValue,
coerceChannelValue, coerceChannelValue,
@@ -40,6 +41,7 @@ import {
type BuilderColumns, type BuilderColumns,
type BuilderConfig, type BuilderConfig,
type BuilderFilter, type BuilderFilter,
type ChartIntent,
type BuilderWarningFix, type BuilderWarningFix,
type ChannelMapping, type ChannelMapping,
type ChannelName, type ChannelName,
@@ -111,6 +113,14 @@ export interface ChartBuilderState {
* `rebaseBuilderConfig`). * `rebaseBuilderConfig`).
*/ */
switchDataset: (datasetId: number) => void; 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; setMark: (mark: MarkType) => void;
/** /**
* Map a column to a channel (null = "None", `COUNT_FIELD` = a field-less count); * 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 } })), setMark: (mark) => set((s) => ({ config: { ...s.config, mark } })),
setChannelColumn: (channel, columnName) => setChannelColumn: (channel, columnName) =>
+314 -1
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { import {
MARK_TYPES,
defaultFieldType, defaultFieldType,
validFieldTypes, validFieldTypes,
defaultMark, defaultMark,
@@ -19,6 +20,11 @@ import {
channelAcceptsValue, channelAcceptsValue,
defaultChannelValue, defaultChannelValue,
coerceChannelValue, coerceChannelValue,
CHART_INTENTS,
intentApplicable,
intentLayout,
applyIntent,
activeIntent,
buildChartSpec, buildChartSpec,
buildSnippetSpecText, buildSnippetSpecText,
generateChartName, generateChartName,
@@ -199,6 +205,62 @@ describe('builderWarnings (Tier B advisories)', () => {
expect(w).toEqual([]); 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)', () => { describe('crowded category axis (one mark per row)', () => {
const crowded = (overrides: Partial<ChannelMapping> = {}) => const crowded = (overrides: Partial<ChannelMapping> = {}) =>
builderWarnings( 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', () => { describe('isBuilderConfigValid', () => {
const base: BuilderConfig = { datasetName: 'D', mark: 'bar', encodings: {} }; const base: BuilderConfig = { datasetName: 'D', mark: 'bar', encodings: {} };
@@ -689,7 +939,7 @@ describe('buildChartSpec', () => {
}); });
it('carries every mark type through to the spec', () => { 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({ const spec = buildChartSpec({
datasetName: 'D', datasetName: 'D',
mark, mark,
@@ -932,6 +1182,69 @@ describe('transforms — aggregate / bin / timeUnit', () => {
}), }),
).toBe('Bar chart of unique customer by region'); ).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)', () => { describe('sort (ranking)', () => {
+340 -27
View File
@@ -29,8 +29,9 @@ import { VEGA_LITE_SCHEMA_URL } from './snippet';
import { validateExpression } from './expr-validate'; import { validateExpression } from './expr-validate';
import { escapeVegaField } from './rendering'; import { escapeVegaField } from './rendering';
/** The five mark types the builder offers, in selector order (spec §06). */ /** The six mark types the builder offers, in selector order (spec §06). `rect` is the
export const MARK_TYPES = ['bar', 'line', 'point', 'area', 'circle'] as const; * 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]; export type MarkType = (typeof MARK_TYPES)[number];
/** The four Vega-Lite field types a channel may carry, in override-menu order. */ /** 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) * - 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 * `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 { export function defaultMark(xType: FieldType | null, yType: FieldType | null): MarkType {
if (xType === null || yType === null) return 'bar'; if (xType === null || yType === null) return 'bar';
@@ -503,6 +506,237 @@ export function defaultBuilderConfig(datasetName: string, columns: BuilderColumn
return { datasetName, mark, encodings }; 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. */ /** The channels actually mapped (a column field, or a field-less count), in order. */
function mappedChannels(config: BuilderConfig): Array<[ChannelName, ChannelMapping]> { function mappedChannels(config: BuilderConfig): Array<[ChannelName, ChannelMapping]> {
return CHANNELS.flatMap((channel) => { return CHANNELS.flatMap((channel) => {
@@ -520,10 +754,21 @@ function effectiveType(mapping: ChannelMapping): FieldType {
: mapping.type; : 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-measures→scatter nudge (arch 10 §5).
*/
function isMeasureMapping(mapping: ChannelMapping): boolean { function isMeasureMapping(mapping: ChannelMapping): boolean {
// A constant value encodes no data, so it is never a measure.
if (isValueMapping(mapping)) return false; if (isValueMapping(mapping)) return false;
if (mapping.bin) return false; // a binned field is a discretized dimension, not a measure
return isContinuous(effectiveType(mapping)); 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 * Whether a mapping is a **reorderable** category axis — one whose order is arbitrary,
* category and the other is a measure (spec §06 → Ranking). Returns the channel to * so ranking it by the measure is meaningful. Nominal/ordinal only, and explicitly
* carry `sort`, or undefined when sorting doesn't apply (no clear category axis). * **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 { export function sortableCategoryChannel(config: BuilderConfig): 'x' | 'y' | undefined {
const x = config.encodings.x ?? null; const x = config.encodings.x ?? null;
const y = config.encodings.y ?? null; const y = config.encodings.y ?? null;
if (!x || !y) return undefined; if (!x || !y) return undefined;
const xMeasure = isMeasureMapping(x); if (isReorderableCategory(x) && isMeasureMapping(y)) return 'x';
const yMeasure = isMeasureMapping(y); if (isReorderableCategory(y) && isMeasureMapping(x)) return 'y';
if (xMeasure && !yMeasure) return 'y';
if (yMeasure && !xMeasure) return 'x';
return undefined; 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 { function stackMeasureChannel(config: BuilderConfig): 'x' | 'y' | undefined {
for (const channel of ['x', 'y'] as const) { for (const channel of ['x', 'y'] as const) {
const mapping = config.encodings[channel]; const mapping = config.encodings[channel];
if (mapping && !isValueMapping(mapping) && effectiveType(mapping) === 'quantitative') if (
mapping &&
!isValueMapping(mapping) &&
!mapping.bin &&
effectiveType(mapping) === 'quantitative'
)
return channel; return channel;
} }
return undefined; return undefined;
@@ -707,14 +972,51 @@ export function builderWarnings(
const y = config.encodings.y ?? null; const y = config.encodings.y ?? null;
const { mark } = config; const { mark } = config;
// Line/area are two-axis marks: a single mapped axis can't draw a meaningful line // Measure/dimension is asked over the post-transform ROLE (`isMeasureMapping`), never
// or band (Draco hard.lp:91 line_area requires both x and y). // raw `effectiveType`, so a binned axis (a discretized dimension) doesn't masquerade as
if ((mark === 'line' || mark === 'area') && (x === null || y === null)) { // 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({ 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 // 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). // (Draco soft.lp:47 only_discrete — the loudest nudge; hard.lp:97/:100 for bar).
if ( if (
@@ -729,15 +1031,20 @@ export function builderWarnings(
}); });
} }
// Two measures on a non-scatter mark: a line/bar/area over two quantitative axes // Two *quantitative measures* on a bar/line/area: a scatter is the conventional choice
// misleads; a scatter is the conventional choice (Draco soft.lp c_c weights). // (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 ( if (
(mark === 'bar' || mark === 'line' || mark === 'area') &&
x !== null && x !== null &&
y !== null && y !== null &&
isMeasureMapping(x) &&
effectiveType(x) === 'quantitative' && effectiveType(x) === 'quantitative' &&
effectiveType(y) === 'quantitative' && isMeasureMapping(y) &&
mark !== 'point' && effectiveType(y) === 'quantitative'
mark !== 'circle'
) { ) {
warnings.push({ warnings.push({
message: 'Two measures usually read best as a scatter.', 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); 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". */ /** A human phrase for what a channel encodes, e.g. "sum of revenue", "count". */
function describeMapping(mapping: ChannelMapping): string { function describeMapping(mapping: ChannelMapping): string {
if (mapping.value !== undefined) return 'a constant'; 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. // A user-written chart title is the best possible name — prefer it verbatim.
const title = config.title?.trim(); const title = config.title?.trim();
if (title) return title; if (title) return title;
const mark = markLabel(config.mark); const noun = markNoun(config.mark);
const x = config.encodings.x; const x = config.encodings.x;
const y = config.encodings.y; 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]; const only = mappedChannels(config)[0];
if (only) return `${mark} chart of ${describeMapping(only[1])}`; if (only) return `${noun} of ${describeMapping(only[1])}`;
return `${mark} chart of ${config.datasetName}`; return `${noun} of ${config.datasetName}`;
} }