Add Chart Builder: no-JSON Vega-Lite composer from a dataset (M4)

This commit is contained in:
2026-06-05 23:46:21 +03:00
parent 693f5d7073
commit c11afc273d
16 changed files with 1856 additions and 26 deletions
+294
View File
@@ -0,0 +1,294 @@
import { describe, it, expect } from 'vitest';
import {
defaultFieldType,
validFieldTypes,
defaultMark,
isChannelTypeAllowed,
builderWarnings,
defaultBuilderConfig,
isBuilderConfigValid,
buildChartSpec,
buildSnippetSpecText,
generateChartName,
type BuilderColumns,
type BuilderConfig,
} from './chart-builder';
import { VEGA_LITE_SCHEMA_URL } from './snippet';
const columns: BuilderColumns = {
columns: ['category', 'value', 'when', 'flag'],
columnTypes: [
{ name: 'category', type: 'string' },
{ name: 'value', type: 'number' },
{ name: 'when', type: 'date' },
{ name: 'flag', type: 'boolean' },
],
};
describe('defaultFieldType', () => {
it('maps inferred column types to Vega-Lite field types (spec §06)', () => {
expect(defaultFieldType('number')).toBe('quantitative');
expect(defaultFieldType('date')).toBe('temporal');
expect(defaultFieldType('string')).toBe('nominal');
expect(defaultFieldType('boolean')).toBe('nominal');
});
it('is always the head of validFieldTypes (no drift)', () => {
for (const t of ['number', 'date', 'string', 'boolean'] as const) {
expect(defaultFieldType(t)).toBe(validFieldTypes(t)[0]);
}
});
});
describe('validFieldTypes (Tier B valid-type locking)', () => {
it('never offers Quantitative for string/boolean, nor Temporal for non-date', () => {
expect(validFieldTypes('string')).not.toContain('quantitative');
expect(validFieldTypes('boolean')).not.toContain('quantitative');
expect(validFieldTypes('number')).not.toContain('temporal');
expect(validFieldTypes('string')).not.toContain('temporal');
});
it('locks date to Temporal only and offers Ordinal where order is plausible', () => {
expect(validFieldTypes('date')).toEqual(['temporal']);
expect(validFieldTypes('number')).toContain('ordinal');
expect(validFieldTypes('string')).toContain('ordinal');
});
});
describe('defaultMark (Tier B smart default)', () => {
it('picks Line for time × measure, Point for two measures, Bar for category × measure', () => {
expect(defaultMark('temporal', 'quantitative')).toBe('line');
expect(defaultMark('quantitative', 'temporal')).toBe('line');
expect(defaultMark('quantitative', 'quantitative')).toBe('point');
expect(defaultMark('nominal', 'quantitative')).toBe('bar');
expect(defaultMark('quantitative', 'nominal')).toBe('bar');
});
it('uses Point when both axes are discrete (Bar/Line/Area need a continuous axis)', () => {
expect(defaultMark('nominal', 'nominal')).toBe('point');
expect(defaultMark('nominal', 'ordinal')).toBe('point');
});
it('falls back to Bar when an axis is unmapped', () => {
expect(defaultMark('quantitative', null)).toBe('bar');
expect(defaultMark(null, null)).toBe('bar');
});
});
describe('isChannelTypeAllowed (Size discipline)', () => {
it('forbids Size for Nominal and Temporal, allows it for Quantitative/Ordinal', () => {
expect(isChannelTypeAllowed('size', 'nominal')).toBe(false);
expect(isChannelTypeAllowed('size', 'temporal')).toBe(false);
expect(isChannelTypeAllowed('size', 'quantitative')).toBe(true);
expect(isChannelTypeAllowed('size', 'ordinal')).toBe(true);
});
it('allows any type on X/Y/Color', () => {
for (const ch of ['x', 'y', 'color'] as const) {
expect(isChannelTypeAllowed(ch, 'nominal')).toBe(true);
expect(isChannelTypeAllowed(ch, 'temporal')).toBe(true);
}
});
});
describe('builderWarnings (Tier B advisories)', () => {
it('warns when a line/area mark is missing an axis', () => {
const w = builderWarnings({
datasetName: 'D',
mark: 'line',
encodings: { x: { field: 'a', type: 'temporal' } },
});
expect(w.some((m) => /need both an X and a Y/.test(m.message))).toBe(true);
});
it('warns when two measures are drawn on a non-scatter mark', () => {
const w = builderWarnings({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'a', type: 'quantitative' },
y: { field: 'b', type: 'quantitative' },
},
});
expect(w.some((m) => /scatter/.test(m.message))).toBe(true);
});
it('warns when a bar/line/area has no measure on either axis', () => {
const w = builderWarnings({
datasetName: 'D',
mark: 'bar',
encodings: { x: { field: 'a', type: 'nominal' }, y: { field: 'b', type: 'nominal' } },
});
expect(w.some((m) => /need a measure/.test(m.message))).toBe(true);
});
it('warns when an area chart is split into colour series', () => {
const w = builderWarnings({
datasetName: 'D',
mark: 'area',
encodings: {
x: { field: 't', type: 'temporal' },
y: { field: 'v', type: 'quantitative' },
color: { field: 'g', type: 'nominal' },
},
});
expect(w.some((m) => m.channel === 'color')).toBe(true);
});
it('is silent for a clean configuration', () => {
const w = builderWarnings({
datasetName: 'D',
mark: 'line',
encodings: { x: { field: 't', type: 'temporal' }, y: { field: 'v', type: 'quantitative' } },
});
expect(w).toEqual([]);
});
});
describe('defaultBuilderConfig', () => {
it('puts the first column on X and the second on Y, each with derived type', () => {
const config = defaultBuilderConfig('Sales', columns);
expect(config.mark).toBe('bar');
expect(config.datasetName).toBe('Sales');
expect(config.encodings.x).toEqual({ field: 'category', type: 'nominal' });
expect(config.encodings.y).toEqual({ field: 'value', type: 'quantitative' });
expect(config.encodings.color).toBeNull();
expect(config.encodings.size).toBeNull();
});
it('opens as a Line when the first two columns are date × number (smart mark)', () => {
const timeSeries: BuilderColumns = {
columns: ['day', 'visits'],
columnTypes: [
{ name: 'day', type: 'date' },
{ name: 'visits', type: 'number' },
],
};
const config = defaultBuilderConfig('Traffic', timeSeries);
expect(config.mark).toBe('line');
expect(config.encodings.x).toEqual({ field: 'day', type: 'temporal' });
expect(config.encodings.y).toEqual({ field: 'visits', type: 'quantitative' });
});
it('leaves Y unmapped when the dataset has a single column', () => {
const single: BuilderColumns = {
columns: ['only'],
columnTypes: [{ name: 'only', type: 'number' }],
};
const config = defaultBuilderConfig('One', single);
expect(config.encodings.x).toEqual({ field: 'only', type: 'quantitative' });
expect(config.encodings.y).toBeNull();
});
it('maps nothing when the dataset has no detected columns', () => {
const config = defaultBuilderConfig('Empty', { columns: [], columnTypes: [] });
expect(isBuilderConfigValid(config)).toBe(false);
expect(config.encodings.x).toBeNull();
});
});
describe('isBuilderConfigValid', () => {
const base: BuilderConfig = { datasetName: 'D', mark: 'bar', encodings: {} };
it('requires at least one mapped channel', () => {
expect(isBuilderConfigValid(base)).toBe(false);
expect(isBuilderConfigValid({ ...base, encodings: { x: null, y: null } })).toBe(false);
expect(
isBuilderConfigValid({ ...base, encodings: { color: { field: 'c', type: 'nominal' } } }),
).toBe(true);
});
});
describe('buildChartSpec', () => {
it('assembles schema, named data, tooltip mark, and mapped encodings', () => {
const config = defaultBuilderConfig('Sales', columns);
const spec = buildChartSpec(config);
expect(spec).toEqual({
$schema: VEGA_LITE_SCHEMA_URL,
data: { name: 'Sales' },
mark: { type: 'bar', tooltip: true },
encoding: {
x: { field: 'category', type: 'nominal' },
y: { field: 'value', type: 'quantitative' },
},
});
});
it('omits unmapped channels and preserves canonical channel order', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'point',
encodings: {
size: { field: 's', type: 'quantitative' },
x: { field: 'a', type: 'nominal' },
color: null,
},
};
const spec = buildChartSpec(config);
expect(Object.keys(spec.encoding as object)).toEqual(['x', 'size']);
});
it('omits the encoding block entirely when nothing is mapped', () => {
const spec = buildChartSpec({ datasetName: 'D', mark: 'bar', encodings: {} });
expect(spec.encoding).toBeUndefined();
expect(spec.mark).toEqual({ type: 'bar', tooltip: true });
});
it('writes explicit width/height only when provided', () => {
const config: BuilderConfig = {
datasetName: 'D',
mark: 'area',
encodings: { x: { field: 'a', type: 'temporal' } },
width: 400,
height: 300,
};
const spec = buildChartSpec(config);
expect(spec.width).toBe(400);
expect(spec.height).toBe(300);
const noDims = buildChartSpec({ ...config, width: undefined, height: undefined });
expect(noDims.width).toBeUndefined();
expect(noDims.height).toBeUndefined();
});
it('carries every mark type through to the spec', () => {
for (const mark of ['bar', 'line', 'point', 'area', 'circle'] as const) {
const spec = buildChartSpec({
datasetName: 'D',
mark,
encodings: { x: { field: 'a', type: 'nominal' } },
});
expect((spec.mark as { type: string }).type).toBe(mark);
}
});
});
describe('buildSnippetSpecText', () => {
it('produces pretty-printed JSON that parses back to the spec', () => {
const config = defaultBuilderConfig('Sales', columns);
const text = buildSnippetSpecText(config);
expect(text).toContain('\n ');
expect(JSON.parse(text)).toEqual(buildChartSpec(config));
});
});
describe('generateChartName', () => {
it('reads "<Mark> chart of <y> by <x>" when both axes are mapped', () => {
const config = defaultBuilderConfig('Sales', columns);
expect(generateChartName(config)).toBe('Bar chart of value by category');
});
it('names the single mapped field when only one channel is set', () => {
const config: BuilderConfig = {
datasetName: 'Sales',
mark: 'line',
encodings: { color: { field: 'region', type: 'nominal' } },
};
expect(generateChartName(config)).toBe('Line chart of region');
});
it('falls back to the dataset name when nothing is mapped', () => {
const config: BuilderConfig = { datasetName: 'Sales', mark: 'circle', encodings: {} };
expect(generateChartName(config)).toBe('Circle chart of Sales');
});
});