Files
astrolabe/src/core/chart-builder.ts
T

1501 lines
66 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Chart Builder — pure Vega-Lite spec assembler (spec §06).
*
* Portable core: no browser APIs, no React, no store access. Turns a no-JSON
* builder configuration (a mark, four optional encoding channels mapped to dataset
* columns, with per-channel transforms, plus chart-level sort/stack and optional
* pixel dimensions) into a complete Vega-Lite spec that references the source
* dataset by name. The UI layer owns the controls; this module owns the spec
* grammar — what a configuration *means* as Vega-Lite — and the defaults the spec
* prescribes (pre-population, field-type derivation, smart mark).
*
* Beyond the Tier-B floor, a channel may carry a **transform** — an `aggregate`
* (count/sum/mean/…), a quantitative `bin`, or a temporal `timeUnit` granularity —
* and the chart may carry a `sort` (rank a categorical axis by its measure) and a
* `stack` (part-to-whole for bar/area + colour). See
* docs/chart-builder-research.md §8 for why each exists.
*
* The produced spec mirrors what the rest of Astrolabe authors by hand: a `$schema`
* stamp (shared with the sample template), a named-data reference the renderer
* resolves at preview time (rendering.ts), a mark with tooltips enabled, the mapped
* encodings, and any explicit width/height. It is the same string-spec shape the
* editor and preview consume — `buildSnippetSpecText` serializes it.
*/
import type { ColumnStats } from './profile';
import { DISTINCT_CAP } from './profile';
import type { ColumnType } from './type-inference';
import { VEGA_LITE_SCHEMA_URL } from './snippet';
import { validateExpression } from './expr-validate';
import { escapeVegaField } from './rendering';
/** 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. */
export type FieldType = 'quantitative' | 'nominal' | 'ordinal' | 'temporal';
/** The four encoding channels the builder offers, in display order (spec §06). */
export const CHANNELS = ['x', 'y', 'color', 'size'] as const;
export type ChannelName = (typeof CHANNELS)[number];
/**
* Aggregation operators a channel may apply (Vega-Lite `aggregate`). `count` is
* special — it is **field-less** (counts records), so a `count` mapping carries no
* `field`. `distinct` counts a field's unique values, so it applies to **any** field
* type and reads as a quantitative measure. The arithmetic ops (sum/mean/median)
* reduce a quantitative field; min/max also order a temporal or ordinal one. See
* `validAggregateOps` for the per-type menu.
*/
export type AggregateOp = 'count' | 'distinct' | 'sum' | 'mean' | 'median' | 'min' | 'max';
/**
* Temporal granularities (Vega-Lite `timeUnit`), coarse → fine, with the combined
* units that make a real time axis. Offered when a channel's type is Temporal.
*/
export const TIME_UNITS = [
'year',
'yearquarter',
'yearmonth',
'yearmonthdate',
'quarter',
'month',
'week',
'date',
'day',
'hours',
] as const;
export type TimeUnit = (typeof TIME_UNITS)[number];
/** Sort the categorical axis by its measure (spec §06 → Ranking). */
export type SortOrder = 'ascending' | 'descending';
/** Part-to-whole stacking for bar/area + a colour series: absolute vs 100%. */
export type StackMode = 'zero' | 'normalize';
/**
* Comparison operators a guarded filter predicate offers (Vega-Lite field
* predicates), in menu order. The set a given field admits is narrowed by
* `validFilterOps` — only a measure/temporal field offers ordering (`lt`…`gt`) and
* `range`; a category offers membership (`oneOf`). `notEqual` is expressed as a
* `{ not: { …equal } }` logical wrapper (Vega-Lite has no bare `!=` predicate).
*/
export type FilterOp = 'equal' | 'notEqual' | 'lt' | 'lte' | 'gt' | 'gte' | 'range' | 'oneOf';
/** How a filter expresses its predicate: a guarded shelf, or a raw expression. */
export type FilterMode = 'predicate' | 'expression';
/**
* One top-level data filter (Vega-Lite `transform: [{ filter }]`), applied to the
* raw rows **before** encoding aggregation — so "filter rows, then aggregate" is the
* natural reading. Two modes share one shape (the UI toggles between them on a single
* row):
*
* - **predicate** — a guarded `field op value` triple (Voyager-style, no expression
* to write for the common case). `range` carries a second bound (`value2`);
* `oneOf` reads `value` as a comma-separated membership list. Values are coerced
* by `fieldType` (a quantitative field compares as a number).
* - **expression** — a raw Vega expression string (`datum.x > 0`), the power-form
* for what the guarded shelf can't say (validated in the UI via
* `expr-validate.ts`).
*
* `id` is a stable key for list editing only; it never reaches the produced spec.
* A partially-filled filter (no value yet, empty expression) is simply skipped by
* the assembler, so the live preview keeps rendering while the user types.
*/
export interface BuilderFilter {
/** Stable list key (UI-assigned); not serialized into the spec. */
id: string;
/** Which form this filter takes. */
mode: FilterMode;
/** Predicate mode: the column being filtered (a dataset or calculated field). */
field?: string;
/** Predicate mode: the field's Vega-Lite type — drives the op menu + coercion. */
fieldType?: FieldType;
/** Predicate mode: the comparison operator. */
op?: FilterOp;
/** Predicate mode: the comparison value (`range` lower bound; `oneOf` CSV list). */
value?: string;
/** Predicate mode: the upper bound, for `range` only. */
value2?: string;
/** Expression mode: a raw Vega predicate expression. */
expr?: string;
}
/**
* One derived field (Vega-Lite `transform: [{ calculate, as }]`): a row-wise Vega
* expression producing a new column the rest of the builder can encode like any
* other. Calculates are emitted **before** filters (a row-wise calculate is
* order-independent — it computes the same per-row value regardless of which rows
* survive — so running it first is equivalent and lets a filter reference the
* derived field). `id` is a UI list key only.
*/
export interface BuilderCalculate {
/** Stable list key (UI-assigned); not serialized into the spec. */
id: string;
/** The Vega expression, e.g. `datum.price * datum.quantity`. */
expr: string;
/** The new field's name (appears in the channel dropdowns once non-empty). */
as: string;
}
/**
* One channel's binding. Two kinds share this shape — the **Property model**: one
* control that holds either a field or a constant.
*
* - **field** — a dataset column `field` plus its `type`, with optional transforms.
* `field` is omitted only for a `count` aggregate (which counts records rather
* than reducing a column).
* - **value** — a fixed constant (Vega-Lite `{ value }`): a literal colour or size
* applied to every mark, with no field/type/transform. `isValueMapping`
* discriminates on `value` being present; the channel's `type` is preserved (but
* ignored) while a value is set, so toggling back to a field restores it.
*
* A channel left on "None" is `null` in the config (omitted from the spec).
*/
export interface ChannelMapping {
/**
* A constant value (Vega-Lite `{ value }`) — a fixed colour/size applied to every
* mark. When set, this channel is a CONSTANT: `field` and the transforms are not
* emitted (`encodingObject` returns `{ value }`). Only colour/size offer it in the
* UI (`channelAcceptsValue`), but the assembler handles it on any channel.
*/
value?: string | number | boolean;
/** The dataset column. Omitted for a `count` aggregate or when `value` is set. */
field?: string;
/** The Vega-Lite field type (see `validFieldTypes`); ignored while `value` is set. */
type: FieldType;
/** Aggregation op; `count` is field-less, the rest reduce a quantitative field. */
aggregate?: AggregateOp;
/** Bin a quantitative field into ranges (e.g. for a histogram). */
bin?: boolean;
/** Temporal granularity for a Temporal field. */
timeUnit?: TimeUnit;
}
/** The full builder configuration the assembler consumes. */
export interface BuilderConfig {
/** The dataset the spec references by name (`{ data: { name } }`). */
datasetName: string;
/** The active mark type. */
mark: MarkType;
/** Per-channel mapping; `null` (or absent) means the channel is unmapped. */
encodings: Partial<Record<ChannelName, ChannelMapping | null>>;
/** Optional chart title (Vega-Lite top-level `title`). */
title?: string;
/** Optional subtitle; emitted only alongside a title (VL nests it under `title`). */
subtitle?: string;
/** Optional explicit chart width in pixels. */
width?: number;
/** Optional explicit chart height in pixels. */
height?: number;
/** Sort the categorical positional axis by the measure axis (spec §06 → Ranking). */
sort?: SortOrder;
/** Stacking for bar/area + a colour series (part-to-whole). */
stack?: StackMode;
/** Derived fields (`transform: [{ calculate, as }]`), emitted before `filters`. */
calculates?: BuilderCalculate[];
/** Row filters (`transform: [{ filter }]`), applied before encoding aggregation. */
filters?: BuilderFilter[];
}
/**
* The field types a column may legitimately carry, given its inferred type — the
* options the channel's type control offers (spec §06 → Tier B, valid-type
* locking). A string/boolean is never Quantitative and a non-date is never
* Temporal (Vega-Lite would mis-encode or error); a number defaults to
* Quantitative but may be treated as a category. Ordinal is offered wherever the
* user might reasonably assert an order (numbers, text), a deliberate superset of
* Voyager's stricter menu. The list head is the default (see `defaultFieldType`).
*
* Convergent across the research: Voyager `getValidTypes`
* (data-pane/field-list.tsx) + Draco `hard.lp` enc_type_valid (a string/boolean
* can't be quantitative; temporal requires datetime). See
* docs/chart-builder-research.md §4.
*/
export function validFieldTypes(columnType: ColumnType): FieldType[] {
switch (columnType) {
case 'number':
return ['quantitative', 'ordinal', 'nominal'];
case 'date':
return ['temporal'];
case 'boolean':
return ['nominal'];
default: // 'string'
return ['nominal', 'ordinal'];
}
}
/**
* Default Vega-Lite field type for a column from its inferred type (spec §06):
* numeric → Quantitative, date → Temporal, everything else (text, boolean) →
* Nominal. The default is the head of `validFieldTypes`, so the two never drift.
* The user may override afterward, within `validFieldTypes`.
*/
export function defaultFieldType(columnType: ColumnType): FieldType {
return validFieldTypes(columnType)[0];
}
/** Continuous (measure-like) field types — quantitative and temporal. */
function isContinuous(type: FieldType): boolean {
return type === 'quantitative' || type === 'temporal';
}
/**
* Whether a field type may be placed on a channel at all (spec §06 → Tier B, Size
* discipline). X/Y/Color accept any type; **Size accepts only Quantitative or
* Ordinal** — encoding a category or a date by symbol size is misleading (size
* implies ordered magnitude). Draco makes this a hard constraint
* (`hard.lp:53` size_nominal); we surface it as a UI gate that disables Size for
* unsuitable columns rather than letting the user produce the bad encoding.
* (Negative-value exclusion, `hard.lp:56`, needs row data — `builderWarnings`
* surfaces it as a soft hint from the column's profiled numeric extent; this
* type-level gate is the part the builder can enforce structurally.)
*/
export function isChannelTypeAllowed(channel: ChannelName, type: FieldType): boolean {
if (channel === 'size') return type === 'quantitative' || type === 'ordinal';
return true;
}
/**
* Whether a dataset column may be placed on a channel at all, judged by its **default**
* field type (spec §06 → Size discipline). X/Y/Color accept any column; Size accepts a
* column only when its natural type reads as a magnitude (a numeric → Quantitative).
* The channel's type control narrows further per field. Used to disable unsuitable
* columns in the field shelf and to pick an auto-assign target when a field is clicked.
*/
export function isColumnAllowedOnChannel(channel: ChannelName, columnType: ColumnType): boolean {
return isChannelTypeAllowed(channel, defaultFieldType(columnType));
}
/**
* The non-count aggregates that legitimately apply to a field of this type — the
* channel's Aggregate menu. Arithmetic reduction (sum/mean/median) needs numbers;
* min/max need an ordering (numbers, dates, asserted-ordinal values); `distinct`
* (count of unique values) applies to anything — the natural measure to wring out
* of a category ("how many unique customers"), which is why the menu is per-type
* rather than quantitative-only.
*/
export function validAggregateOps(type: FieldType): Exclude<AggregateOp, 'count'>[] {
switch (type) {
case 'quantitative':
return ['sum', 'mean', 'median', 'min', 'max', 'distinct'];
case 'temporal':
case 'ordinal':
return ['min', 'max', 'distinct'];
case 'nominal':
return ['distinct'];
}
}
/** Whether any non-count aggregate (sum/…/distinct) can apply to this field type. */
export function supportsAggregate(type: FieldType): boolean {
return validAggregateOps(type).length > 0;
}
/** Whether binning into ranges can apply to this field type. */
export function supportsBin(type: FieldType): boolean {
return type === 'quantitative';
}
/** Whether a temporal granularity (timeUnit) can apply to this field type. */
export function supportsTimeUnit(type: FieldType): boolean {
return type === 'temporal';
}
/**
* Whether a channel mapping is a **constant value** (Vega-Lite `{ value }`) rather
* than a field binding — the discriminator of the Property model. A constant has no
* field, type, or transform; it colours/sizes every mark the same.
*/
export function isValueMapping(mapping: ChannelMapping): boolean {
return mapping.value !== undefined;
}
/**
* Whether a channel offers the constant-value control in the UI. A fixed **colour**
* or **size** is both common and awkward in JSON (the promotion test), so it earns a
* control; a constant X/Y position is not useful, so X/Y stay field-only. The
* assembler emits a value on any channel — this only gates where the toggle appears,
* and generalizes to channels added later (e.g. opacity).
*/
export function channelAcceptsValue(channel: ChannelName): boolean {
return channel === 'color' || channel === 'size';
}
/**
* A sensible starting constant when a channel is first switched to value mode:
* Vega-Lite's default categorical blue for colour, a clearly-visible 100 for size.
* The user adjusts it from there.
*/
export function defaultChannelValue(channel: ChannelName): string | number {
return channel === 'size' ? 100 : '#4c78a8';
}
/**
* Coerce a constant-value text input to the JS type its channel expects, so the
* emitted `{ value }` carries the right type: **size** is a magnitude → a number (a
* blank/non-numeric entry falls back to the raw string rather than NaN); every other
* channel keeps the string (a colour is a CSS string). Mirrors the filter shelf's
* `coerceFilterValue` discipline.
*/
export function coerceChannelValue(channel: ChannelName, raw: string): string | number {
if (channel === 'size') {
const n = Number(raw);
return raw.trim() !== '' && Number.isFinite(n) ? n : raw;
}
return raw;
}
/**
* The mark that best fits the X/Y field-type shape (spec §06 → Tier B, smart
* default mark) — the research's strongest convergence (Draco mark-by-shape soft
* constraints, Voyager effectiveness, FT, Datawrapper all agree;
* docs/chart-builder-research.md §4):
*
* - temporal × quantitative → **Line** (a time series)
* - quantitative × quantitative → **Point** (a scatter)
* - one continuous + one discrete axis → **Bar** (category vs measure)
* - both axes discrete → **Point** (Bar/Line/Area need a continuous axis)
* - 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 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';
const timeVsMeasure =
(xType === 'temporal' && yType === 'quantitative') ||
(xType === 'quantitative' && yType === 'temporal');
if (timeVsMeasure) return 'line';
const xc = isContinuous(xType);
const yc = isContinuous(yType);
if (xc && yc) return 'point'; // two measures → scatter
if (xc === yc) return 'point'; // both discrete → bar/line/area are invalid here
return 'bar'; // one continuous axis, one categorical → category-vs-measure bar
}
/** A dataset's columns paired with their inferred types — the builder's input. */
export interface BuilderColumns {
columns: readonly string[];
columnTypes: ReadonlyArray<{ name: string; type: ColumnType }>;
/**
* Per-column cardinality + numeric range, when the dataset was profiled with it
* (datasets created before this profiling, and URL/non-tabular data, omit it).
* The data-aware `builderWarnings` use it where present and silently skip those
* hints where absent.
*/
columnStats?: ReadonlyArray<ColumnStats>;
}
/** The derived field type for a named column, defaulting to Nominal if unknown. */
function fieldTypeForColumn(name: string, columns: BuilderColumns): FieldType {
const match = columns.columnTypes.find((c) => c.name === name);
return defaultFieldType(match?.type ?? 'string');
}
/**
* Above this many distinct values, a column is too high-cardinality to be a good
* default category axis: its labels overlap into an unreadable axis, and an
* unaggregated chart that wide can exceed the canvas size limit. Matches the
* crowded-axis warning threshold.
*/
const CATEGORY_DEFAULT_MAX_DISTINCT = 30;
/**
* Pick a *sensible, renderable* default X/Y from the data shape, using profiled
* cardinality (`columnStats`). This avoids opening the builder on a degenerate
* one-mark-per-row chart — the shape a positional first-two-columns rule yields when
* the leading columns are an id and a high-cardinality key. Returns `null` when there
* are no stats to reason about (older / URL datasets), so the caller falls back to the
* positional default.
*
* Preference order (each guaranteed to render and read cleanly):
* 1. a low-cardinality **category** vs a **count of records** → a tidy bar;
* 2. else a **temporal** axis vs count → a time series (continuous x, always fits);
* 3. else two **measures** → a scatter (continuous axes, always fit).
*
* The category case pairs with the field-less **count**, not a raw measure: count is
* always meaningful and avoids summing an id-like numeric (Row ID, Postal Code) into
* nonsense. This is the builder's *opening* state only; a later intent-first entry
* point can layer richer recommendations on top.
*/
function smartDefaultEncodings(
columns: BuilderColumns,
): { x: ChannelMapping; y: ChannelMapping } | null {
const stats = columns.columnStats;
if (!stats || stats.length === 0) return null; // no profiling → positional fallback
const typeOf = (name: string): ColumnType =>
columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
const knownDistinct = (name: string): number | undefined => {
const s = stats.find((x) => x.name === name);
return s && !s.distinctCapped ? s.distinct : undefined;
};
const count: ChannelMapping = { type: 'quantitative', aggregate: 'count' };
const numbers = columns.columns.filter((name) => typeOf(name) === 'number');
// 1. The lowest-cardinality readable category (string/boolean) → a tidy bar. Pair it
// with **count** (not a raw measure): a raw measure would draw one bar per row.
const category = columns.columns
.filter((name) => {
const t = typeOf(name);
if (t !== 'string' && t !== 'boolean') return false;
const d = knownDistinct(name);
return d !== undefined && d >= 2 && d <= CATEGORY_DEFAULT_MAX_DISTINCT;
})
.sort((a, b) => (knownDistinct(a) ?? 0) - (knownDistinct(b) ?? 0))[0];
if (category) return { x: { field: category, type: 'nominal' }, y: count };
// 2. A date → a time series of the first measure (a temporal axis is continuous, so a
// line of raw values always fits); fall back to count if there is no measure.
const temporal = columns.columns.find((name) => typeOf(name) === 'date');
if (temporal) {
const y: ChannelMapping = numbers[0] ? { field: numbers[0], type: 'quantitative' } : count;
return { x: { field: temporal, type: 'temporal' }, y };
}
// 3. Two measures → a scatter (continuous axes, always renderable).
if (numbers.length >= 2) {
return {
x: { field: numbers[0], type: 'quantitative' },
y: { field: numbers[1], type: 'quantitative' },
};
}
return null;
}
/**
* The builder's opening configuration for a dataset (spec §06 → Default
* pre-population, Tier B). When the dataset is profiled, X/Y are chosen as a
* **data-aware "safest bet"** (`smartDefaultEncodings`) — a low-cardinality category
* vs a count of records (a tidy bar), else a time series, else a scatter — so the
* builder never opens on a degenerate one-mark-per-row chart that can't render. When
* there are no stats to reason about, it falls back to the positional rule: first
* column on X, second (if any) on Y, each with its derived field type. Either way the
* mark is the **smart default** for the resulting X/Y shape (`defaultMark`), Color
* and Size start unmapped with no transforms, and a dataset with no detected columns
* yields an all-unmapped config (the modal then prompts).
*/
export function defaultBuilderConfig(datasetName: string, columns: BuilderColumns): BuilderConfig {
const encodings: Partial<Record<ChannelName, ChannelMapping | null>> = {
x: null,
y: null,
color: null,
size: null,
};
// Prefer a data-aware "safest bet" (a renderable, readable chart) when the dataset
// is profiled; otherwise fall back to the positional first-on-X, second-on-Y rule.
const smart = smartDefaultEncodings(columns);
if (smart) {
encodings.x = smart.x;
encodings.y = smart.y;
} else {
const [first, second] = columns.columns;
if (first !== undefined) {
encodings.x = { field: first, type: fieldTypeForColumn(first, columns) };
}
if (second !== undefined) {
encodings.y = { field: second, type: fieldTypeForColumn(second, columns) };
}
}
const mark = defaultMark(encodings.x?.type ?? null, encodings.y?.type ?? null);
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) => {
const mapping = config.encodings[channel];
return mapping ? [[channel, mapping] as [ChannelName, ChannelMapping]] : [];
});
}
/** The effective field type a mapping encodes: a count or a distinct-count reads as
* a quantitative measure whatever the underlying field; other aggregates (sum, a
* temporal min/…) keep the field's own type. */
function effectiveType(mapping: ChannelMapping): FieldType {
return mapping.aggregate === 'count' || mapping.aggregate === 'distinct'
? 'quantitative'
: mapping.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 {
if (isValueMapping(mapping)) return false;
if (mapping.bin) return false; // a binned field is a discretized dimension, not a measure
return isContinuous(effectiveType(mapping));
}
/**
* Whether the configuration is renderable / saveable (spec §06 → Validation): at
* least one channel must be mapped. The modal gates the Create action and the
* preview prompt on this.
*/
export function isBuilderConfigValid(config: BuilderConfig): boolean {
return mappedChannels(config).length > 0;
}
/**
* 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;
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. 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) &&
!mapping.bin &&
effectiveType(mapping) === 'quantitative'
)
return channel;
}
return undefined;
}
/**
* Whether Color is bound to a **field** (a real per-value series + legend) rather
* than a constant. Stacking and the area-split hint care about a colour *series*; a
* fixed colour produces neither, so both treat a constant Color as no colour at all.
*/
function colorIsSeries(config: BuilderConfig): boolean {
const color = config.encodings.color;
return !!color && !isValueMapping(color);
}
/**
* Whether sorting can be offered for this config (a clear category-vs-measure axis
* pair exists). The UI shows the Sort control only when true.
*/
export function supportsSort(config: BuilderConfig): boolean {
return sortableCategoryChannel(config) !== undefined;
}
/**
* Whether stacking can be offered: a bar/area mark with a colour series and a
* quantitative positional axis to stack along (spec §06 → part-to-whole).
*/
export function supportsStack(config: BuilderConfig): boolean {
return (
(config.mark === 'bar' || config.mark === 'area') &&
colorIsSeries(config) &&
stackMeasureChannel(config) !== undefined
);
}
/**
* A one-click remedy a warning can offer. `apply` is a pure config→config transform;
* the modal renders `label` as a button that runs it. It is an *offer*, never a forced
* change — once applied, the warning re-derives away. Lives in core so the remedies
* unit-test alongside the warnings.
*/
export interface BuilderWarningFix {
/** The button label naming the remedy, e.g. "Aggregate as Sum". */
label: string;
/** Produce the corrected configuration from the current one (pure). */
apply: (config: BuilderConfig) => BuilderConfig;
}
/** A non-blocking advisory about a configuration (spec §06 → Tier B warnings). */
export interface BuilderWarning {
/** The channel the hint is about, when it's channel-specific. */
channel?: ChannelName;
/** A short, plain-language hint the modal shows inline (not an error). */
message: string;
/** Optional one-click remedies the modal renders as buttons next to the hint. */
fixes?: BuilderWarningFix[];
}
/**
* Above this many rows, a category-vs-measure bar/line/area with a **raw**
* (un-aggregated) measure draws so many marks — one per row — that the category
* axis becomes an unreadable picket fence of labels. The threshold is a legibility
* estimate, not a hard limit (the chart still renders); it's set where vertical bar
* labels reliably start overlapping. See `builderWarnings`.
*/
const CROWDED_CATEGORY_ROWS = 30;
/**
* Above this many *distinct* category values, even an aggregated category axis (one
* mark per category, not per row) has too many labels to read. Matched to
* `CROWDED_CATEGORY_ROWS`: both flag a category axis that overruns its labels.
*/
const CROWDED_CATEGORY_DISTINCT = 30;
/**
* Above this many distinct colour values a discrete legend is too long to scan and
* the palette starts recycling hues. Qualitative colour scales top out around 812
* across the canon (Datawrapper, FT Visual Vocabulary); 12 is the generous end.
*/
const CROWDED_LEGEND_DISTINCT = 12;
/** Profiled stats for a mapped field, when the dataset carries them. */
function statsFor(field: string | undefined, columns?: BuilderColumns): ColumnStats | undefined {
if (!field || !columns?.columnStats) return undefined;
return columns.columnStats.find((s) => s.name === field);
}
/** "N" or "more than 50" — distinct count, honouring the profiler's cap. */
function cardinalityText(stats: ColumnStats): string {
return stats.distinctCapped ? `more than ${DISTINCT_CAP}` : `${stats.distinct}`;
}
// --- Pure config transforms backing the actionable-hint fixes. They mirror the
// store's setChannelAggregate / swapXY / setStack / setChannelColumn(null) / setMark
// actions, so applying a fix and making the equivalent manual edit land on the same
// config. Kept here (not the store) so the remedies are pure and unit-testable. ---
/** Aggregate one channel's field (clearing any bin — the two are mutually exclusive). */
function withChannelAggregate(
config: BuilderConfig,
channel: ChannelName,
aggregate: AggregateOp,
): BuilderConfig {
const current = config.encodings[channel];
if (!current) return config;
const next: ChannelMapping = { ...current, aggregate };
delete next.bin;
return { ...config, encodings: { ...config.encodings, [channel]: next } };
}
/** Exchange the X and Y mappings (the manual Swap X/Y, as a pure transform). */
function withSwappedXY(config: BuilderConfig): BuilderConfig {
return {
...config,
encodings: {
...config.encodings,
x: config.encodings.y ?? null,
y: config.encodings.x ?? null,
},
};
}
/** Clear one channel back to "None" (drops its mapping from the spec). */
function withChannelCleared(config: BuilderConfig, channel: ChannelName): BuilderConfig {
return { ...config, encodings: { ...config.encodings, [channel]: null } };
}
/**
* Non-blocking advisories for the current configuration (spec §06 → Tier B): the
* encodings that render but read poorly, drawn from the research's soft rules
* (docs/chart-builder-research.md §4, §7). These never block Create — `isBuilder
* ConfigValid` is the only gate — they just steer the user toward a better chart.
* Returned in a stable order so the UI list doesn't jitter as config changes.
*
* `rowCount` (the dataset's row count, when known) powers the one-mark-per-row hint;
* `columns` (with per-column `columnStats`) powers the data-aware hints — crowded
* legend / category axis (cardinality) and the negative-value Size guard (numeric
* extent). Either omitted (URL / non-tabular / pre-cardinality datasets) simply
* skips the hints that need it.
*/
export function builderWarnings(
config: BuilderConfig,
rowCount?: number | null,
columns?: BuilderColumns,
): BuilderWarning[] {
const warnings: BuilderWarning[] = [];
const x = config.encodings.x ?? null;
const y = config.encodings.y ?? null;
const { mark } = config;
// 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: `${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 (
(mark === 'bar' || mark === 'line' || mark === 'area') &&
x !== null &&
y !== null &&
!isMeasureMapping(x) &&
!isMeasureMapping(y)
) {
warnings.push({
message: `${markLabel(mark)} charts need a measure (a value or count) on the X or Y axis.`,
});
}
// 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' &&
isMeasureMapping(y) &&
effectiveType(y) === 'quantitative'
) {
warnings.push({
message: 'Two measures usually read best as a scatter.',
fixes: [{ label: 'Switch to Point', apply: (c) => ({ ...c, mark: 'point' }) }],
});
}
// Area split into many series hides per-component change (FT Visual Vocabulary:
// "seeing change in components can be very difficult").
// (Stacking turns overlapping series into a cumulative part-to-whole, a valid read,
// so a stacked area is not flagged — applying the [Stack] fix below clears this.)
if (mark === 'area' && colorIsSeries(config) && !config.stack) {
const fixes: BuilderWarningFix[] = [];
if (supportsStack(config)) {
fixes.push({ label: 'Stack', apply: (c) => ({ ...c, stack: 'zero' }) });
}
fixes.push({ label: 'Remove colour', apply: (c) => withChannelCleared(c, 'color') });
warnings.push({
channel: 'color',
message: 'Area charts make per-series change hard to read.',
fixes,
});
}
// Crowded category axis: a bar/line/area whose discrete category axis carries too
// many entries to label legibly. Two ways it happens, mutually exclusive:
// 1. A *raw* (un-aggregated, un-binned) measure draws one mark per row, so the
// label count == rowCount — flagged from rowCount alone (the builder's own
// default does this: first column on X, second on Y, no aggregate).
// 2. Otherwise (aggregated/binned), one mark per *category* — crowded only when
// the category column itself has many distinct values, which needs the
// profiled cardinality (columnStats) and is skipped without it.
// The remedy follows the canon: for case 1 aggregate to one mark per category; for
// either, a bar can flip to horizontal where long labels stay readable (FT Visual
// Vocabulary: bar is "good when … labels have long category names"; Datawrapper:
// long category lists belong on a horizontal bar).
if (mark === 'bar' || mark === 'line' || mark === 'area') {
const category = sortableCategoryChannel(config); // discrete axis of a category-vs-measure pair
const measureChannel = category ? (category === 'x' ? 'y' : 'x') : null;
const measure = measureChannel ? (config.encodings[measureChannel] ?? null) : null;
if (category && measureChannel && measure) {
const rawMeasure = !measure.aggregate && !measure.bin;
if (rawMeasure && typeof rowCount === 'number' && rowCount > CROWDED_CATEGORY_ROWS) {
// Aggregating the measure collapses one-mark-per-row to one-per-category; a bar
// can also flip horizontal (Swap X/Y) where long labels stay readable. Offer
// aggregate only for a quantitative measure (Sum is meaningless on a date).
const fixes: BuilderWarningFix[] = [];
if (effectiveType(measure) === 'quantitative') {
fixes.push({
label: 'Aggregate as Sum',
apply: (c) => withChannelAggregate(c, measureChannel, 'sum'),
});
}
if (mark === 'bar') fixes.push({ label: 'Swap X/Y', apply: withSwappedXY });
warnings.push({
channel: category,
message: `This draws one mark per row (${rowCount} in this dataset), so the category-axis labels will overlap.`,
fixes: fixes.length ? fixes : undefined,
});
} else {
const stats = statsFor(config.encodings[category]?.field, columns);
if (stats && stats.distinct > CROWDED_CATEGORY_DISTINCT) {
// Aggregated already, so the remedy is fewer categories (filter — not yet a
// builder control) or, for a bar, a horizontal flip where long lists fit.
const fixes: BuilderWarningFix[] =
mark === 'bar' ? [{ label: 'Swap X/Y', apply: withSwappedXY }] : [];
warnings.push({
channel: category,
message: `This category axis has ${cardinalityText(stats)} distinct values, so its labels will overlap.`,
fixes: fixes.length ? fixes : undefined,
});
}
}
}
}
// Crowded colour legend: a discrete colour series with many distinct values makes
// the legend too long to scan and forces the palette to recycle hues. A continuous
// colour ramp (quantitative/temporal) has no per-value legend, so this is for
// discrete colour only (Datawrapper/FT cap categorical colour low).
const color = config.encodings.color ?? null;
if (color && !isMeasureMapping(color)) {
const stats = statsFor(color.field, columns);
if (stats && stats.distinct > CROWDED_LEGEND_DISTINCT) {
warnings.push({
channel: 'color',
message: `Colour has ${cardinalityText(stats)} categories — a legend that long is hard to scan and the palette will repeat hues. Group rarer categories, or map this field to the X axis instead.`,
});
}
}
// Negative-value Size guard (Draco hard.lp:56 size_negative): symbol size encodes
// magnitude, so negatives render as zero/clipped area and mislead. The type gate
// (isChannelTypeAllowed) can't catch this — it needs the data — so we flag it from
// the profiled numeric extent.
const size = config.encodings.size ?? null;
if (size?.field) {
const stats = statsFor(size.field, columns);
if (stats?.numericExtent && stats.numericExtent.min < 0) {
warnings.push({
channel: 'size',
message: `Size can't show negative values, but "${size.field}" goes down to ${stats.numericExtent.min}. Negative magnitudes render as tiny or clipped symbols — encode this field with Colour (a diverging scale) instead, or filter to non-negative values.`,
});
}
}
return warnings;
}
// --- Top-level data transforms: filters + calculated fields (spec §06 → Data). ----
/**
* The comparison operators a field of this type admits (spec §06 → Data filters).
* A measure or a temporal field can be ordered and ranged (`lt`…`gte`, `range`);
* a category (nominal/ordinal) offers equality and membership (`oneOf`) only —
* ordering categories by value isn't meaningful. `equal`/`notEqual` apply to every
* type. Mirrors Vega-Lite's field-predicate grammar (Voyager's guarded filter).
*/
export function validFilterOps(fieldType: FieldType): FilterOp[] {
if (fieldType === 'quantitative' || fieldType === 'temporal') {
return ['equal', 'notEqual', 'lt', 'lte', 'gt', 'gte', 'range'];
}
return ['equal', 'notEqual', 'oneOf'];
}
/** `range` reads two bounds; `oneOf` a membership list; the rest a single value. */
export function filterOpArity(op: FilterOp): 'single' | 'range' | 'list' {
if (op === 'range') return 'range';
if (op === 'oneOf') return 'list';
return 'single';
}
/**
* Coerce a filter's text value to the type Vega-Lite compares against: a
* quantitative field compares as a number (so `> 10` orders numerically, not
* lexically); every other type compares as the raw string. ISO date strings sort
* lexically == chronologically, so temporal predicates work as strings without a
* date parse; non-ISO dates are the expression-mode case. A non-numeric string on a
* quantitative field falls back to the string (Vega-Lite then compares loosely).
*/
function coerceFilterValue(value: string, fieldType: FieldType): string | number {
if (fieldType === 'quantitative') {
const n = Number(value);
return value.trim() !== '' && Number.isFinite(n) ? n : value;
}
return value;
}
/**
* The Vega-Lite field predicate for a guarded filter, or `null` when it is still
* incomplete (no field/op, or a value the operator needs is blank) — an incomplete
* filter is skipped so the preview keeps rendering. `notEqual` wraps an `equal`
* predicate in a `{ not }` (Vega-Lite has no bare inequality predicate).
*/
function predicateObject(filter: BuilderFilter): Record<string, unknown> | null {
const { op } = filter;
if (!filter.field || !op) return null;
// Escape `.`/`[`/`]` so a column literally named e.g. `user.age` is read as that
// field, not a nested-property accessor (same convention as encodingObject and the
// renderer — docs/architecture/05 §4).
const field = escapeVegaField(filter.field);
const type = filter.fieldType ?? 'nominal';
const value = filter.value ?? '';
const coerce = (v: string) => coerceFilterValue(v, type);
switch (op) {
case 'equal':
return value === '' ? null : { field, equal: coerce(value) };
case 'notEqual':
return value === '' ? null : { not: { field, equal: coerce(value) } };
case 'lt':
return value === '' ? null : { field, lt: coerce(value) };
case 'lte':
return value === '' ? null : { field, lte: coerce(value) };
case 'gt':
return value === '' ? null : { field, gt: coerce(value) };
case 'gte':
return value === '' ? null : { field, gte: coerce(value) };
case 'range': {
const upper = filter.value2 ?? '';
if (value === '' || upper === '') return null;
return { field, range: [coerce(value), coerce(upper)] };
}
case 'oneOf': {
const items = value
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
return items.length === 0 ? null : { field, oneOf: items.map(coerce) };
}
default:
return null;
}
}
/** One filter's `{ filter }` transform entry, or `null` when incomplete. */
function filterTransformObject(filter: BuilderFilter): Record<string, unknown> | null {
if (filter.mode === 'expression') {
const expr = (filter.expr ?? '').trim();
// A syntactically-invalid expression is dropped like an empty one: a half-typed
// `datum.x *` must not reach the renderer and blank the preview mid-edit — the
// inline feedback already flags it. (Same mid-edit resilience as a partial predicate.)
return expr === '' || !validateExpression(expr).valid ? null : { filter: expr };
}
const predicate = predicateObject(filter);
return predicate ? { filter: predicate } : null;
}
/** One calculate's `{ calculate, as }` transform entry, or `null` when incomplete or unparseable. */
function calculateTransformObject(calc: BuilderCalculate): Record<string, unknown> | null {
const expr = calc.expr.trim();
const as = calc.as.trim();
return expr === '' || as === '' || !validateExpression(expr).valid
? null
: { calculate: expr, as };
}
/**
* The complete top-level `transform` array for a configuration: every calculated
* field first (so filters and encodings can reference the derived columns), then
* every filter, each in the user's list order. Incomplete or unparseable entries (a
* half-typed predicate, an unnamed calculate, an expression that doesn't parse) are
* dropped so a configuration mid-edit still produces a renderable spec. An empty
* result means no `transform` key is emitted.
*/
export function buildTransforms(config: BuilderConfig): Array<Record<string, unknown>> {
const out: Array<Record<string, unknown>> = [];
for (const calc of config.calculates ?? []) {
const t = calculateTransformObject(calc);
if (t) out.push(t);
}
for (const filter of config.filters ?? []) {
const t = filterTransformObject(filter);
if (t) out.push(t);
}
return out;
}
/** The named (`as`) derived fields a config defines, in order, ignoring unnamed ones. */
export function calculatedFieldNames(
calculates: readonly BuilderCalculate[] | undefined,
): string[] {
return (calculates ?? []).map((c) => c.as.trim()).filter((as) => as !== '');
}
/**
* The dataset's columns augmented with the config's calculated fields, so the UI's
* column dropdowns and field-type logic treat a derived field like any other. A
* calculated field's inferred type is unknown, so it defaults to **number**
* (quantitative — the common arithmetic case; the user can retype it within the
* valid set on the channel). Calculated names that collide with a real column are
* skipped (the real column wins). No cardinality stats are derived for them.
*/
export function effectiveColumns(
base: BuilderColumns,
calculates: readonly BuilderCalculate[] | undefined,
): BuilderColumns {
const added = calculatedFieldNames(calculates).filter(
(name, i, all) => !base.columns.includes(name) && all.indexOf(name) === i,
);
if (added.length === 0) return base;
return {
columns: [...base.columns, ...added],
columnTypes: [
...base.columnTypes,
...added.map((name): { name: string; type: ColumnType } => ({ name, type: 'number' })),
],
columnStats: base.columnStats,
};
}
/**
* Clear any channel whose mapped column no longer exists among the effective
* columns — the cleanup after a calculated field is removed or renamed, so the
* produced spec never encodes a dangling field (which Vega-Lite would render empty).
* Returns the same config object when nothing changed (stable for React equality).
*/
export function pruneEncodings(config: BuilderConfig, base: BuilderColumns): BuilderConfig {
const available = new Set(effectiveColumns(base, config.calculates).columns);
let changed = false;
const encodings = { ...config.encodings };
for (const channel of CHANNELS) {
const mapping = encodings[channel];
if (
mapping &&
!isValueMapping(mapping) &&
mapping.field !== undefined &&
!available.has(mapping.field)
) {
encodings[channel] = null;
changed = true;
}
}
return changed ? { ...config, encodings } : config;
}
/**
* Re-point an in-progress config at a different dataset (spec §06 → Dataset
* picker). Chart-level intent survives the switch — mark, title/subtitle,
* explicit size, sort/stack, calculated fields, and expression filters (their
* `datum` references are surfaced by the unknown-field feedback, not dropped) —
* while anything bound to a column the new dataset doesn't have is shed:
* encodings via `pruneEncodings`, predicate filters by field lookup. With a
* same-schema dataset (the common switch: a fresher version of the same data)
* everything survives verbatim.
*/
export function rebaseBuilderConfig(
config: BuilderConfig,
datasetName: string,
columns: BuilderColumns,
): BuilderConfig {
const available = new Set(effectiveColumns(columns, config.calculates).columns);
const filters = (config.filters ?? []).filter(
(f) => f.mode === 'expression' || (f.field !== undefined && available.has(f.field)),
);
const rebased: BuilderConfig = { ...config, datasetName };
if (config.filters !== undefined) rebased.filters = filters;
return pruneEncodings(rebased, columns);
}
/** A built Vega-Lite spec, as a plain object (serialize with `buildSnippetSpecText`). */
export type ChartSpec = Record<string, unknown>;
/** Build one channel's Vega-Lite encoding object from its mapping + transforms. */
function encodingObject(mapping: ChannelMapping): Record<string, unknown> {
// A constant value: `{ value }` — a fixed colour/size, no field/type/transform.
if (mapping.value !== undefined) {
return { value: mapping.value };
}
// A field-less count: `{ aggregate: 'count', type: 'quantitative' }`.
if (mapping.aggregate === 'count') {
return { aggregate: 'count', type: 'quantitative' };
}
const enc: Record<string, unknown> = {};
// Escape `.`/`[`/`]` so a column literally named e.g. `user.age` is read as that
// field, not a nested-property accessor (docs/architecture/05 §4).
if (mapping.field !== undefined) enc.field = escapeVegaField(mapping.field);
// The emitted type is the *effective* one: a distinct-count of any field is a
// quantitative measure (the carried field type is preserved for a later un-aggregate).
enc.type = effectiveType(mapping);
if (mapping.aggregate) enc.aggregate = mapping.aggregate;
if (mapping.bin) enc.bin = true;
if (mapping.timeUnit) enc.timeUnit = mapping.timeUnit;
return enc;
}
/**
* Assemble the complete Vega-Lite spec from a builder configuration (spec §06 →
* Output). Includes the schema reference, a named data reference to the dataset,
* any top-level `transform` (calculated fields then row filters), the mark with
* tooltips enabled, every mapped encoding (field, type, and any aggregate/bin/
* timeUnit transform), chart-level sort (rank a categorical axis by its measure)
* and stack (part-to-whole), any title/subtitle, and any explicit width/height. Unmapped
* channels are omitted; if nothing is mapped the `encoding` block is omitted
* entirely (validation prevents saving that, but the live preview may render a bare
* mark while the user is still configuring).
*/
export function buildChartSpec(config: BuilderConfig): ChartSpec {
const spec: ChartSpec = {
$schema: VEGA_LITE_SCHEMA_URL,
data: { name: config.datasetName },
};
// Top-level transforms (calculated fields, then filters) sit between data and
// mark — applied to the raw rows before encoding aggregation.
const transform = buildTransforms(config);
if (transform.length > 0) spec.transform = transform;
// Title/subtitle: a bare string for a lone title, the object form when a
// subtitle rides along. A subtitle without a title is not emitted (VL has no
// standalone subtitle; the UI disables the input until a title exists).
const title = config.title?.trim();
if (title) {
const subtitle = config.subtitle?.trim();
spec.title = subtitle ? { text: title, subtitle } : title;
}
spec.mark = { type: config.mark, tooltip: true };
const encoding: Record<string, Record<string, unknown>> = {};
for (const [channel, mapping] of mappedChannels(config)) {
encoding[channel] = encodingObject(mapping);
}
// Sort: the categorical positional axis sorts by the value of the measure axis
// ("-y" descending, "y" ascending) — the conventional Vega-Lite ranking idiom.
if (config.sort) {
const category = sortableCategoryChannel(config);
if (category && encoding[category]) {
const measure = category === 'x' ? 'y' : 'x';
encoding[category].sort = config.sort === 'descending' ? `-${measure}` : measure;
}
}
// Stack: part-to-whole on the quantitative positional axis of a bar/area + colour.
if (config.stack && supportsStack(config)) {
const measure = stackMeasureChannel(config);
if (measure && encoding[measure]) encoding[measure].stack = config.stack;
}
if (Object.keys(encoding).length > 0) spec.encoding = encoding;
if (config.width !== undefined) spec.width = config.width;
if (config.height !== undefined) spec.height = config.height;
return spec;
}
/** The built spec as pretty-printed JSON text, ready for a snippet's `spec`. */
export function buildSnippetSpecText(config: BuilderConfig): string {
return JSON.stringify(buildChartSpec(config), null, 2);
}
/** Title-case a single mark type for display/naming (e.g. `bar` → `Bar`). */
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';
if (mapping.aggregate === 'count') return 'count';
const field = mapping.field ?? '';
if (mapping.aggregate === 'distinct') return `unique ${field}`;
if (mapping.aggregate) return `${mapping.aggregate} of ${field}`;
return field;
}
/**
* An auto-generated, descriptive name for the created snippet (spec §06 → Output:
* "an auto-generated descriptive name"). When both X and Y are mapped it reads
* "Bar chart of <y> by <x>" (using each channel's measure phrase, e.g. "count" or
* "sum of revenue"); otherwise it falls back to naming the dataset. Deterministic —
* no timestamp — so the name describes the chart, not when it was made.
*/
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 noun = markNoun(config.mark);
const x = config.encodings.x;
const y = config.encodings.y;
if (x && y) return `${noun} of ${describeMapping(y)} by ${describeMapping(x)}`;
const only = mappedChannels(config)[0];
if (only) return `${noun} of ${describeMapping(only[1])}`;
return `${noun} of ${config.datasetName}`;
}