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
+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}`;
}