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

1132 lines
49 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 five mark types the builder offers, in selector order (spec §06). */
export const MARK_TYPES = ['bar', 'line', 'point', 'area', 'circle'] as const;
export type MarkType = (typeof MARK_TYPES)[number];
/** The four Vega-Lite field types a channel may carry, in override-menu order. */
export const FIELD_TYPES = ['quantitative', 'nominal', 'ordinal', 'temporal'] as const;
export type FieldType = (typeof FIELD_TYPES)[number];
/** 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`. The rest reduce a quantitative `field`.
*/
export const AGGREGATE_OPS = ['count', 'sum', 'mean', 'median', 'min', 'max'] as const;
export type AggregateOp = (typeof AGGREGATE_OPS)[number];
/**
* 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 const SORT_ORDERS = ['ascending', 'descending'] as const;
export type SortOrder = (typeof SORT_ORDERS)[number];
/** Part-to-whole stacking for bar/area + a colour series: absolute vs 100%. */
export const STACK_MODES = ['zero', 'normalize'] as const;
export type StackMode = (typeof STACK_MODES)[number];
/**
* 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 const FILTER_OPS = [
'equal',
'notEqual',
'lt',
'lte',
'gt',
'gte',
'range',
'oneOf',
] as const;
export type FilterOp = (typeof FILTER_OPS)[number];
/** 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 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));
}
/** Whether a non-count aggregate (sum/mean/…) can apply to this field type. */
export function supportsAggregate(type: FieldType): boolean {
return type === 'quantitative';
}
/** 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 five marks afterward.
*/
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 };
}
/** 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 is quantitative). */
function effectiveType(mapping: ChannelMapping): FieldType {
return mapping.aggregate === 'count' ? 'quantitative' : mapping.type;
}
/** True when a mapping reads as a continuous measure (count/aggregate or continuous type). */
function isMeasureMapping(mapping: ChannelMapping): boolean {
// A constant value encodes no data, so it is never a measure.
if (isValueMapping(mapping)) return false;
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;
}
/**
* 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).
*/
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';
return undefined;
}
/** The quantitative positional channel (x or y) that stacking applies to, if any. */
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')
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;
// 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)) {
warnings.push({
message: `${mark === 'line' ? 'Line' : 'Area'} charts need both an X and a Y axis.`,
});
}
// 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 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).
if (
x !== null &&
y !== null &&
effectiveType(x) === 'quantitative' &&
effectiveType(y) === 'quantitative' &&
mark !== 'point' &&
mark !== 'circle'
) {
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;
}
/** 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);
enc.type = mapping.type;
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), 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;
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);
}
/** 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) 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 {
const mark = markLabel(config.mark);
const x = config.encodings.x;
const y = config.encodings.y;
if (x && y) return `${mark} chart 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}`;
}