Add aggregation, binning, granularity, sort, and stacking to the Chart Builder

- Per-channel transforms: aggregate (sum/mean/median/min/max), quantitative
  bin, and temporal timeUnit granularity; bin and aggregate are mutually
  exclusive. A field-less "Count of records" measure (Voyager's count(*)).
- Chart-level sort (rank a categorical axis by its measure) and stacking
  (zero / 100% normalize), each shown only when it applies.
- Field type is a fixed N|O|Q|T segmented control with the column's invalid
  types disabled; SegmentedControl gains APG-correct disabled options.
- A crowded-category-axis warning (a raw measure drawing one mark per row over
  a large dataset) and a disabled-Create hint (says why it's disabled).
- Drop the Create success toast — the new snippet is immediately visible.
- Docs: spec §06, research-doc §8 backlog (incl. the cardinality/extent
  profiling TODO), architecture 01 (stable-selector rule) and 05 (builder-local
  preview), and a profiling breadcrumb.
This commit is contained in:
2026-06-06 18:04:24 +03:00
parent c11afc273d
commit af9ee1e4c0
14 changed files with 982 additions and 119 deletions
+214
View File
@@ -4,6 +4,12 @@ import {
validFieldTypes,
defaultMark,
isChannelTypeAllowed,
supportsAggregate,
supportsBin,
supportsTimeUnit,
supportsSort,
supportsStack,
sortableCategoryChannel,
builderWarnings,
defaultBuilderConfig,
isBuilderConfigValid,
@@ -12,6 +18,7 @@ import {
generateChartName,
type BuilderColumns,
type BuilderConfig,
type ChannelMapping,
} from './chart-builder';
import { VEGA_LITE_SCHEMA_URL } from './snippet';
@@ -143,6 +150,79 @@ describe('builderWarnings (Tier B advisories)', () => {
});
expect(w).toEqual([]);
});
describe('crowded category axis (one mark per row)', () => {
const crowded = (overrides: Partial<ChannelMapping> = {}) =>
builderWarnings(
{
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'name', type: 'nominal' },
y: { field: 'mpg', type: 'quantitative', ...overrides },
},
},
406,
);
it('warns when a raw measure draws one bar per row over a large dataset', () => {
const w = crowded();
const hint = w.find((m) => /one mark per row/.test(m.message));
expect(hint?.channel).toBe('x'); // the category axis
expect(hint?.message).toContain('406 in this dataset');
expect(hint?.message).toMatch(/Swap X\/Y/); // bar → horizontal-bar remedy
});
it('is silent once the measure is aggregated (one bar per category)', () => {
const w = crowded({ aggregate: 'mean' });
expect(w.some((m) => /one mark per row/.test(m.message))).toBe(false);
});
it('is silent for a small dataset even with a raw measure', () => {
const w = builderWarnings(
{
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'name', type: 'nominal' },
y: { field: 'mpg', type: 'quantitative' },
},
},
12,
);
expect(w.some((m) => /one mark per row/.test(m.message))).toBe(false);
});
it('is silent when the row count is unknown (URL/non-tabular)', () => {
const w = builderWarnings({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'name', type: 'nominal' },
y: { field: 'mpg', type: 'quantitative' },
},
});
expect(w.some((m) => /one mark per row/.test(m.message))).toBe(false);
});
it('uses non-bar wording (no Swap X/Y) for a line mark', () => {
const w = builderWarnings(
{
datasetName: 'D',
mark: 'line',
encodings: {
x: { field: 'name', type: 'nominal' },
y: { field: 'mpg', type: 'quantitative' },
},
},
406,
);
const hint = w.find((m) => /one mark per row/.test(m.message));
expect(hint).toBeDefined();
expect(hint?.message).not.toMatch(/Swap X\/Y/);
expect(hint?.message).toMatch(/reduce the number of categories/);
});
});
});
describe('defaultBuilderConfig', () => {
@@ -263,6 +343,140 @@ describe('buildChartSpec', () => {
});
});
describe('transforms — aggregate / bin / timeUnit', () => {
it('emits a field-less count encoding', () => {
const spec = buildChartSpec({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { type: 'quantitative', aggregate: 'count' },
},
});
const enc = spec.encoding as Record<string, Record<string, unknown>>;
expect(enc.y).toEqual({ aggregate: 'count', type: 'quantitative' });
expect(enc.y.field).toBeUndefined();
});
it('emits a non-count aggregate with its field', () => {
const spec = buildChartSpec({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'revenue', type: 'quantitative', aggregate: 'sum' },
},
});
const enc = spec.encoding as Record<string, Record<string, unknown>>;
expect(enc.y).toEqual({ field: 'revenue', type: 'quantitative', aggregate: 'sum' });
});
it('emits bin on a quantitative field (histogram shape) and timeUnit on a temporal one', () => {
const hist = buildChartSpec({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'price', type: 'quantitative', bin: true },
y: { type: 'quantitative', aggregate: 'count' },
},
});
const henc = hist.encoding as Record<string, Record<string, unknown>>;
expect(henc.x).toEqual({ field: 'price', type: 'quantitative', bin: true });
const ts = buildChartSpec({
datasetName: 'D',
mark: 'line',
encodings: {
x: { field: 'day', type: 'temporal', timeUnit: 'yearmonth' },
y: { field: 'v', type: 'quantitative' },
},
});
const tenc = ts.encoding as Record<string, Record<string, unknown>>;
expect(tenc.x).toEqual({ field: 'day', type: 'temporal', timeUnit: 'yearmonth' });
});
it('exposes the transform-applicability predicates by field type', () => {
expect(supportsAggregate('quantitative')).toBe(true);
expect(supportsAggregate('nominal')).toBe(false);
expect(supportsBin('quantitative')).toBe(true);
expect(supportsBin('temporal')).toBe(false);
expect(supportsTimeUnit('temporal')).toBe(true);
expect(supportsTimeUnit('quantitative')).toBe(false);
});
});
describe('sort (ranking)', () => {
const ranking: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'revenue', type: 'quantitative', aggregate: 'sum' },
},
};
it('sorts the category axis by the measure axis (descending → "-y")', () => {
expect(sortableCategoryChannel(ranking)).toBe('x');
expect(supportsSort(ranking)).toBe(true);
const enc = buildChartSpec({ ...ranking, sort: 'descending' }).encoding as Record<
string,
Record<string, unknown>
>;
expect(enc.x.sort).toBe('-y');
const asc = buildChartSpec({ ...ranking, sort: 'ascending' }).encoding as Record<
string,
Record<string, unknown>
>;
expect(asc.x.sort).toBe('y');
});
it('does not offer sort when both axes are measures', () => {
const scatter: BuilderConfig = {
datasetName: 'D',
mark: 'point',
encodings: {
x: { field: 'a', type: 'quantitative' },
y: { field: 'b', type: 'quantitative' },
},
};
expect(supportsSort(scatter)).toBe(false);
expect(buildChartSpec({ ...scatter, sort: 'descending' }).encoding).toBeDefined();
const enc = buildChartSpec({ ...scatter, sort: 'descending' }).encoding as Record<
string,
Record<string, unknown>
>;
expect(enc.x.sort).toBeUndefined();
});
});
describe('stack (part-to-whole)', () => {
const stacked: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'month', type: 'ordinal' },
y: { field: 'sales', type: 'quantitative', aggregate: 'sum' },
color: { field: 'product', type: 'nominal' },
},
};
it('stacks the quantitative axis for a bar/area + colour series', () => {
expect(supportsStack(stacked)).toBe(true);
const enc = buildChartSpec({ ...stacked, stack: 'normalize' }).encoding as Record<
string,
Record<string, unknown>
>;
expect(enc.y.stack).toBe('normalize');
});
it('does not stack without a colour series or on a point mark', () => {
expect(supportsStack({ ...stacked, encodings: { ...stacked.encodings, color: null } })).toBe(
false,
);
expect(supportsStack({ ...stacked, mark: 'point' })).toBe(false);
});
});
describe('buildSnippetSpecText', () => {
it('produces pretty-printed JSON that parses back to the spec', () => {
const config = defaultBuilderConfig('Sales', columns);