Chart builder: field-first shelf + value-or-field channels

This commit is contained in:
2026-06-11 23:56:40 +03:00
parent 9ebe398e75
commit 4dcff4601d
11 changed files with 1192 additions and 184 deletions
+96
View File
@@ -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({