mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add aggregation, binning, granularity, sort, and stacking to the Chart Builder
- Per-channel transforms: aggregate (sum/mean/median/min/max), quantitative bin, and temporal timeUnit granularity; bin and aggregate are mutually exclusive. A field-less "Count of records" measure (Voyager's count(*)). - Chart-level sort (rank a categorical axis by its measure) and stacking (zero / 100% normalize), each shown only when it applies. - Field type is a fixed N|O|Q|T segmented control with the column's invalid types disabled; SegmentedControl gains APG-correct disabled options. - A crowded-category-axis warning (a raw measure drawing one mark per row over a large dataset) and a disabled-Create hint (says why it's disabled). - Drop the Create success toast — the new snippet is immediately visible. - Docs: spec §06, research-doc §8 backlog (incl. the cardinality/extent profiling TODO), architecture 01 (stable-selector rule) and 05 (builder-local preview), and a profiling breadcrumb.
This commit is contained in:
@@ -4,6 +4,12 @@ import {
|
||||
validFieldTypes,
|
||||
defaultMark,
|
||||
isChannelTypeAllowed,
|
||||
supportsAggregate,
|
||||
supportsBin,
|
||||
supportsTimeUnit,
|
||||
supportsSort,
|
||||
supportsStack,
|
||||
sortableCategoryChannel,
|
||||
builderWarnings,
|
||||
defaultBuilderConfig,
|
||||
isBuilderConfigValid,
|
||||
@@ -12,6 +18,7 @@ import {
|
||||
generateChartName,
|
||||
type BuilderColumns,
|
||||
type BuilderConfig,
|
||||
type ChannelMapping,
|
||||
} from './chart-builder';
|
||||
import { VEGA_LITE_SCHEMA_URL } from './snippet';
|
||||
|
||||
@@ -143,6 +150,79 @@ describe('builderWarnings (Tier B advisories)', () => {
|
||||
});
|
||||
expect(w).toEqual([]);
|
||||
});
|
||||
|
||||
describe('crowded category axis (one mark per row)', () => {
|
||||
const crowded = (overrides: Partial<ChannelMapping> = {}) =>
|
||||
builderWarnings(
|
||||
{
|
||||
datasetName: 'D',
|
||||
mark: 'bar',
|
||||
encodings: {
|
||||
x: { field: 'name', type: 'nominal' },
|
||||
y: { field: 'mpg', type: 'quantitative', ...overrides },
|
||||
},
|
||||
},
|
||||
406,
|
||||
);
|
||||
|
||||
it('warns when a raw measure draws one bar per row over a large dataset', () => {
|
||||
const w = crowded();
|
||||
const hint = w.find((m) => /one mark per row/.test(m.message));
|
||||
expect(hint?.channel).toBe('x'); // the category axis
|
||||
expect(hint?.message).toContain('406 in this dataset');
|
||||
expect(hint?.message).toMatch(/Swap X\/Y/); // bar → horizontal-bar remedy
|
||||
});
|
||||
|
||||
it('is silent once the measure is aggregated (one bar per category)', () => {
|
||||
const w = crowded({ aggregate: 'mean' });
|
||||
expect(w.some((m) => /one mark per row/.test(m.message))).toBe(false);
|
||||
});
|
||||
|
||||
it('is silent for a small dataset even with a raw measure', () => {
|
||||
const w = builderWarnings(
|
||||
{
|
||||
datasetName: 'D',
|
||||
mark: 'bar',
|
||||
encodings: {
|
||||
x: { field: 'name', type: 'nominal' },
|
||||
y: { field: 'mpg', type: 'quantitative' },
|
||||
},
|
||||
},
|
||||
12,
|
||||
);
|
||||
expect(w.some((m) => /one mark per row/.test(m.message))).toBe(false);
|
||||
});
|
||||
|
||||
it('is silent when the row count is unknown (URL/non-tabular)', () => {
|
||||
const w = builderWarnings({
|
||||
datasetName: 'D',
|
||||
mark: 'bar',
|
||||
encodings: {
|
||||
x: { field: 'name', type: 'nominal' },
|
||||
y: { field: 'mpg', type: 'quantitative' },
|
||||
},
|
||||
});
|
||||
expect(w.some((m) => /one mark per row/.test(m.message))).toBe(false);
|
||||
});
|
||||
|
||||
it('uses non-bar wording (no Swap X/Y) for a line mark', () => {
|
||||
const w = builderWarnings(
|
||||
{
|
||||
datasetName: 'D',
|
||||
mark: 'line',
|
||||
encodings: {
|
||||
x: { field: 'name', type: 'nominal' },
|
||||
y: { field: 'mpg', type: 'quantitative' },
|
||||
},
|
||||
},
|
||||
406,
|
||||
);
|
||||
const hint = w.find((m) => /one mark per row/.test(m.message));
|
||||
expect(hint).toBeDefined();
|
||||
expect(hint?.message).not.toMatch(/Swap X\/Y/);
|
||||
expect(hint?.message).toMatch(/reduce the number of categories/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultBuilderConfig', () => {
|
||||
@@ -263,6 +343,140 @@ describe('buildChartSpec', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('transforms — aggregate / bin / timeUnit', () => {
|
||||
it('emits a field-less count encoding', () => {
|
||||
const spec = buildChartSpec({
|
||||
datasetName: 'D',
|
||||
mark: 'bar',
|
||||
encodings: {
|
||||
x: { field: 'region', type: 'nominal' },
|
||||
y: { type: 'quantitative', aggregate: 'count' },
|
||||
},
|
||||
});
|
||||
const enc = spec.encoding as Record<string, Record<string, unknown>>;
|
||||
expect(enc.y).toEqual({ aggregate: 'count', type: 'quantitative' });
|
||||
expect(enc.y.field).toBeUndefined();
|
||||
});
|
||||
|
||||
it('emits a non-count aggregate with its field', () => {
|
||||
const spec = buildChartSpec({
|
||||
datasetName: 'D',
|
||||
mark: 'bar',
|
||||
encodings: {
|
||||
x: { field: 'region', type: 'nominal' },
|
||||
y: { field: 'revenue', type: 'quantitative', aggregate: 'sum' },
|
||||
},
|
||||
});
|
||||
const enc = spec.encoding as Record<string, Record<string, unknown>>;
|
||||
expect(enc.y).toEqual({ field: 'revenue', type: 'quantitative', aggregate: 'sum' });
|
||||
});
|
||||
|
||||
it('emits bin on a quantitative field (histogram shape) and timeUnit on a temporal one', () => {
|
||||
const hist = buildChartSpec({
|
||||
datasetName: 'D',
|
||||
mark: 'bar',
|
||||
encodings: {
|
||||
x: { field: 'price', type: 'quantitative', bin: true },
|
||||
y: { type: 'quantitative', aggregate: 'count' },
|
||||
},
|
||||
});
|
||||
const henc = hist.encoding as Record<string, Record<string, unknown>>;
|
||||
expect(henc.x).toEqual({ field: 'price', type: 'quantitative', bin: true });
|
||||
|
||||
const ts = buildChartSpec({
|
||||
datasetName: 'D',
|
||||
mark: 'line',
|
||||
encodings: {
|
||||
x: { field: 'day', type: 'temporal', timeUnit: 'yearmonth' },
|
||||
y: { field: 'v', type: 'quantitative' },
|
||||
},
|
||||
});
|
||||
const tenc = ts.encoding as Record<string, Record<string, unknown>>;
|
||||
expect(tenc.x).toEqual({ field: 'day', type: 'temporal', timeUnit: 'yearmonth' });
|
||||
});
|
||||
|
||||
it('exposes the transform-applicability predicates by field type', () => {
|
||||
expect(supportsAggregate('quantitative')).toBe(true);
|
||||
expect(supportsAggregate('nominal')).toBe(false);
|
||||
expect(supportsBin('quantitative')).toBe(true);
|
||||
expect(supportsBin('temporal')).toBe(false);
|
||||
expect(supportsTimeUnit('temporal')).toBe(true);
|
||||
expect(supportsTimeUnit('quantitative')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sort (ranking)', () => {
|
||||
const ranking: BuilderConfig = {
|
||||
datasetName: 'D',
|
||||
mark: 'bar',
|
||||
encodings: {
|
||||
x: { field: 'region', type: 'nominal' },
|
||||
y: { field: 'revenue', type: 'quantitative', aggregate: 'sum' },
|
||||
},
|
||||
};
|
||||
|
||||
it('sorts the category axis by the measure axis (descending → "-y")', () => {
|
||||
expect(sortableCategoryChannel(ranking)).toBe('x');
|
||||
expect(supportsSort(ranking)).toBe(true);
|
||||
const enc = buildChartSpec({ ...ranking, sort: 'descending' }).encoding as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(enc.x.sort).toBe('-y');
|
||||
const asc = buildChartSpec({ ...ranking, sort: 'ascending' }).encoding as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(asc.x.sort).toBe('y');
|
||||
});
|
||||
|
||||
it('does not offer sort when both axes are measures', () => {
|
||||
const scatter: BuilderConfig = {
|
||||
datasetName: 'D',
|
||||
mark: 'point',
|
||||
encodings: {
|
||||
x: { field: 'a', type: 'quantitative' },
|
||||
y: { field: 'b', type: 'quantitative' },
|
||||
},
|
||||
};
|
||||
expect(supportsSort(scatter)).toBe(false);
|
||||
expect(buildChartSpec({ ...scatter, sort: 'descending' }).encoding).toBeDefined();
|
||||
const enc = buildChartSpec({ ...scatter, sort: 'descending' }).encoding as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(enc.x.sort).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('stack (part-to-whole)', () => {
|
||||
const stacked: BuilderConfig = {
|
||||
datasetName: 'D',
|
||||
mark: 'bar',
|
||||
encodings: {
|
||||
x: { field: 'month', type: 'ordinal' },
|
||||
y: { field: 'sales', type: 'quantitative', aggregate: 'sum' },
|
||||
color: { field: 'product', type: 'nominal' },
|
||||
},
|
||||
};
|
||||
|
||||
it('stacks the quantitative axis for a bar/area + colour series', () => {
|
||||
expect(supportsStack(stacked)).toBe(true);
|
||||
const enc = buildChartSpec({ ...stacked, stack: 'normalize' }).encoding as Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
>;
|
||||
expect(enc.y.stack).toBe('normalize');
|
||||
});
|
||||
|
||||
it('does not stack without a colour series or on a point mark', () => {
|
||||
expect(supportsStack({ ...stacked, encodings: { ...stacked.encodings, color: null } })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(supportsStack({ ...stacked, mark: 'point' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSnippetSpecText', () => {
|
||||
it('produces pretty-printed JSON that parses back to the spec', () => {
|
||||
const config = defaultBuilderConfig('Sales', columns);
|
||||
|
||||
+248
-38
@@ -2,17 +2,24 @@
|
||||
* 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, 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).
|
||||
* 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).
|
||||
*
|
||||
* 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.
|
||||
* 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 { ColumnType } from './type-inference';
|
||||
@@ -31,12 +38,56 @@ export const CHANNELS = ['x', 'y', 'color', 'size'] as const;
|
||||
export type ChannelName = (typeof CHANNELS)[number];
|
||||
|
||||
/**
|
||||
* One channel's mapping: a dataset column `field` plus its `type`. A channel left
|
||||
* on "None" is represented by `null` in the config (omitted from the spec).
|
||||
* 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 {
|
||||
field: string;
|
||||
/** 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. */
|
||||
@@ -51,6 +102,10 @@ export interface BuilderConfig {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -110,6 +165,21 @@ export function isChannelTypeAllowed(channel: ChannelName, type: FieldType): boo
|
||||
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
|
||||
@@ -153,11 +223,11 @@ function fieldTypeForColumn(name: string, columns: BuilderColumns): FieldType {
|
||||
/**
|
||||
* The builder's opening configuration for a dataset (spec §06 → Default
|
||||
* pre-population, Tier B): the first column on X and the second (if any) on Y, each
|
||||
* with its derived field type; Color and Size start unmapped. The mark is the
|
||||
* **smart default** for the resulting X/Y shape (`defaultMark`) rather than always
|
||||
* Bar — a date-vs-number dataset opens as a Line, two measures as a Point — so the
|
||||
* first preview is already the conventional chart. A dataset with no detected
|
||||
* columns yields an all-unmapped config (the modal then prompts / disables Create).
|
||||
* with its derived field type; Color and Size start unmapped, no transforms. The
|
||||
* mark is the **smart default** for the resulting X/Y shape (`defaultMark`) rather
|
||||
* than always Bar — a date-vs-number dataset opens as a Line, two measures as a
|
||||
* Point — so the first preview is already the conventional chart. 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>> = {
|
||||
@@ -177,7 +247,7 @@ export function defaultBuilderConfig(datasetName: string, columns: BuilderColumn
|
||||
return { datasetName, mark, encodings };
|
||||
}
|
||||
|
||||
/** The channels actually mapped to a column, in canonical order. */
|
||||
/** 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];
|
||||
@@ -185,15 +255,70 @@ function mappedChannels(config: BuilderConfig): Array<[ChannelName, ChannelMappi
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 to a column. The modal gates the Create action
|
||||
* and the preview prompt on this.
|
||||
* 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 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. */
|
||||
@@ -202,14 +327,27 @@ export interface BuilderWarning {
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 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 crowded-axis hint;
|
||||
* pass it from the loaded dataset. Omitted/`null` (URL or non-tabular data) simply
|
||||
* skips that one hint.
|
||||
*/
|
||||
export function builderWarnings(config: BuilderConfig): BuilderWarning[] {
|
||||
export function builderWarnings(config: BuilderConfig, rowCount?: number | null): BuilderWarning[] {
|
||||
const warnings: BuilderWarning[] = [];
|
||||
const x = config.encodings.x ?? null;
|
||||
const y = config.encodings.y ?? null;
|
||||
@@ -229,11 +367,11 @@ export function builderWarnings(config: BuilderConfig): BuilderWarning[] {
|
||||
(mark === 'bar' || mark === 'line' || mark === 'area') &&
|
||||
x !== null &&
|
||||
y !== null &&
|
||||
!isContinuous(x.type) &&
|
||||
!isContinuous(y.type)
|
||||
!isMeasureMapping(x) &&
|
||||
!isMeasureMapping(y)
|
||||
) {
|
||||
warnings.push({
|
||||
message: `${markLabel(mark)} charts need a measure (quantitative or temporal) on the X or Y axis.`,
|
||||
message: `${markLabel(mark)} charts need a measure (a value or count) on the X or Y axis.`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -242,8 +380,8 @@ export function builderWarnings(config: BuilderConfig): BuilderWarning[] {
|
||||
if (
|
||||
x !== null &&
|
||||
y !== null &&
|
||||
x.type === 'quantitative' &&
|
||||
y.type === 'quantitative' &&
|
||||
effectiveType(x) === 'quantitative' &&
|
||||
effectiveType(y) === 'quantitative' &&
|
||||
mark !== 'point' &&
|
||||
mark !== 'circle'
|
||||
) {
|
||||
@@ -262,19 +400,66 @@ export function builderWarnings(config: BuilderConfig): BuilderWarning[] {
|
||||
});
|
||||
}
|
||||
|
||||
// Crowded category axis: a bar/line/area pairing a discrete category against a
|
||||
// *raw* (un-aggregated, un-binned) measure draws one mark — and one axis label —
|
||||
// per row, so a large dataset becomes an unreadable picket fence of labels (the
|
||||
// builder's own default does this: first column on X, second on Y, no aggregate).
|
||||
// We can only flag the un-aggregated case, where mark-count == rowCount exactly;
|
||||
// an *aggregated* axis that still has many distinct categories needs per-column
|
||||
// distinct counts we don't profile yet (backlog A2/A3). The fix follows the canon:
|
||||
// aggregate the measure to one mark per category, or — for a bar — flip to a
|
||||
// horizontal bar 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') &&
|
||||
typeof rowCount === 'number' &&
|
||||
rowCount > CROWDED_CATEGORY_ROWS
|
||||
) {
|
||||
const category = sortableCategoryChannel(config); // the discrete axis of a category-vs-measure pair
|
||||
const measure = category ? config.encodings[category === 'x' ? 'y' : 'x'] : null;
|
||||
if (category && measure && !measure.aggregate && !measure.bin) {
|
||||
const fix =
|
||||
mark === 'bar'
|
||||
? 'Aggregate the measure (e.g. Sum or Mean) for one bar per category, or use Swap X/Y for a horizontal bar where long labels stay readable.'
|
||||
: 'Aggregate the measure (e.g. Sum or Mean) so there is one mark per category, or reduce the number of categories.';
|
||||
warnings.push({
|
||||
channel: category,
|
||||
message: `This draws one mark per row (${rowCount} in this dataset), so the category-axis labels will overlap. ${fix}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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 + field type), 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).
|
||||
* 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 = {
|
||||
@@ -283,10 +468,27 @@ export function buildChartSpec(config: BuilderConfig): ChartSpec {
|
||||
mark: { type: config.mark, tooltip: true },
|
||||
};
|
||||
|
||||
const encoding: Record<string, { field: string; type: FieldType }> = {};
|
||||
const encoding: Record<string, Record<string, unknown>> = {};
|
||||
for (const [channel, mapping] of mappedChannels(config)) {
|
||||
encoding[channel] = { field: mapping.field, type: mapping.type };
|
||||
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;
|
||||
@@ -305,19 +507,27 @@ 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>"; otherwise it falls back to naming the dataset:
|
||||
* "Bar chart of <dataset>". Deterministic — no timestamp — so the name describes
|
||||
* the chart, not when it was made.
|
||||
* "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 ${y.field} by ${x.field}`;
|
||||
if (x && y) return `${mark} chart of ${describeMapping(y)} by ${describeMapping(x)}`;
|
||||
const only = mappedChannels(config)[0];
|
||||
if (only) return `${mark} chart of ${only[1].field}`;
|
||||
if (only) return `${mark} chart of ${describeMapping(only[1])}`;
|
||||
return `${mark} chart of ${config.datasetName}`;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,10 @@ export function profileData(
|
||||
if (columns.length === 0) return naProfile(size);
|
||||
|
||||
const sample = sampleRows(rows);
|
||||
// TODO (backlog: docs/chart-builder-research.md §8 — A3/A4 enabler): in this same
|
||||
// sample pass, also derive a capped per-column distinct count (cardinality, cap ~50)
|
||||
// and numeric extent (min/max → sign), surfaced on DatasetProfile, to power the Chart
|
||||
// Builder's crowded-legend / high-cardinality warnings and its negative-value Size guard.
|
||||
const columnTypes = columns.map((name) => ({
|
||||
name,
|
||||
type: inferColumnType(sample.map((r) => r[name])),
|
||||
|
||||
Reference in New Issue
Block a user