mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
1501 lines
66 KiB
TypeScript
1501 lines
66 KiB
TypeScript
/**
|
||
* 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 8–12
|
||
* 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}`;
|
||
}
|