mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart builder: intent front door, Heatmap mark, role-aware guidance
This commit is contained in:
@@ -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
@@ -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}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user