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

766 lines
33 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';
/** 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];
/**
* One channel's mapping: 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). A channel left on "None" is `null` in the config
* (omitted from the spec).
*/
export interface ChannelMapping {
/** The dataset column. Omitted only when `aggregate === 'count'`. */
field?: string;
/** The Vega-Lite field type (see `validFieldTypes`). */
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;
}
/**
* 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 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';
}
/**
* 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 {
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 && effectiveType(mapping) === 'quantitative') return channel;
}
return undefined;
}
/**
* 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') &&
!!config.encodings.color &&
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' && config.encodings.color && !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;
}
/** 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 field-less count: `{ aggregate: 'count', type: 'quantitative' }`.
if (mapping.aggregate === 'count') {
return { aggregate: 'count', type: 'quantitative' };
}
const enc: Record<string, unknown> = {};
if (mapping.field !== undefined) enc.field = 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,
* 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 },
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.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}`;
}