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
@@ -58,6 +58,79 @@
gap: var(--space-3);
}
/* ── Intent front door (spec §06 → Intent) ───────────────────────────────
A persistent "what do you want to show?" strip under the dataset picker. A
quiet accent-soft wash marks it as the guided on-ramp without competing with
the primary Create action (--accent stays reserved for primary actions). */
.intentStrip {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-4);
border: var(--border-width) solid var(--border);
background: var(--accent-soft);
}
.intentQ {
font-size: 13px;
font-weight: 600;
color: var(--text);
}
.intentSub {
margin: 0;
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary);
}
.intentChips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-top: var(--space-1);
}
.intentChip {
appearance: none;
height: var(--control-height);
border: var(--border-width) solid var(--border-strong);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 12px;
padding: 0 var(--space-3);
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
box-shadow var(--dur-fast) var(--ease);
}
.intentChip:hover {
background: var(--field);
}
/* The chip whose layout the live chart matches (council-reserved: selection uses an
accent ring, never an accent fill — fill stays the primary-action signal). */
.intentChip[aria-pressed='true'] {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
/* Disabled = the dataset can't satisfy this intent: perceivable but inert (the chip's
accessible name carries the reason). */
.intentChip[aria-disabled='true'] {
color: var(--text-placeholder);
border-color: var(--border);
background: var(--bg);
cursor: not-allowed;
}
.intentChip:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
/* ── Data section: filters, calculated fields, row preview (spec §06 → Data) ──── */
.dataSection {
@@ -346,6 +419,18 @@
gap: var(--space-2);
}
/* The mark picker carries six segments — too many for one 320360px row. Override the
SegmentedControl's fixed single-row height so it wraps; each option keeps the control
height so the two rows read as even segments inside the shared border. */
.markPicker {
flex-wrap: wrap;
height: auto;
}
.markPickerOption {
min-height: var(--control-height);
}
.fieldLabel {
font-size: 12px;
font-weight: 500;
@@ -475,4 +475,117 @@ describe('ChartBuilderModal', () => {
// A colour picker renders for the constant.
expect(container.querySelector('input[type="color"]')).not.toBeNull();
});
// The intent front door's core logic (which chip lights, what each applies, gating)
// is covered in core/store; these check only the parts that live in the component —
// the toolbar's roving tabindex and arrow navigation (a hand-rolled handler, not a
// shared primitive), and that a click reshapes the chart.
describe('intent front door (the "What do you want to show?" strip)', () => {
// Two categories + one date + two measures → every intent applies; enough shape to
// light a default and to exercise an applied intent (Heatmap needs two categories).
const seedSuperstore = () => {
const ds = createDataset({
name: 'Superstore',
data: [
{ region: 'E', segment: 'A', date: '2026-01-01', sales: 5, profit: 1 },
{ region: 'W', segment: 'B', date: '2026-02-01', sales: 9, profit: 3 },
],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
useChartBuilderStore.getState().init(id);
return id;
};
const chips = (): HTMLButtonElement[] =>
Array.from(container.querySelectorAll<HTMLButtonElement>('[role="toolbar"] button'));
test('renders one tab stop and lights the active intent (roving tabindex)', async () => {
seedSuperstore();
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const all = chips();
expect(all.length).toBe(7); // every intent shows (Tableau Show Me: never hidden)
// Exactly one chip is in the tab order; the rest are roving (-1).
expect(all.filter((b) => b.tabIndex === 0)).toHaveLength(1);
// The smart default for a category+count shape is Compare, so its chip is pressed.
const pressed = all.filter((b) => b.getAttribute('aria-pressed') === 'true');
expect(pressed).toHaveLength(1);
expect(pressed[0].textContent).toBe('Compare');
});
test('an inapplicable intent is disabled and names its reason', async () => {
// One category, one measure, no date and no second measure → Correlation/Time/
// Heatmap/Part-to-whole cannot apply.
const ds = createDataset({
name: 'Thin',
data: [{ region: 'E', sales: 5 }],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
useChartBuilderStore.getState().init(id);
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const correlation = chips().find((b) => b.textContent === 'Correlation')!;
expect(correlation.getAttribute('aria-disabled')).toBe('true');
expect(correlation.getAttribute('aria-label')).toMatch(/needs two number columns/);
});
test('clicking an enabled chip reshapes the chart to that intent', async () => {
seedSuperstore();
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const heatmap = chips().find((b) => b.textContent === 'Heatmap')!;
await act(async () => {
heatmap.click();
await Promise.resolve();
});
expect(useChartBuilderStore.getState().config.mark).toBe('rect');
expect(useChartBuilderStore.getState().config.encodings.color).toEqual({
type: 'quantitative',
aggregate: 'count',
});
});
test('ArrowRight moves focus along the toolbar without applying (focus-only)', async () => {
seedSuperstore();
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const all = chips();
const start = all.findIndex((b) => b.tabIndex === 0);
const markBefore = useChartBuilderStore.getState().config.mark;
await act(async () => {
all[start].focus();
all[start].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
);
await Promise.resolve();
});
// Focus moved to the next chip; the chart is untouched (arrows navigate, Enter applies).
expect(document.activeElement).toBe(all[(start + 1) % all.length]);
expect(useChartBuilderStore.getState().config.mark).toBe(markBefore);
});
});
});
+152 -1
View File
@@ -23,14 +23,17 @@ import { useShallow } from 'zustand/react/shallow';
import type { VisualizationSpec } from 'vega-embed';
import {
CHANNELS,
CHART_INTENTS,
MARK_TYPES,
TIME_UNITS,
activeIntent,
builderWarnings,
channelAcceptsValue,
defaultChannelValue,
defaultFieldType,
effectiveColumns,
filterOpArity,
intentApplicable,
isBuilderConfigValid,
isChannelTypeAllowed,
isColumnAllowedOnChannel,
@@ -50,6 +53,7 @@ import {
type BuilderWarningFix,
type ChannelMapping,
type ChannelName,
type ChartIntent,
type FieldType,
type FilterOp,
type MarkType,
@@ -113,11 +117,46 @@ function titleCase(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
/** User-facing mark labels. All but `rect` are their geometry name title-cased; `rect`
* is shown as **Heatmap** — the chart it makes, since "Rect" is opaque to the no-JSON
* audience the builder serves (the spec's mark-set table notes the 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) => ({
value: m,
label: titleCase(m),
label: MARK_LABELS[m],
title: m === 'rect' ? 'Heatmap (rect mark)' : undefined,
}));
/** Intent front-door copy (spec §06 → Intent). Label = the chip; needs = what the
* dataset must have for the intent to apply (shown when the chip is disabled). */
const INTENT_LABELS: Record<ChartIntent, string> = {
compare: 'Compare',
ranking: 'Ranking',
time: 'Change over time',
correlation: 'Correlation',
distribution: 'Distribution',
partToWhole: 'Part-to-whole',
heatmap: 'Heatmap',
};
const INTENT_NEEDS: Record<ChartIntent, string> = {
compare: 'a category column',
ranking: 'a category column',
time: 'a date column',
correlation: 'two number columns',
distribution: 'a number column',
partToWhole: 'two category columns',
heatmap: 'two category columns',
};
const CHANNEL_LABELS: Record<ChannelName, string> = {
x: 'X',
y: 'Y',
@@ -1277,6 +1316,112 @@ function DatasetPicker({ datasetId }: { datasetId: number | null }) {
);
}
/**
* The intent-first front door (spec §06 → Intent): a persistent "what do you want to
* show?" strip pinned under the dataset picker. Each chip applies a recommended mark +
* channel layout — the "do it for me" (Tableau "Show Me"). Intents the dataset can't
* satisfy are disabled (with the reason in their name), and the chip whose layout the
* live chart matches stays highlighted; once the user edits away from any, none is — a
* "Custom" chart. It seeds the builder; it never gates it (the user can ignore it and
* drive the channels directly). The active intent is derived from the config, so no
* intent state is stored.
*/
function IntentStrip() {
const columns = useChartBuilderStore((s) => s.columns);
const config = useChartBuilderStore((s) => s.config);
const setIntent = useChartBuilderStore((s) => s.setIntent);
const active = useMemo(() => activeIntent(config, columns), [config, columns]);
const applicable = useMemo(
() => new Set(CHART_INTENTS.filter((i) => intentApplicable(i, columns))),
[columns],
);
// APG toolbar (arch 10 §5): the chips are a single tab stop with a roving tabindex,
// NOT seven independently-tabbable buttons (the pane-toggle precedent). Arrows MOVE
// focus only — Enter/Space activates — because applying an
// intent reshapes the whole chart; a radiogroup's select-on-arrow would do that on
// every keypress. Disabled chips stay arrow-reachable so their "needs …" reason is
// discoverable (APG: focusable disabled controls where discoverability is crucial).
const btnRefs = useRef<Array<HTMLButtonElement | null>>([]);
const tabStop = useMemo(() => {
if (active) return CHART_INTENTS.indexOf(active); // an active intent is always enabled
const firstEnabled = CHART_INTENTS.findIndex((i) => applicable.has(i));
return firstEnabled >= 0 ? firstEnabled : 0;
}, [active, applicable]);
const onKeyDown = (e: React.KeyboardEvent, index: number) => {
const n = CHART_INTENTS.length;
let next: number;
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
next = (index + 1) % n;
break;
case 'ArrowLeft':
case 'ArrowUp':
next = (index - 1 + n) % n;
break;
case 'Home':
next = 0;
break;
case 'End':
next = n - 1;
break;
default:
return; // not ours — let it bubble (Enter/Space activate the native button)
}
e.preventDefault();
btnRefs.current[next]?.focus();
};
return (
<div className={styles.intentStrip}>
<span className={styles.intentQ} id="cb-intent-q">
What do you want to show?
</span>
<p className={styles.intentSub}>
{active ? (
<>
Starting point: <strong>{INTENT_LABELS[active]}</strong>. Pick another, or adjust the
controls below.
</>
) : (
<>Pick a starting point and we will set the chart up or build it yourself below.</>
)}
</p>
<div className={styles.intentChips} role="toolbar" aria-labelledby="cb-intent-q">
{CHART_INTENTS.map((intent, i) => {
const enabled = applicable.has(intent);
return (
<button
key={intent}
ref={(el) => {
btnRefs.current[i] = el;
}}
type="button"
className={styles.intentChip}
aria-pressed={active === intent}
aria-disabled={!enabled || undefined}
aria-label={
enabled ? undefined : `${INTENT_LABELS[intent]} — needs ${INTENT_NEEDS[intent]}`
}
title={enabled ? undefined : `Needs ${INTENT_NEEDS[intent]}`}
tabIndex={i === tabStop ? 0 : -1}
onKeyDown={(e) => onKeyDown(e, i)}
onClick={() => {
if (enabled) setIntent(intent);
}}
>
{INTENT_LABELS[intent]}
</button>
);
})}
</div>
</div>
);
}
/**
* The no-datasets state (spec §06 → Opening; Carbon no-data empty state): says
* what the builder does and offers the one next step — never a dead end. The
@@ -1377,6 +1522,8 @@ export function ChartBuilderModal() {
</div>
<DatasetPicker datasetId={datasetId} />
<IntentStrip />
<DataSection />
<div className={styles.field}>
@@ -1386,6 +1533,10 @@ export function ChartBuilderModal() {
options={MARK_OPTIONS}
value={mark}
onChange={setMark}
// Six marks don't fit one 320360px row: wrap to two, each segment kept at
// the control height (the base look is fixed-height, single-row).
className={styles.markPicker}
optionClassName={styles.markPickerOption}
/>
</div>
+17
View File
@@ -45,6 +45,23 @@ describe('init', () => {
});
});
describe('setIntent (front door "do it for me")', () => {
test('reshapes the chart to the intent layout and disarms any armed channel', () => {
const id = seedDataset('Sales', [
{ region: 'E', segment: 'A', sales: 5 },
{ region: 'W', segment: 'B', sales: 9 },
]);
cb().init(id);
cb().focusChannel('color'); // arm a channel first
cb().setIntent('heatmap');
expect(cb().config.mark).toBe('rect');
expect(cb().config.encodings.color).toEqual({ type: 'quantitative', aggregate: 'count' });
expect(cb().config.encodings.x?.field).toBeDefined();
expect(cb().config.encodings.y?.field).toBeDefined();
expect(cb().activeChannel).toBeNull();
});
});
describe('channel editing', () => {
test('mapping a column seeds a channel-appropriate type (Size stays a measure)', () => {
const id = seedDataset('Mixed', [{ name: 'A', value: 5 }]);
+13
View File
@@ -18,6 +18,7 @@
import { create } from 'zustand';
import {
CHANNELS,
applyIntent,
buildSnippetSpecText,
channelAcceptsValue,
coerceChannelValue,
@@ -40,6 +41,7 @@ import {
type BuilderColumns,
type BuilderConfig,
type BuilderFilter,
type ChartIntent,
type BuilderWarningFix,
type ChannelMapping,
type ChannelName,
@@ -111,6 +113,14 @@ export interface ChartBuilderState {
* `rebaseBuilderConfig`).
*/
switchDataset: (datasetId: number) => void;
/**
* Reshape the chart to an intent's recommended layout — the front door's "do it
* for me" (spec §06 → Intent). Replaces mark + encodings + sort/stack with the
* intent's; keeps the dataset, data transforms, and chart properties. The active
* intent is derived from the config (`activeIntent`), not stored, so an edit after
* a pick reads as "Custom" with no extra state.
*/
setIntent: (intent: ChartIntent) => void;
setMark: (mark: MarkType) => void;
/**
* Map a column to a channel (null = "None", `COUNT_FIELD` = a field-less count);
@@ -310,6 +320,9 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
});
},
setIntent: (intent) =>
set((s) => ({ activeChannel: null, config: applyIntent(s.config, intent, s.columns) })),
setMark: (mark) => set((s) => ({ config: { ...s.config, mark } })),
setChannelColumn: (channel, columnName) =>
+314 -1
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import {
MARK_TYPES,
defaultFieldType,
validFieldTypes,
defaultMark,
@@ -19,6 +20,11 @@ import {
channelAcceptsValue,
defaultChannelValue,
coerceChannelValue,
CHART_INTENTS,
intentApplicable,
intentLayout,
applyIntent,
activeIntent,
buildChartSpec,
buildSnippetSpecText,
generateChartName,
@@ -199,6 +205,62 @@ describe('builderWarnings (Tier B advisories)', () => {
expect(w).toEqual([]);
});
describe('heatmap (rect mark)', () => {
it('warns when a rect mark is missing an axis (a heatmap is an X×Y grid)', () => {
const w = builderWarnings({
datasetName: 'D',
mark: 'rect',
encodings: { x: { field: 'a', type: 'nominal' } },
});
expect(w.some((m) => /Heatmaps need both an X and a Y/.test(m.message))).toBe(true);
});
it('nudges a colour measure when both axes are mapped, offering [Colour by count]', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'rect',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'segment', type: 'nominal' },
},
};
const w = builderWarnings(config);
const hint = w.find((m) => m.channel === 'color');
expect(hint?.message).toMatch(/map a measure \(or Count\) to Colour/);
const fixed = hint!.fixes!.find((f) => f.label === 'Colour by count')!.apply(config);
expect(fixed.encodings.color).toEqual({ type: 'quantitative', aggregate: 'count' });
// With a count on Colour it is a complete heatmap — the hint re-derives away.
expect(builderWarnings(fixed).some((m) => m.channel === 'color')).toBe(false);
});
it('does NOT push two-measures-to-scatter on a rect (a binned 2-D histogram is valid)', () => {
const w = builderWarnings({
datasetName: 'D',
mark: 'rect',
encodings: {
x: { field: 'a', type: 'quantitative', bin: true },
y: { field: 'b', type: 'quantitative', bin: true },
color: { type: 'quantitative', aggregate: 'count' },
},
});
expect(w.some((m) => /scatter/.test(m.message))).toBe(false);
expect(w).toEqual([]); // a binned 2-D histogram with a count colour is clean
});
it('reads a colour-measure heatmap as a clean configuration', () => {
const w = builderWarnings({
datasetName: 'D',
mark: 'rect',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'segment', type: 'nominal' },
color: { field: 'revenue', type: 'quantitative', aggregate: 'sum' },
},
});
expect(w).toEqual([]);
});
});
describe('crowded category axis (one mark per row)', () => {
const crowded = (overrides: Partial<ChannelMapping> = {}) =>
builderWarnings(
@@ -624,6 +686,194 @@ describe('defaultBuilderConfig — data-aware "safest bet" (profiled datasets)',
});
});
describe('intent-first front door', () => {
const stat = (name: string, distinct: number, capped = false) => ({
name,
distinct,
distinctCapped: capped,
numericExtent: null,
});
// Two categories (Segment 3 < Region 4), one date, two measures → every intent applies.
const superstore: BuilderColumns = {
columns: ['Segment', 'Region', 'Order Date', 'Sales', 'Profit'],
columnTypes: [
{ name: 'Segment', type: 'string' },
{ name: 'Region', type: 'string' },
{ name: 'Order Date', type: 'date' },
{ name: 'Sales', type: 'number' },
{ name: 'Profit', type: 'number' },
],
columnStats: [
stat('Segment', 3),
stat('Region', 4),
stat('Order Date', 50, true),
stat('Sales', 50, true),
stat('Profit', 50, true),
],
};
const count = { type: 'quantitative', aggregate: 'count' };
describe('intentApplicable (Tableau "Show Me" gating)', () => {
it('enables every intent on a category+date+measure dataset', () => {
for (const intent of CHART_INTENTS) {
expect(intentApplicable(intent, superstore)).toBe(true);
}
});
it('disables intents whose required column roles are missing', () => {
const oneMeasureOneCat: BuilderColumns = {
columns: ['Segment', 'Sales'],
columnTypes: [
{ name: 'Segment', type: 'string' },
{ name: 'Sales', type: 'number' },
],
columnStats: [stat('Segment', 3), stat('Sales', 50, true)],
};
expect(intentApplicable('correlation', oneMeasureOneCat)).toBe(false); // needs 2 measures
expect(intentApplicable('time', oneMeasureOneCat)).toBe(false); // needs a date
expect(intentApplicable('heatmap', oneMeasureOneCat)).toBe(false); // needs 2 categories
expect(intentApplicable('partToWhole', oneMeasureOneCat)).toBe(false);
expect(intentApplicable('compare', oneMeasureOneCat)).toBe(true);
expect(intentApplicable('distribution', oneMeasureOneCat)).toBe(true);
});
});
describe('intentLayout (intent → mark + channels)', () => {
it('maps each intent to its recommended layout', () => {
expect(intentLayout('compare', superstore)).toEqual({
mark: 'bar',
encodings: { x: { field: 'Segment', type: 'nominal' }, y: count },
});
expect(intentLayout('ranking', superstore)).toEqual({
mark: 'bar',
sort: 'descending',
encodings: { x: { field: 'Segment', type: 'nominal' }, y: count },
});
expect(intentLayout('time', superstore)).toEqual({
mark: 'line',
encodings: {
x: { field: 'Order Date', type: 'temporal' },
y: { field: 'Sales', type: 'quantitative' },
},
});
expect(intentLayout('correlation', superstore)).toEqual({
mark: 'point',
encodings: {
x: { field: 'Sales', type: 'quantitative' },
y: { field: 'Profit', type: 'quantitative' },
},
});
expect(intentLayout('distribution', superstore)).toEqual({
mark: 'bar',
encodings: { x: { field: 'Sales', type: 'quantitative', bin: true }, y: count },
});
expect(intentLayout('partToWhole', superstore)).toEqual({
mark: 'bar',
stack: 'zero',
encodings: {
x: { field: 'Segment', type: 'nominal' },
y: count,
color: { field: 'Region', type: 'nominal' },
},
});
expect(intentLayout('heatmap', superstore)).toEqual({
mark: 'rect',
encodings: {
x: { field: 'Segment', type: 'nominal' },
y: { field: 'Region', type: 'nominal' },
color: count,
},
});
});
});
describe('applyIntent', () => {
it('reshapes mark/encodings/sort/stack but keeps dataset, transforms, and title', () => {
const start: BuilderConfig = {
datasetName: 'Superstore',
mark: 'point',
encodings: { x: { field: 'Sales', type: 'quantitative' } },
title: 'My chart',
calculates: [{ id: 'c1', expr: 'datum.Sales * 2', as: 'double' }],
filters: [{ id: 'f1', mode: 'expression', expr: 'datum.Sales > 0' }],
};
const out = applyIntent(start, 'heatmap', superstore);
expect(out.mark).toBe('rect');
expect(out.encodings).toEqual({
x: { field: 'Segment', type: 'nominal' },
y: { field: 'Region', type: 'nominal' },
color: count,
size: null,
});
expect(out.datasetName).toBe('Superstore');
expect(out.title).toBe('My chart');
expect(out.calculates).toBe(start.calculates);
expect(out.filters).toBe(start.filters);
});
it('clears a stale channel and sort/stack when switching intents', () => {
const heat = applyIntent(
{ datasetName: 'D', mark: 'bar', encodings: {} },
'heatmap',
superstore,
);
expect(heat.encodings.color).toEqual(count);
const ranked = applyIntent(heat, 'ranking', superstore);
expect(ranked.encodings.color).toBeNull(); // heatmap's colour is dropped
expect(ranked.sort).toBe('descending');
const compared = applyIntent(ranked, 'compare', superstore);
expect(compared.sort).toBeUndefined(); // ranking's sort is dropped
});
});
describe('activeIntent (which chip the live chart lights)', () => {
it('lights the smart default on open (the coupling guard)', () => {
// The data-aware default IS an intent layout, so the strip auto-highlights it.
expect(activeIntent(defaultBuilderConfig('D', superstore), superstore)).toBe('compare');
const dateShape: BuilderColumns = {
columns: ['Order ID', 'Order Date', 'Sales'],
columnTypes: [
{ name: 'Order ID', type: 'string' },
{ name: 'Order Date', type: 'date' },
{ name: 'Sales', type: 'number' },
],
columnStats: [
stat('Order ID', 50, true),
stat('Order Date', 50, true),
stat('Sales', 50, true),
],
};
expect(activeIntent(defaultBuilderConfig('D', dateShape), dateShape)).toBe('time');
const scatterShape: BuilderColumns = {
columns: ['Order ID', 'Sales', 'Profit'],
columnTypes: [
{ name: 'Order ID', type: 'string' },
{ name: 'Sales', type: 'number' },
{ name: 'Profit', type: 'number' },
],
columnStats: [
stat('Order ID', 50, true),
stat('Sales', 50, true),
stat('Profit', 50, true),
],
};
expect(activeIntent(defaultBuilderConfig('D', scatterShape), scatterShape)).toBe(
'correlation',
);
});
it('reflects the picked intent, then reads Custom after a hand edit', () => {
const base = defaultBuilderConfig('D', superstore);
expect(activeIntent(applyIntent(base, 'heatmap', superstore), superstore)).toBe('heatmap');
// An edit that no intent produces (an area mark) → no chip lit.
const edited: BuilderConfig = { ...base, mark: 'area' };
expect(activeIntent(edited, superstore)).toBeNull();
});
});
});
describe('isBuilderConfigValid', () => {
const base: BuilderConfig = { datasetName: 'D', mark: 'bar', encodings: {} };
@@ -689,7 +939,7 @@ describe('buildChartSpec', () => {
});
it('carries every mark type through to the spec', () => {
for (const mark of ['bar', 'line', 'point', 'area', 'circle'] as const) {
for (const mark of MARK_TYPES) {
const spec = buildChartSpec({
datasetName: 'D',
mark,
@@ -932,6 +1182,69 @@ describe('transforms — aggregate / bin / timeUnit', () => {
}),
).toBe('Bar chart of unique customer by region');
});
it('names a heatmap "Heatmap of …" (not "Rect chart of …")', () => {
expect(
generateChartName({
datasetName: 'D',
mark: 'rect',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'segment', type: 'nominal' },
color: { field: 'revenue', type: 'quantitative', aggregate: 'sum' },
},
}),
).toBe('Heatmap of segment by region');
});
});
describe('role-aware guidance (bin is a dimension, not a measure)', () => {
// A 1-D histogram: bar, binned quantitative X, count Y. Both axes are quantitative by
// *type*, but the binned X is a discretized dimension by *role* — the false-positive
// class the role fix closes (eng-council 2026-06-13).
const histogram: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'amount', type: 'quantitative', bin: true },
y: { type: 'quantitative', aggregate: 'count' },
},
};
it('does not nudge a histogram toward a scatter (the binned axis is a dimension)', () => {
expect(builderWarnings(histogram).some((m) => /scatter/.test(m.message))).toBe(false);
expect(builderWarnings(histogram)).toEqual([]); // a clean histogram has no hints
});
it('does not offer Sort on a histogram (binned bins carry an inherent order)', () => {
expect(sortableCategoryChannel(histogram)).toBeUndefined();
expect(supportsSort(histogram)).toBe(false);
});
it('still offers Sort on a real category-vs-measure bar', () => {
const bar: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { type: 'quantitative', aggregate: 'count' },
},
};
expect(sortableCategoryChannel(bar)).toBe('x');
expect(supportsSort(bar)).toBe(true);
});
it('still nudges two RAW quantitative measures on a bar toward a scatter', () => {
const twoRaw: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'sales', type: 'quantitative' },
y: { field: 'profit', type: 'quantitative' },
},
};
expect(builderWarnings(twoRaw).some((m) => /scatter/.test(m.message))).toBe(true);
});
});
describe('sort (ranking)', () => {
+340 -27
View File
@@ -29,8 +29,9 @@ import { VEGA_LITE_SCHEMA_URL } from './snippet';
import { validateExpression } from './expr-validate';
import { escapeVegaField } from './rendering';
/** The five mark types the builder offers, in selector order (spec §06). */
export const MARK_TYPES = ['bar', 'line', 'point', 'area', 'circle'] as const;
/** The six mark types the builder offers, in selector order (spec §06). `rect` is the
* heatmap mark — an X×Y grid of cells shaded by a Colour measure. */
export const MARK_TYPES = ['bar', 'line', 'point', 'area', 'circle', 'rect'] as const;
export type MarkType = (typeof MARK_TYPES)[number];
/** The four Vega-Lite field types a channel may carry, in override-menu order. */
@@ -361,7 +362,9 @@ export function coerceChannelValue(channel: ChannelName, raw: string): string |
* - a single mapped axis, or nothing yet → **Bar** (the safe default)
*
* `null` means the channel is unmapped. This is the *default*; the user can switch
* to any of the five marks afterward.
* to any of the six marks afterward. **Heatmap (`rect`) is never auto-defaulted** —
* it reads only with a Colour measure, which the X/Y shape alone can't determine, so
* it stays a deliberate pick (the heatmap guidance nudges the missing Colour).
*/
export function defaultMark(xType: FieldType | null, yType: FieldType | null): MarkType {
if (xType === null || yType === null) return 'bar';
@@ -503,6 +506,237 @@ export function defaultBuilderConfig(datasetName: string, columns: BuilderColumn
return { datasetName, mark, encodings };
}
// ── Intent-first front door (spec §06 → Intent) ────────────────────────────────
//
// "What do you want to show?" — a small set of analytic intents (FT Visual
// Vocabulary / Datawrapper taxonomy) that each recommend a mark + channel layout
// from the dataset's column roles. The front door is an *on-ramp*, not a gate: it
// seeds the mark-first builder, which the user can then adjust or ignore, and the
// intent never enters the produced spec — it is builder-local steering only (the
// JSON spec stays the document). Mirrors Tableau "Show Me": intents the data can't
// satisfy are disabled, and the live chart's matching intent stays highlighted.
/** The intents the front door offers, in display order. */
export const CHART_INTENTS = [
'compare',
'ranking',
'time',
'correlation',
'distribution',
'partToWhole',
'heatmap',
] as const;
export type ChartIntent = (typeof CHART_INTENTS)[number];
/**
* Dataset columns split by the role they play in a chart. Categories are ordered
* **most-readable first** — a low-cardinality category (2…`CATEGORY_DEFAULT_MAX_DISTINCT`
* distinct) ahead of a constant or a high-cardinality one, then by ascending distinct
* — so `categories[0]` is the same readable axis `smartDefaultEncodings` prefers. That
* shared preference is what lets `activeIntent` light the smart default on open.
*/
function columnRoles(columns: BuilderColumns): {
categories: string[];
measures: string[];
temporals: string[];
} {
const typeOf = (name: string): ColumnType =>
columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
const distinctOf = (name: string): number => {
const s = columns.columnStats?.find((x) => x.name === name);
return s && !s.distinctCapped ? s.distinct : Number.POSITIVE_INFINITY;
};
const readable = (name: string): boolean => {
const d = distinctOf(name);
return d >= 2 && d <= CATEGORY_DEFAULT_MAX_DISTINCT;
};
const measures = columns.columns.filter((n) => typeOf(n) === 'number');
const temporals = columns.columns.filter((n) => typeOf(n) === 'date');
const categories = columns.columns
.filter((n) => typeOf(n) === 'string' || typeOf(n) === 'boolean')
.sort((a, b) => {
const ra = readable(a);
const rb = readable(b);
if (ra !== rb) return ra ? -1 : 1; // readable categories first
return distinctOf(a) - distinctOf(b); // then lowest-cardinality
});
return { categories, measures, temporals };
}
/** A fresh field-less count measure (never share a reference — configs are immutable). */
function countMapping(): ChannelMapping {
return { type: 'quantitative', aggregate: 'count' };
}
/**
* Whether the dataset has the column roles an intent needs to be meaningful. The
* front door **disables** the intents that don't apply (Tableau "Show Me": an
* unavailable chart is shown but inert) rather than producing a broken chart.
*/
export function intentApplicable(intent: ChartIntent, columns: BuilderColumns): boolean {
const { categories, measures, temporals } = columnRoles(columns);
switch (intent) {
case 'compare':
case 'ranking':
return categories.length >= 1; // a category axis vs a count
case 'time':
return temporals.length >= 1;
case 'correlation':
return measures.length >= 2; // two measures to cross
case 'distribution':
return measures.length >= 1; // a measure to bin
case 'partToWhole':
case 'heatmap':
return categories.length >= 2; // two categorical dimensions
}
}
/** Keep only the channels an intent actually sets, in canonical order. */
function pruneLayout(
enc: Partial<Record<ChannelName, ChannelMapping | undefined>>,
): Partial<Record<ChannelName, ChannelMapping>> {
const out: Partial<Record<ChannelName, ChannelMapping>> = {};
for (const ch of CHANNELS) {
const m = enc[ch];
if (m) out[ch] = m;
}
return out;
}
/**
* The mark + channel layout an intent recommends for these columns (spec §06 →
* Intent). A *partial* config — only the channels the intent sets, plus the mark and
* any sort/stack; `applyIntent` merges it onto the working config. Best-effort when a
* preferred column is absent (the UI disables fully-inapplicable intents via
* `intentApplicable`, so a returned partial is always at least renderable).
*/
// TODO: exported only for its direct unit test — `applyIntent`/`activeIntent` are its
// sole production callers, both in this module. If no external caller appears, drop the
// export and assert the layout table through `applyIntent` (the file's convention for
// internal helpers like `smartDefaultEncodings`).
export function intentLayout(
intent: ChartIntent,
columns: BuilderColumns,
): Pick<BuilderConfig, 'mark' | 'sort' | 'stack'> & {
encodings: Partial<Record<ChannelName, ChannelMapping>>;
} {
const { categories, measures, temporals } = columnRoles(columns);
const cat = (i: number): ChannelMapping | undefined =>
categories[i] !== undefined ? { field: categories[i], type: 'nominal' } : undefined;
const measure = (i: number): ChannelMapping | undefined =>
measures[i] !== undefined ? { field: measures[i], type: 'quantitative' } : undefined;
const temporal = (i: number): ChannelMapping | undefined =>
temporals[i] !== undefined ? { field: temporals[i], type: 'temporal' } : undefined;
switch (intent) {
case 'compare': // magnitude across categories → a tidy bar of counts
return { mark: 'bar', encodings: pruneLayout({ x: cat(0), y: countMapping() }) };
case 'ranking': // the same, ordered by value
return {
mark: 'bar',
sort: 'descending',
encodings: pruneLayout({ x: cat(0), y: countMapping() }),
};
case 'time': // a trend over time → a line of the first measure (or a count)
return {
mark: 'line',
encodings: pruneLayout({ x: temporal(0), y: measure(0) ?? countMapping() }),
};
case 'correlation': // two measures crossed → a scatter
return { mark: 'point', encodings: pruneLayout({ x: measure(0), y: measure(1) }) };
case 'distribution': {
// the spread of one measure → a histogram (binned measure × count)
const m = measure(0);
return {
mark: 'bar',
encodings: pruneLayout({ x: m ? { ...m, bin: true } : undefined, y: countMapping() }),
};
}
case 'partToWhole': // shares of a total → a stacked bar by a second category
return {
mark: 'bar',
stack: 'zero',
encodings: pruneLayout({ x: cat(0), y: countMapping(), color: cat(1) }),
};
case 'heatmap': // a two-category grid shaded by count
return {
mark: 'rect',
encodings: pruneLayout({ x: cat(0), y: cat(1), color: countMapping() }),
};
}
}
/**
* Reshape the working config to an intent's recommended layout (spec §06 → Intent),
* the front door's "do it for me". Replaces mark + encodings + sort + stack with the
* intent's; **keeps** the dataset, the data transforms (filters / calculated fields)
* and the chart-level title/subtitle/size — all orthogonal to *what kind of chart*.
* Pure; the store calls it when the user picks an intent.
*/
export function applyIntent(
config: BuilderConfig,
intent: ChartIntent,
columns: BuilderColumns,
): BuilderConfig {
const layout = intentLayout(intent, columns);
const next: BuilderConfig = {
...config,
mark: layout.mark,
encodings: { x: null, y: null, color: null, size: null, ...layout.encodings },
};
if (layout.sort) next.sort = layout.sort;
else delete next.sort;
if (layout.stack) next.stack = layout.stack;
else delete next.stack;
return next;
}
/** Deep-equal two channel mappings (or nulls) on every field the builder emits. */
function sameMapping(a: ChannelMapping | null, b: ChannelMapping | null): boolean {
if (a === null || b === null) return a === b;
return (
a.field === b.field &&
a.type === b.type &&
a.aggregate === b.aggregate &&
!!a.bin === !!b.bin &&
a.timeUnit === b.timeUnit &&
a.value === b.value
);
}
/** Whether the config's mark/encodings/sort/stack match an intent's layout exactly. */
function configMatchesIntent(
config: BuilderConfig,
intent: ChartIntent,
columns: BuilderColumns,
): boolean {
const layout = intentLayout(intent, columns);
if (config.mark !== layout.mark) return false;
if ((config.sort ?? null) !== (layout.sort ?? null)) return false;
if ((config.stack ?? null) !== (layout.stack ?? null)) return false;
const want = { x: null, y: null, color: null, size: null, ...layout.encodings };
for (const ch of CHANNELS) {
if (!sameMapping(config.encodings[ch] ?? null, want[ch] ?? null)) return false;
}
return true;
}
/**
* The intent whose recommended layout the current config matches, or `null`
* ("Custom") once the user has edited away from any. Lets the front-door strip
* highlight the live chart's intent — and auto-light the smart default on open —
* **without storing the intent** (the config stays the single source of truth, so a
* dataset switch or a hand-built layout resolves correctly too). A structural match,
* preferring the first applicable intent on the rare tie.
*/
export function activeIntent(config: BuilderConfig, columns: BuilderColumns): ChartIntent | null {
return (
CHART_INTENTS.find(
(intent) => intentApplicable(intent, columns) && configMatchesIntent(config, intent, columns),
) ?? null
);
}
/** The channels actually mapped (a column field, or a field-less count), in order. */
function mappedChannels(config: BuilderConfig): Array<[ChannelName, ChannelMapping]> {
return CHANNELS.flatMap((channel) => {
@@ -520,10 +754,21 @@ function effectiveType(mapping: ChannelMapping): FieldType {
: mapping.type;
}
/** True when a mapping reads as a continuous measure (count/aggregate or continuous type). */
/**
* Whether a mapping reads as a continuous **measure** — the post-transform *role*, not
* the raw field type. A constant encodes no data; a **binned** field is a discretized
* *dimension* (the same notion as Vega-Lite's own `isDiscrete(fieldDef)`, which returns
* true for a binned quantitative); everything else continuous (raw quantitative/temporal,
* or a count/sum/etc. aggregate) is a measure. Bin and aggregate are mutually exclusive,
* so the bin guard never hides a real aggregate measure.
*
* The builder's guidance must ask this *role* question, never `effectiveType === 'quantitative'`
* directly — a binned axis is quantitative by type but a dimension by role; conflating the two
* makes a histogram (binned X + count Y) trip the two-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), "<Mark> chart" for the rest. */
function markNoun(mark: MarkType): string {
return mark === 'rect' ? 'Heatmap' : `${markLabel(mark)} chart`;
}
/** A human phrase for what a channel encodes, e.g. "sum of revenue", "count". */
function describeMapping(mapping: ChannelMapping): string {
if (mapping.value !== undefined) return 'a constant';
@@ -1177,11 +1490,11 @@ export function generateChartName(config: BuilderConfig): string {
// A user-written chart title is the best possible name — prefer it verbatim.
const title = config.title?.trim();
if (title) return title;
const mark = markLabel(config.mark);
const noun = markNoun(config.mark);
const x = config.encodings.x;
const y = config.encodings.y;
if (x && y) return `${mark} chart of ${describeMapping(y)} by ${describeMapping(x)}`;
if (x && y) return `${noun} of ${describeMapping(y)} by ${describeMapping(x)}`;
const only = mappedChannels(config)[0];
if (only) return `${mark} chart of ${describeMapping(only[1])}`;
return `${mark} chart of ${config.datasetName}`;
if (only) return `${noun} of ${describeMapping(only[1])}`;
return `${noun} of ${config.datasetName}`;
}