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
+217 -57
View File
@@ -2,12 +2,16 @@
* Chart Builder — the modal body (spec §06).
*
* A two-pane composer: left is the configuration (dataset name, mark selector, one
* row per channel, optional dimensions, guidance, Create), right is a live preview
* of the spec the configuration produces. All spec logic and Tier-B defaults/guards
* come from `@core/chart-builder` via `ChartBuilderStore`; this component is the
* view. The preview is builder-local (its own debounced render over the shared
* `chart-renderer` service) rather than a reuse of `LivePreview`, which is bound to
* the snippet editor's stores.
* block per channel, chart-level sort/stacking, optional dimensions, guidance,
* Create), right is a live preview of the spec the configuration produces. All spec
* logic and Tier-B defaults/guards come from `@core/chart-builder` via
* `ChartBuilderStore`; this component is the view. Each channel is a small block:
* a column dropdown (with a field-less "Count of records" option), a fixed
* `N | O | Q | T` field-type segmented control (the column's invalid types are
* disabled), and the transforms that apply to its type (aggregate + bin for a
* measure, granularity for a temporal field). The preview is builder-local (its own
* debounced render over the shared `chart-renderer` service) rather than a reuse of
* `LivePreview`, which is bound to the snippet editor's stores.
*/
import { useEffect, useMemo, useRef, useState } from 'react';
@@ -15,15 +19,25 @@ import { useShallow } from 'zustand/react/shallow';
import type { VisualizationSpec } from 'vega-embed';
import {
CHANNELS,
FIELD_TYPES,
MARK_TYPES,
TIME_UNITS,
builderWarnings,
defaultFieldType,
isBuilderConfigValid,
isChannelTypeAllowed,
supportsAggregate,
supportsBin,
supportsSort,
supportsStack,
supportsTimeUnit,
validFieldTypes,
type AggregateOp,
type ChannelMapping,
type ChannelName,
type FieldType,
type MarkType,
type TimeUnit,
} from '@core/chart-builder';
import type { ColumnType } from '@core/type-inference';
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
@@ -33,6 +47,7 @@ import { closeModal } from '../modals/ModalCoordinator';
import { useAppStore } from '../stores/AppStore';
import { useDatasetStore } from '../stores/DatasetStore';
import {
COUNT_FIELD,
selectBuilderSpecText,
selectBuilderValid,
useChartBuilderStore,
@@ -42,7 +57,7 @@ import styles from './ChartBuilderModal.module.css';
const RENDER_DEBOUNCE_MS = 300;
/** Title-case a token for display (e.g. `bar` → `Bar`, `quantitative` → `Quantitative`). */
/** Title-case a token for display (e.g. `bar` → `Bar`, `sum` → `Sum`). */
function titleCase(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1);
}
@@ -59,7 +74,33 @@ const CHANNEL_LABELS: Record<ChannelName, string> = {
size: 'Size',
};
/** A compact type indicator for a column option (text · # · date · ✓). */
/** The fixed N | O | Q | T field-type segments (terse, with full-name tooltips). */
const TYPE_ORDER: readonly FieldType[] = ['nominal', 'ordinal', 'quantitative', 'temporal'];
const TYPE_ABBR: Record<FieldType, string> = {
nominal: 'N',
ordinal: 'O',
quantitative: 'Q',
temporal: 'T',
};
/** Non-count aggregate operators offered for a quantitative field. */
const FIELD_AGGREGATES: readonly AggregateOp[] = ['sum', 'mean', 'median', 'min', 'max'];
/** Friendly labels for each temporal granularity. */
const TIME_UNIT_LABELS: Record<TimeUnit, string> = {
year: 'Year',
yearquarter: 'Year-Quarter',
yearmonth: 'Year-Month',
yearmonthdate: 'Year-Month-Day',
quarter: 'Quarter',
month: 'Month',
week: 'Week',
date: 'Day of month',
day: 'Day of week',
hours: 'Hour',
};
/** A compact type indicator for a column option (# / date / bool / text). */
function typeBadge(type: ColumnType): string {
switch (type) {
case 'number':
@@ -78,62 +119,145 @@ function columnAllowedOnChannel(channel: ChannelName, colType: ColumnType): bool
return isChannelTypeAllowed(channel, defaultFieldType(colType));
}
function ChannelRow({ channel }: { channel: ChannelName }) {
/** True when the mapping is the field-less Count-of-records measure. */
function isCount(mapping: ChannelMapping | null): boolean {
return !!mapping && mapping.aggregate === 'count' && mapping.field === undefined;
}
function ChannelBlock({ channel }: { channel: ChannelName }) {
const columns = useChartBuilderStore((s) => s.columns);
const mapping = useChartBuilderStore((s) => s.config.encodings[channel] ?? null);
const setChannelColumn = useChartBuilderStore((s) => s.setChannelColumn);
const setChannelType = useChartBuilderStore((s) => s.setChannelType);
const setChannelAggregate = useChartBuilderStore((s) => s.setChannelAggregate);
const setChannelBin = useChartBuilderStore((s) => s.setChannelBin);
const setChannelTimeUnit = useChartBuilderStore((s) => s.setChannelTimeUnit);
const colTypeOf = (name: string): ColumnType =>
columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
// Type options valid for this column AND allowed on this channel (e.g. Size hides
// Nominal). Shown only when >1 option and a column is selected (spec §06).
const typeOptions: FieldType[] = mapping
? validFieldTypes(colTypeOf(mapping.field)).filter((t) => isChannelTypeAllowed(channel, t))
: [];
// The fixed N|O|Q|T control: a column's invalid types and types disallowed on this
// channel (e.g. a category on Size) are disabled, never hidden, so the control keeps
// one shape on every channel (APG radio with disabled options).
const typeSegments: ReadonlyArray<SegmentedOption<FieldType>> = useMemo(() => {
const valid = mapping?.field !== undefined ? validFieldTypes(colTypeOf(mapping.field)) : [];
return TYPE_ORDER.filter((t) => FIELD_TYPES.includes(t)).map((t) => ({
value: t,
label: TYPE_ABBR[t],
title: titleCase(t),
disabled: !(valid.includes(t) && isChannelTypeAllowed(channel, t)),
}));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [channel, mapping?.field, columns]);
const selectValue =
mapping === null ? '' : isCount(mapping) ? COUNT_FIELD : (mapping.field ?? '');
return (
<div className={styles.channelRow}>
<label className={styles.channelLabel} htmlFor={`ch-${channel}`}>
{CHANNEL_LABELS[channel]}
</label>
<select
id={`ch-${channel}`}
className={styles.select}
value={mapping?.field ?? ''}
onChange={(e) => setChannelColumn(channel, e.target.value === '' ? null : e.target.value)}
>
<option value="">None</option>
{columns.columns.map((name) => {
const allowed = columnAllowedOnChannel(channel, colTypeOf(name));
return (
<option key={name} value={name} disabled={!allowed}>
{name} · {typeBadge(colTypeOf(name))}
{allowed ? '' : ' (needs a measure)'}
</option>
);
})}
</select>
{mapping && typeOptions.length > 1 && (
<div className={styles.channel}>
<div className={styles.channelTop}>
<span className={styles.channelLabel}>{CHANNEL_LABELS[channel]}</span>
<select
className={styles.typeSelect}
aria-label={`${CHANNEL_LABELS[channel]} field type`}
value={mapping.type}
onChange={(e) => setChannelType(channel, e.target.value as FieldType)}
className={styles.select}
aria-label={`${CHANNEL_LABELS[channel]} column`}
value={selectValue}
onChange={(e) => setChannelColumn(channel, e.target.value === '' ? null : e.target.value)}
>
{typeOptions.map((t) => (
<option key={t} value={t}>
{titleCase(t)}
</option>
))}
<option value="">None</option>
<option value={COUNT_FIELD}>Count of records</option>
{columns.columns.map((name) => {
const allowed = columnAllowedOnChannel(channel, colTypeOf(name));
return (
<option key={name} value={name} disabled={!allowed}>
{name} · {typeBadge(colTypeOf(name))}
{allowed ? '' : ' (needs a measure)'}
</option>
);
})}
</select>
</div>
{mapping && !isCount(mapping) && (
<div className={styles.channelControls}>
<SegmentedControl
label={`${CHANNEL_LABELS[channel]} field type`}
options={typeSegments}
value={mapping.type}
onChange={(t) => setChannelType(channel, t)}
className={styles.typeSeg}
/>
{supportsAggregate(mapping.type) && (
<label className={styles.transform}>
<span className={styles.miniLabel}>Aggregate</span>
<select
className={styles.mini}
value={mapping.aggregate && mapping.aggregate !== 'count' ? mapping.aggregate : ''}
onChange={(e) =>
setChannelAggregate(
channel,
(e.target.value || undefined) as AggregateOp | undefined,
)
}
>
<option value="">None</option>
{FIELD_AGGREGATES.map((op) => (
<option key={op} value={op}>
{titleCase(op)}
</option>
))}
</select>
</label>
)}
{supportsBin(mapping.type) && (
<label className={styles.toggle}>
<input
type="checkbox"
checked={!!mapping.bin}
onChange={(e) => setChannelBin(channel, e.target.checked)}
/>
Bin
</label>
)}
{supportsTimeUnit(mapping.type) && (
<label className={styles.transform}>
<span className={styles.miniLabel}>Granularity</span>
<select
className={styles.mini}
value={mapping.timeUnit ?? ''}
onChange={(e) =>
setChannelTimeUnit(channel, (e.target.value || undefined) as TimeUnit | undefined)
}
>
<option value="">None (raw)</option>
{TIME_UNITS.map((u) => (
<option key={u} value={u}>
{TIME_UNIT_LABELS[u]}
</option>
))}
</select>
</label>
)}
</div>
)}
</div>
);
}
/** Sort control values: 'none' maps to an unsorted config. */
const SORT_OPTIONS: ReadonlyArray<SegmentedOption<'none' | 'ascending' | 'descending'>> = [
{ value: 'none', label: 'None' },
{ value: 'ascending', label: 'Asc' },
{ value: 'descending', label: 'Desc' },
];
const STACK_OPTIONS: ReadonlyArray<SegmentedOption<'zero' | 'normalize'>> = [
{ value: 'zero', label: 'Stacked' },
{ value: 'normalize', label: '100%' },
];
function BuilderPreview() {
const hostRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<RenderHandle | null>(null);
@@ -150,8 +274,6 @@ function BuilderPreview() {
const timer = setTimeout(() => {
void (async () => {
const mine = ++generationRef.current;
// Below validation there is nothing to draw — clear the chart and show the
// configuration prompt, not an error (spec §06 → Live Preview placeholder).
if (!valid) {
handleRef.current?.destroy();
handleRef.current = null;
@@ -189,7 +311,6 @@ function BuilderPreview() {
return () => clearTimeout(timer);
}, [specText, valid, uiTheme, datasets]);
// Finalize the view on unmount so the Vega view and its listeners don't leak.
useEffect(
() => () => {
handleRef.current?.destroy();
@@ -221,24 +342,30 @@ export function ChartBuilderModal() {
const mark = useChartBuilderStore((s) => s.config.mark);
const width = useChartBuilderStore((s) => s.config.width);
const height = useChartBuilderStore((s) => s.config.height);
const sort = useChartBuilderStore((s) => s.config.sort);
const stack = useChartBuilderStore((s) => s.config.stack);
const setMark = useChartBuilderStore((s) => s.setMark);
const swapXY = useChartBuilderStore((s) => s.swapXY);
const setSort = useChartBuilderStore((s) => s.setSort);
const setStack = useChartBuilderStore((s) => s.setStack);
const setWidth = useChartBuilderStore((s) => s.setWidth);
const setHeight = useChartBuilderStore((s) => s.setHeight);
const runCreate = useChartBuilderStore((s) => s.createSnippet);
// Derive validity + guidance from the stable `config` reference via useMemo, NOT
// from a store selector: `builderWarnings` builds a fresh array of objects each
// call, which no selector-equality (even useShallow, since the element objects
// differ every time) can stabilize — subscribing to it would re-render forever.
// Validity + guidance + which chart-level controls apply are derived from the
// stable `config` reference via useMemo, NOT a store selector that would build a
// fresh array each render (which loops useSyncExternalStore — see SnippetStore note).
const config = useChartBuilderStore((s) => s.config);
const rowCount = useChartBuilderStore((s) => s.rowCount);
const valid = useMemo(() => isBuilderConfigValid(config), [config]);
const warnings = useMemo(() => builderWarnings(config), [config]);
const warnings = useMemo(() => builderWarnings(config, rowCount), [config, rowCount]);
const canSort = useMemo(() => supportsSort(config), [config]);
const canStack = useMemo(() => supportsStack(config), [config]);
if (datasetId === null) {
return <p className={styles.muted}>No dataset loaded. Open this from a dataset in Datasets.</p>;
}
/** Parse a dimension input: blank → undefined, otherwise a non-negative integer. */
const parseDim = (raw: string): number | undefined => {
if (raw.trim() === '') return undefined;
const n = Number(raw);
@@ -270,12 +397,39 @@ export function ChartBuilderModal() {
</button>
</div>
{CHANNELS.map((channel) => (
<ChannelRow key={channel} channel={channel} />
<ChannelBlock key={channel} channel={channel} />
))}
</div>
{(canSort || canStack) && (
<div className={styles.chartControls}>
{canSort && (
<div className={styles.field}>
<span className={styles.fieldLabel}>Sort</span>
<SegmentedControl
label="Sort the category axis by its measure"
options={SORT_OPTIONS}
value={sort ?? 'none'}
onChange={(v) => setSort(v === 'none' ? undefined : v)}
/>
</div>
)}
{canStack && (
<div className={styles.field}>
<span className={styles.fieldLabel}>Stacking</span>
<SegmentedControl
label="Stacking mode"
options={STACK_OPTIONS}
value={stack ?? 'zero'}
onChange={setStack}
/>
</div>
)}
</div>
)}
<div className={styles.dimensions}>
<span className={styles.fieldLabel}>Dimensions (optional)</span>
<span className={styles.fieldLabel}>Size (optional)</span>
<div className={styles.dimInputs}>
<label className={styles.dimField}>
<span>Width</span>
@@ -312,6 +466,11 @@ export function ChartBuilderModal() {
</ul>
)}
{!valid && (
<p id="cb-create-hint" className={styles.createHint}>
Map at least one channel to a column to create a snippet.
</p>
)}
<div className={styles.actions}>
<button type="button" className={styles.action} onClick={() => void closeModal()}>
Cancel
@@ -320,6 +479,7 @@ export function ChartBuilderModal() {
type="button"
className={`${styles.action} ${styles.primary}`}
disabled={!valid}
aria-describedby={!valid ? 'cb-create-hint' : undefined}
onClick={() => runCreate()}
>
Create Snippet