mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
Chart builder: SelectControl pickers, channel chooser, per-type aggregates
This commit is contained in:
@@ -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