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
+55 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, test } from 'vitest';
import { createDataset } from '@core/dataset';
import { useChartBuilderStore } from './ChartBuilderStore';
import { COUNT_FIELD, useChartBuilderStore } from './ChartBuilderStore';
import { useDatasetStore } from './DatasetStore';
import { useSnippetStore } from './SnippetStore';
@@ -71,6 +71,60 @@ describe('channel editing', () => {
});
});
describe('transforms — aggregate / bin / timeUnit / count', () => {
test('the Count-of-records option maps a field-less count measure', () => {
const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
cb().init(id);
cb().setChannelColumn('y', COUNT_FIELD);
expect(cb().config.encodings.y).toEqual({ type: 'quantitative', aggregate: 'count' });
});
test('aggregate and bin are mutually exclusive on a channel', () => {
const id = seedDataset('S', [{ revenue: 5 }]);
cb().init(id);
cb().setChannelColumn('y', 'revenue');
cb().setChannelAggregate('y', 'sum');
expect(cb().config.encodings.y).toMatchObject({ field: 'revenue', aggregate: 'sum' });
cb().setChannelBin('y', true);
expect(cb().config.encodings.y?.aggregate).toBeUndefined();
expect(cb().config.encodings.y?.bin).toBe(true);
cb().setChannelAggregate('y', 'mean');
expect(cb().config.encodings.y?.bin).toBeUndefined();
expect(cb().config.encodings.y?.aggregate).toBe('mean');
});
test('changing field type drops transforms that no longer apply', () => {
const id = seedDataset('S', [{ price: 5 }]);
cb().init(id);
cb().setChannelColumn('x', 'price');
cb().setChannelBin('x', true);
cb().setChannelType('x', 'nominal'); // leaving quantitative
expect(cb().config.encodings.x).toEqual({ field: 'price', type: 'nominal' });
});
test('setChannelTimeUnit sets and clears granularity', () => {
const id = seedDataset('S', [{ day: '2026-01-01', v: 1 }]);
cb().init(id);
cb().setChannelTimeUnit('x', 'yearmonth');
expect(cb().config.encodings.x?.timeUnit).toBe('yearmonth');
cb().setChannelTimeUnit('x', undefined);
expect(cb().config.encodings.x?.timeUnit).toBeUndefined();
});
});
describe('sort / stack', () => {
test('setSort and setStack set and clear the chart-level fields', () => {
const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
cb().init(id);
cb().setSort('descending');
expect(cb().config.sort).toBe('descending');
cb().setSort(undefined);
expect(cb().config.sort).toBeUndefined();
cb().setStack('normalize');
expect(cb().config.stack).toBe('normalize');
});
});
describe('createSnippet', () => {
test('builds a linked snippet, activates it, and resets the builder', () => {
const id = seedDataset('Sales', [