/** * 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>; /** 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; } /** 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> = { 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 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; // 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; /** Build one channel's Vega-Lite encoding object from its mapping + transforms. */ function encodingObject(mapping: ChannelMapping): Record { // A field-less count: `{ aggregate: 'count', type: 'quantitative' }`. if (mapping.aggregate === 'count') { return { aggregate: 'count', type: 'quantitative' }; } const enc: Record = {}; 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> = {}; 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 by " (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}`; }