mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user