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:
@@ -4,6 +4,7 @@ import {
|
||||
validFieldTypes,
|
||||
defaultMark,
|
||||
isChannelTypeAllowed,
|
||||
isColumnAllowedOnChannel,
|
||||
supportsAggregate,
|
||||
supportsBin,
|
||||
supportsTimeUnit,
|
||||
@@ -13,6 +14,10 @@ import {
|
||||
builderWarnings,
|
||||
defaultBuilderConfig,
|
||||
isBuilderConfigValid,
|
||||
isValueMapping,
|
||||
channelAcceptsValue,
|
||||
defaultChannelValue,
|
||||
coerceChannelValue,
|
||||
buildChartSpec,
|
||||
buildSnippetSpecText,
|
||||
generateChartName,
|
||||
@@ -106,6 +111,23 @@ describe('isChannelTypeAllowed (Size discipline)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isColumnAllowedOnChannel (field-shelf placement by default type)', () => {
|
||||
it('lets Size take only a numeric column (its default type is the magnitude)', () => {
|
||||
expect(isColumnAllowedOnChannel('size', 'number')).toBe(true);
|
||||
expect(isColumnAllowedOnChannel('size', 'string')).toBe(false);
|
||||
expect(isColumnAllowedOnChannel('size', 'date')).toBe(false);
|
||||
expect(isColumnAllowedOnChannel('size', 'boolean')).toBe(false);
|
||||
});
|
||||
|
||||
it('lets X/Y/Color take any column type', () => {
|
||||
for (const ch of ['x', 'y', 'color'] as const) {
|
||||
for (const t of ['number', 'string', 'date', 'boolean'] as const) {
|
||||
expect(isColumnAllowedOnChannel(ch, t)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('builderWarnings (Tier B advisories)', () => {
|
||||
it('warns when a line/area mark is missing an axis', () => {
|
||||
const w = builderWarnings({
|
||||
@@ -690,6 +712,80 @@ describe('buildChartSpec', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('value channels (constant colour / size — the Property model)', () => {
|
||||
it('emits `{ value }` and ignores field/type/transforms when a constant is set', () => {
|
||||
const spec = buildChartSpec({
|
||||
datasetName: 'D',
|
||||
mark: 'point',
|
||||
encodings: {
|
||||
x: { field: 'category', type: 'nominal' },
|
||||
// A constant carries a (preserved-but-ignored) type and even a stale field;
|
||||
// the assembler still emits only `{ value }`.
|
||||
color: { value: '#c0392b', type: 'nominal', field: 'category', aggregate: 'sum' },
|
||||
size: { value: 100, type: 'quantitative' },
|
||||
},
|
||||
});
|
||||
const enc = spec.encoding as Record<string, Record<string, unknown>>;
|
||||
expect(enc.color).toEqual({ value: '#c0392b' });
|
||||
expect(enc.size).toEqual({ value: 100 });
|
||||
});
|
||||
|
||||
it('counts a constant channel as mapped (saveable) and preserves channel order', () => {
|
||||
const config: BuilderConfig = {
|
||||
datasetName: 'D',
|
||||
mark: 'point',
|
||||
encodings: { color: { value: 'steelblue', type: 'nominal' } },
|
||||
};
|
||||
expect(isBuilderConfigValid(config)).toBe(true);
|
||||
expect(Object.keys(buildChartSpec(config).encoding as object)).toEqual(['color']);
|
||||
});
|
||||
|
||||
it('a constant Colour is not a series: no stacking, no area-split hint', () => {
|
||||
const base: BuilderConfig = {
|
||||
datasetName: 'D',
|
||||
mark: 'bar',
|
||||
encodings: {
|
||||
x: { field: 'category', type: 'nominal' },
|
||||
y: { field: 'value', type: 'quantitative' },
|
||||
color: { value: '#4c78a8', type: 'nominal' },
|
||||
},
|
||||
};
|
||||
// A field-bound Colour would enable stacking and (on area) flag the split hint;
|
||||
// a constant Colour produces neither — it draws every mark one fixed colour.
|
||||
expect(supportsStack(base)).toBe(false);
|
||||
const areaWarnings = builderWarnings({ ...base, mark: 'area' });
|
||||
expect(areaWarnings.some((w) => w.message.includes('per-series change'))).toBe(false);
|
||||
|
||||
// Sanity: the same config with a *field* Colour does enable stacking.
|
||||
const seriesColor: BuilderConfig = {
|
||||
...base,
|
||||
encodings: { ...base.encodings, color: { field: 'category', type: 'nominal' } },
|
||||
};
|
||||
expect(supportsStack(seriesColor)).toBe(true);
|
||||
});
|
||||
|
||||
it('describes the Property-model helpers', () => {
|
||||
expect(isValueMapping({ value: 10, type: 'quantitative' })).toBe(true);
|
||||
expect(isValueMapping({ field: 'value', type: 'quantitative' })).toBe(false);
|
||||
|
||||
expect(channelAcceptsValue('color')).toBe(true);
|
||||
expect(channelAcceptsValue('size')).toBe(true);
|
||||
expect(channelAcceptsValue('x')).toBe(false);
|
||||
expect(channelAcceptsValue('y')).toBe(false);
|
||||
|
||||
expect(typeof defaultChannelValue('color')).toBe('string');
|
||||
expect(defaultChannelValue('size')).toBe(100);
|
||||
});
|
||||
|
||||
it('coerces a constant value by channel (size → number, others → string)', () => {
|
||||
expect(coerceChannelValue('size', '40')).toBe(40);
|
||||
expect(coerceChannelValue('size', '')).toBe(''); // blank stays raw, not NaN
|
||||
expect(coerceChannelValue('size', 'big')).toBe('big'); // non-numeric stays raw
|
||||
expect(coerceChannelValue('color', '#ff0000')).toBe('#ff0000');
|
||||
expect(coerceChannelValue('color', 'red')).toBe('red');
|
||||
});
|
||||
});
|
||||
|
||||
describe('transforms — aggregate / bin / timeUnit', () => {
|
||||
it('emits a field-less count encoding', () => {
|
||||
const spec = buildChartSpec({
|
||||
|
||||
+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