Chart builder: SelectControl pickers, channel chooser, per-type aggregates

This commit is contained in:
2026-06-12 14:45:31 +03:00
parent 4dcff4601d
commit ed66fe9c05
15 changed files with 1232 additions and 276 deletions
+86 -1
View File
@@ -6,6 +6,7 @@ import {
isChannelTypeAllowed,
isColumnAllowedOnChannel,
supportsAggregate,
validAggregateOps,
supportsBin,
supportsTimeUnit,
supportsSort,
@@ -840,12 +841,96 @@ describe('transforms — aggregate / bin / timeUnit', () => {
it('exposes the transform-applicability predicates by field type', () => {
expect(supportsAggregate('quantitative')).toBe(true);
expect(supportsAggregate('nominal')).toBe(false);
// Every type now takes at least one aggregate (`distinct` applies to anything).
expect(supportsAggregate('nominal')).toBe(true);
expect(supportsBin('quantitative')).toBe(true);
expect(supportsBin('temporal')).toBe(false);
expect(supportsTimeUnit('temporal')).toBe(true);
expect(supportsTimeUnit('quantitative')).toBe(false);
});
it('offers per-type aggregate menus: arithmetic needs numbers, min/max an ordering, distinct anything', () => {
expect(validAggregateOps('quantitative')).toEqual([
'sum',
'mean',
'median',
'min',
'max',
'distinct',
]);
expect(validAggregateOps('temporal')).toEqual(['min', 'max', 'distinct']);
expect(validAggregateOps('ordinal')).toEqual(['min', 'max', 'distinct']);
expect(validAggregateOps('nominal')).toEqual(['distinct']);
});
it('emits a distinct-count of a categorical field as a quantitative measure', () => {
const spec = buildChartSpec({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'customer', type: 'nominal', aggregate: 'distinct' },
},
});
const enc = spec.encoding as Record<string, Record<string, unknown>>;
// The carried (nominal) type is for round-tripping; the emitted type is the
// effective one — a count of unique values reads as a quantitative measure.
expect(enc.y).toEqual({ field: 'customer', type: 'quantitative', aggregate: 'distinct' });
});
it('keeps the field type on order-preserving aggregates (a temporal min is still temporal)', () => {
const spec = buildChartSpec({
datasetName: 'D',
mark: 'point',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'orderDate', type: 'temporal', aggregate: 'min' },
},
});
const enc = spec.encoding as Record<string, Record<string, unknown>>;
expect(enc.y).toEqual({ field: 'orderDate', type: 'temporal', aggregate: 'min' });
});
it('emits title as a bare string, the object form with a subtitle, nothing without a title', () => {
const base: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: { x: { field: 'region', type: 'nominal' } },
};
expect(buildChartSpec({ ...base, title: 'Sales by region' }).title).toBe('Sales by region');
expect(buildChartSpec({ ...base, title: 'Sales', subtitle: 'FY26' }).title).toEqual({
text: 'Sales',
subtitle: 'FY26',
});
// A subtitle alone is not emitted (VL has no standalone subtitle), nor is a
// whitespace-only title.
expect(buildChartSpec({ ...base, subtitle: 'orphan' }).title).toBeUndefined();
expect(buildChartSpec({ ...base, title: ' ' }).title).toBeUndefined();
});
it('prefers a user-written title as the generated snippet name', () => {
expect(
generateChartName({
datasetName: 'D',
mark: 'bar',
title: 'Quarterly revenue',
encodings: { x: { field: 'region', type: 'nominal' } },
}),
).toBe('Quarterly revenue');
});
it('names a distinct-count chart with a "unique" phrase', () => {
expect(
generateChartName({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'customer', type: 'nominal', aggregate: 'distinct' },
},
}),
).toBe('Bar chart of unique customer by region');
});
});
describe('sort (ranking)', () => {
+54 -8
View File
@@ -44,9 +44,12 @@ 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`.
* `field`. `distinct` counts a field's unique values, so it applies to **any** field
* type and reads as a quantitative measure. The arithmetic ops (sum/mean/median)
* reduce a quantitative field; min/max also order a temporal or ordinal one. See
* `validAggregateOps` for the per-type menu.
*/
export const AGGREGATE_OPS = ['count', 'sum', 'mean', 'median', 'min', 'max'] as const;
export const AGGREGATE_OPS = ['count', 'distinct', 'sum', 'mean', 'median', 'min', 'max'] as const;
export type AggregateOp = (typeof AGGREGATE_OPS)[number];
/**
@@ -193,6 +196,10 @@ export interface BuilderConfig {
mark: MarkType;
/** Per-channel mapping; `null` (or absent) means the channel is unmapped. */
encodings: Partial<Record<ChannelName, ChannelMapping | null>>;
/** Optional chart title (Vega-Lite top-level `title`). */
title?: string;
/** Optional subtitle; emitted only alongside a title (VL nests it under `title`). */
subtitle?: string;
/** Optional explicit chart width in pixels. */
width?: number;
/** Optional explicit chart height in pixels. */
@@ -276,9 +283,29 @@ export function isColumnAllowedOnChannel(channel: ChannelName, columnType: Colum
return isChannelTypeAllowed(channel, defaultFieldType(columnType));
}
/** Whether a non-count aggregate (sum/mean/…) can apply to this field type. */
/**
* The non-count aggregates that legitimately apply to a field of this type — the
* channel's Aggregate menu. Arithmetic reduction (sum/mean/median) needs numbers;
* min/max need an ordering (numbers, dates, asserted-ordinal values); `distinct`
* (count of unique values) applies to anything — the natural measure to wring out
* of a category ("how many unique customers"), which is why the menu is per-type
* rather than quantitative-only.
*/
export function validAggregateOps(type: FieldType): Exclude<AggregateOp, 'count'>[] {
switch (type) {
case 'quantitative':
return ['sum', 'mean', 'median', 'min', 'max', 'distinct'];
case 'temporal':
case 'ordinal':
return ['min', 'max', 'distinct'];
case 'nominal':
return ['distinct'];
}
}
/** Whether any non-count aggregate (sum/…/distinct) can apply to this field type. */
export function supportsAggregate(type: FieldType): boolean {
return type === 'quantitative';
return validAggregateOps(type).length > 0;
}
/** Whether binning into ranges can apply to this field type. */
@@ -498,9 +525,13 @@ function mappedChannels(config: BuilderConfig): Array<[ChannelName, ChannelMappi
});
}
/** The effective field type a mapping encodes (a count is quantitative). */
/** The effective field type a mapping encodes: a count or a distinct-count reads as
* a quantitative measure whatever the underlying field; other aggregates (sum, a
* temporal min/…) keep the field's own type. */
function effectiveType(mapping: ChannelMapping): FieldType {
return mapping.aggregate === 'count' ? 'quantitative' : mapping.type;
return mapping.aggregate === 'count' || mapping.aggregate === 'distinct'
? 'quantitative'
: mapping.type;
}
/** True when a mapping reads as a continuous measure (count/aggregate or continuous type). */
@@ -1034,7 +1065,9 @@ function encodingObject(mapping: ChannelMapping): Record<string, unknown> {
// Escape `.`/`[`/`]` so a column literally named e.g. `user.age` is read as that
// field, not a nested-property accessor (docs/architecture/05 §4).
if (mapping.field !== undefined) enc.field = escapeVegaField(mapping.field);
enc.type = mapping.type;
// The emitted type is the *effective* one: a distinct-count of any field is a
// quantitative measure (the carried field type is preserved for a later un-aggregate).
enc.type = effectiveType(mapping);
if (mapping.aggregate) enc.aggregate = mapping.aggregate;
if (mapping.bin) enc.bin = true;
if (mapping.timeUnit) enc.timeUnit = mapping.timeUnit;
@@ -1047,7 +1080,7 @@ function encodingObject(mapping: ChannelMapping): Record<string, unknown> {
* any top-level `transform` (calculated fields then row filters), the mark with
* tooltips enabled, every mapped encoding (field, type, and any aggregate/bin/
* timeUnit transform), chart-level sort (rank a categorical axis by its measure)
* and stack (part-to-whole), and any explicit width/height. Unmapped
* and stack (part-to-whole), any title/subtitle, and any explicit width/height. Unmapped
* channels are omitted; if nothing is mapped the `encoding` block is omitted
* entirely (validation prevents saving that, but the live preview may render a bare
* mark while the user is still configuring).
@@ -1063,6 +1096,15 @@ export function buildChartSpec(config: BuilderConfig): ChartSpec {
const transform = buildTransforms(config);
if (transform.length > 0) spec.transform = transform;
// Title/subtitle: a bare string for a lone title, the object form when a
// subtitle rides along. A subtitle without a title is not emitted (VL has no
// standalone subtitle; the UI disables the input until a title exists).
const title = config.title?.trim();
if (title) {
const subtitle = config.subtitle?.trim();
spec.title = subtitle ? { text: title, subtitle } : title;
}
spec.mark = { type: config.mark, tooltip: true };
const encoding: Record<string, Record<string, unknown>> = {};
@@ -1109,6 +1151,7 @@ function describeMapping(mapping: ChannelMapping): string {
if (mapping.value !== undefined) return 'a constant';
if (mapping.aggregate === 'count') return 'count';
const field = mapping.field ?? '';
if (mapping.aggregate === 'distinct') return `unique ${field}`;
if (mapping.aggregate) return `${mapping.aggregate} of ${field}`;
return field;
}
@@ -1121,6 +1164,9 @@ function describeMapping(mapping: ChannelMapping): string {
* no timestamp — so the name describes the chart, not when it was made.
*/
export function generateChartName(config: BuilderConfig): string {
// A user-written chart title is the best possible name — prefer it verbatim.
const title = config.title?.trim();
if (title) return title;
const mark = markLabel(config.mark);
const x = config.encodings.x;
const y = config.encodings.y;