diff --git a/.claude/skills/alignment/SKILL.md b/.claude/skills/alignment/SKILL.md index ce58179..9cf75b5 100644 --- a/.claude/skills/alignment/SKILL.md +++ b/.claude/skills/alignment/SKILL.md @@ -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. diff --git a/docs/architecture/10-interaction-and-feedback.md b/docs/architecture/10-interaction-and-feedback.md index 1543648..fa150ec 100644 --- a/docs/architecture/10-interaction-and-feedback.md +++ b/docs/architecture/10-interaction-and-feedback.md @@ -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 diff --git a/docs/chart-builder-enhancement-scope.md b/docs/chart-builder-enhancement-scope.md index f75c08c..72eaa8b 100644 --- a/docs/chart-builder-enhancement-scope.md +++ b/docs/chart-builder-enhancement-scope.md @@ -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 320–360px 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 diff --git a/docs/spec/06-chart-builder.md b/docs/spec/06-chart-builder.md index afb09f5..26d88f6 100644 --- a/docs/spec/06-chart-builder.md +++ b/docs/spec/06-chart-builder.md @@ -2,7 +2,7 @@ The Chart Builder is a visual, no-JSON way to compose a Vega-Lite chart from a selected dataset. The user picks a mark type and maps the dataset's columns to encoding channels; the builder produces a complete Vega-Lite spec and saves it as a new snippet that references the dataset. It is intended for users who want to start a chart quickly without hand-writing JSON in the _Spec Editor & Draft/Published Workflow_. -> **Design level — "smart + guarded" (Tier B).** The builder is 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). diff --git a/src/app/components/ChartBuilderModal.module.css b/src/app/components/ChartBuilderModal.module.css index a51c493..ea168a4 100644 --- a/src/app/components/ChartBuilderModal.module.css +++ b/src/app/components/ChartBuilderModal.module.css @@ -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 320–360px 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; diff --git a/src/app/components/ChartBuilderModal.test.tsx b/src/app/components/ChartBuilderModal.test.tsx index 6c5ceb9..f04081c 100644 --- a/src/app/components/ChartBuilderModal.test.tsx +++ b/src/app/components/ChartBuilderModal.test.tsx @@ -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('[role="toolbar"] button')); + + test('renders one tab stop and lights the active intent (roving tabindex)', async () => { + seedSuperstore(); + await act(async () => { + root.render(); + 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(); + 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(); + 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(); + 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); + }); + }); }); diff --git a/src/app/components/ChartBuilderModal.tsx b/src/app/components/ChartBuilderModal.tsx index e08dc08..0d60430 100644 --- a/src/app/components/ChartBuilderModal.tsx +++ b/src/app/components/ChartBuilderModal.tsx @@ -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 rect↔heatmap pairing). */ +const MARK_LABELS: Record = { + bar: 'Bar', + line: 'Line', + point: 'Point', + area: 'Area', + circle: 'Circle', + rect: 'Heatmap', +}; + const MARK_OPTIONS: ReadonlyArray> = 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 = { + compare: 'Compare', + ranking: 'Ranking', + time: 'Change over time', + correlation: 'Correlation', + distribution: 'Distribution', + partToWhole: 'Part-to-whole', + heatmap: 'Heatmap', +}; + +const INTENT_NEEDS: Record = { + 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 = { 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>([]); + 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 ( +
+ + What do you want to show? + +

+ {active ? ( + <> + Starting point: {INTENT_LABELS[active]}. Pick another, or adjust the + controls below. + + ) : ( + <>Pick a starting point and we will set the chart up — or build it yourself below. + )} +

+
+ {CHART_INTENTS.map((intent, i) => { + const enabled = applicable.has(intent); + return ( + + ); + })} +
+
+ ); +} + /** * 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() { + +
@@ -1386,6 +1533,10 @@ export function ChartBuilderModal() { options={MARK_OPTIONS} value={mark} onChange={setMark} + // Six marks don't fit one 320–360px 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} />
diff --git a/src/app/stores/ChartBuilderStore.test.ts b/src/app/stores/ChartBuilderStore.test.ts index 14a9377..6dce664 100644 --- a/src/app/stores/ChartBuilderStore.test.ts +++ b/src/app/stores/ChartBuilderStore.test.ts @@ -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 }]); diff --git a/src/app/stores/ChartBuilderStore.ts b/src/app/stores/ChartBuilderStore.ts index f41a68e..1869538 100644 --- a/src/app/stores/ChartBuilderStore.ts +++ b/src/app/stores/ChartBuilderStore.ts @@ -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((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) => diff --git a/src/core/chart-builder.test.ts b/src/core/chart-builder.test.ts index 69f71af..25dd7a4 100644 --- a/src/core/chart-builder.test.ts +++ b/src/core/chart-builder.test.ts @@ -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 = {}) => 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)', () => { diff --git a/src/core/chart-builder.ts b/src/core/chart-builder.ts index b572af2..86f02e3 100644 --- a/src/core/chart-builder.ts +++ b/src/core/chart-builder.ts @@ -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>, +): Partial> { + const out: Partial> = {}; + 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 & { + encodings: Partial>; +} { + 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-measures→scatter 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), " 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}`; }