mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart builder: field-first shelf + value-or-field channels
This commit is contained in:
+103
-10
@@ -152,15 +152,30 @@ export interface BuilderCalculate {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* One channel's binding. Two kinds share this shape — the **Property model**: one
|
||||
* control that holds either a field or a constant.
|
||||
*
|
||||
* - **field** — 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).
|
||||
* - **value** — a fixed constant (Vega-Lite `{ value }`): a literal colour or size
|
||||
* applied to every mark, with no field/type/transform. `isValueMapping`
|
||||
* discriminates on `value` being present; the channel's `type` is preserved (but
|
||||
* ignored) while a value is set, so toggling back to a field restores it.
|
||||
*
|
||||
* 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'`. */
|
||||
/**
|
||||
* A constant value (Vega-Lite `{ value }`) — a fixed colour/size applied to every
|
||||
* mark. When set, this channel is a CONSTANT: `field` and the transforms are not
|
||||
* emitted (`encodingObject` returns `{ value }`). Only colour/size offer it in the
|
||||
* UI (`channelAcceptsValue`), but the assembler handles it on any channel.
|
||||
*/
|
||||
value?: string | number | boolean;
|
||||
/** The dataset column. Omitted for a `count` aggregate or when `value` is set. */
|
||||
field?: string;
|
||||
/** The Vega-Lite field type (see `validFieldTypes`). */
|
||||
/** The Vega-Lite field type (see `validFieldTypes`); ignored while `value` is set. */
|
||||
type: FieldType;
|
||||
/** Aggregation op; `count` is field-less, the rest reduce a quantitative field. */
|
||||
aggregate?: AggregateOp;
|
||||
@@ -250,6 +265,17 @@ export function isChannelTypeAllowed(channel: ChannelName, type: FieldType): boo
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a dataset column may be placed on a channel at all, judged by its **default**
|
||||
* field type (spec §06 → Size discipline). X/Y/Color accept any column; Size accepts a
|
||||
* column only when its natural type reads as a magnitude (a numeric → Quantitative).
|
||||
* The channel's type control narrows further per field. Used to disable unsuitable
|
||||
* columns in the field shelf and to pick an auto-assign target when a field is clicked.
|
||||
*/
|
||||
export function isColumnAllowedOnChannel(channel: ChannelName, columnType: ColumnType): boolean {
|
||||
return isChannelTypeAllowed(channel, defaultFieldType(columnType));
|
||||
}
|
||||
|
||||
/** Whether a non-count aggregate (sum/mean/…) can apply to this field type. */
|
||||
export function supportsAggregate(type: FieldType): boolean {
|
||||
return type === 'quantitative';
|
||||
@@ -265,6 +291,50 @@ export function supportsTimeUnit(type: FieldType): boolean {
|
||||
return type === 'temporal';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a channel mapping is a **constant value** (Vega-Lite `{ value }`) rather
|
||||
* than a field binding — the discriminator of the Property model. A constant has no
|
||||
* field, type, or transform; it colours/sizes every mark the same.
|
||||
*/
|
||||
export function isValueMapping(mapping: ChannelMapping): boolean {
|
||||
return mapping.value !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a channel offers the constant-value control in the UI. A fixed **colour**
|
||||
* or **size** is both common and awkward in JSON (the promotion test), so it earns a
|
||||
* control; a constant X/Y position is not useful, so X/Y stay field-only. The
|
||||
* assembler emits a value on any channel — this only gates where the toggle appears,
|
||||
* and generalizes to channels added later (e.g. opacity).
|
||||
*/
|
||||
export function channelAcceptsValue(channel: ChannelName): boolean {
|
||||
return channel === 'color' || channel === 'size';
|
||||
}
|
||||
|
||||
/**
|
||||
* A sensible starting constant when a channel is first switched to value mode:
|
||||
* Vega-Lite's default categorical blue for colour, a clearly-visible 100 for size.
|
||||
* The user adjusts it from there.
|
||||
*/
|
||||
export function defaultChannelValue(channel: ChannelName): string | number {
|
||||
return channel === 'size' ? 100 : '#4c78a8';
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a constant-value text input to the JS type its channel expects, so the
|
||||
* emitted `{ value }` carries the right type: **size** is a magnitude → a number (a
|
||||
* blank/non-numeric entry falls back to the raw string rather than NaN); every other
|
||||
* channel keeps the string (a colour is a CSS string). Mirrors the filter shelf's
|
||||
* `coerceFilterValue` discipline.
|
||||
*/
|
||||
export function coerceChannelValue(channel: ChannelName, raw: string): string | number {
|
||||
if (channel === 'size') {
|
||||
const n = Number(raw);
|
||||
return raw.trim() !== '' && Number.isFinite(n) ? n : raw;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -435,6 +505,8 @@ function effectiveType(mapping: ChannelMapping): FieldType {
|
||||
|
||||
/** True when a mapping reads as a continuous measure (count/aggregate or continuous type). */
|
||||
function isMeasureMapping(mapping: ChannelMapping): boolean {
|
||||
// A constant value encodes no data, so it is never a measure.
|
||||
if (isValueMapping(mapping)) return false;
|
||||
return isContinuous(effectiveType(mapping));
|
||||
}
|
||||
|
||||
@@ -467,11 +539,22 @@ export function sortableCategoryChannel(config: BuilderConfig): 'x' | 'y' | unde
|
||||
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;
|
||||
if (mapping && !isValueMapping(mapping) && effectiveType(mapping) === 'quantitative')
|
||||
return channel;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Color is bound to a **field** (a real per-value series + legend) rather
|
||||
* than a constant. Stacking and the area-split hint care about a colour *series*; a
|
||||
* fixed colour produces neither, so both treat a constant Color as no colour at all.
|
||||
*/
|
||||
function colorIsSeries(config: BuilderConfig): boolean {
|
||||
const color = config.encodings.color;
|
||||
return !!color && !isValueMapping(color);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
@@ -487,7 +570,7 @@ export function supportsSort(config: BuilderConfig): boolean {
|
||||
export function supportsStack(config: BuilderConfig): boolean {
|
||||
return (
|
||||
(config.mark === 'bar' || config.mark === 'area') &&
|
||||
!!config.encodings.color &&
|
||||
colorIsSeries(config) &&
|
||||
stackMeasureChannel(config) !== undefined
|
||||
);
|
||||
}
|
||||
@@ -649,7 +732,7 @@ export function builderWarnings(
|
||||
// "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) {
|
||||
if (mark === 'area' && colorIsSeries(config) && !config.stack) {
|
||||
const fixes: BuilderWarningFix[] = [];
|
||||
if (supportsStack(config)) {
|
||||
fixes.push({ label: 'Stack', apply: (c) => ({ ...c, stack: 'zero' }) });
|
||||
@@ -921,7 +1004,12 @@ export function pruneEncodings(config: BuilderConfig, base: BuilderColumns): Bui
|
||||
const encodings = { ...config.encodings };
|
||||
for (const channel of CHANNELS) {
|
||||
const mapping = encodings[channel];
|
||||
if (mapping && mapping.field !== undefined && !available.has(mapping.field)) {
|
||||
if (
|
||||
mapping &&
|
||||
!isValueMapping(mapping) &&
|
||||
mapping.field !== undefined &&
|
||||
!available.has(mapping.field)
|
||||
) {
|
||||
encodings[channel] = null;
|
||||
changed = true;
|
||||
}
|
||||
@@ -934,6 +1022,10 @@ 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 constant value: `{ value }` — a fixed colour/size, no field/type/transform.
|
||||
if (mapping.value !== undefined) {
|
||||
return { value: mapping.value };
|
||||
}
|
||||
// A field-less count: `{ aggregate: 'count', type: 'quantitative' }`.
|
||||
if (mapping.aggregate === 'count') {
|
||||
return { aggregate: 'count', type: 'quantitative' };
|
||||
@@ -1014,6 +1106,7 @@ function markLabel(mark: MarkType): string {
|
||||
|
||||
/** A human phrase for what a channel encodes, e.g. "sum of revenue", "count". */
|
||||
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) return `${mapping.aggregate} of ${field}`;
|
||||
|
||||
Reference in New Issue
Block a user