mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart builder: SelectControl pickers, channel chooser, per-type aggregates
This commit is contained in:
@@ -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)', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user