Chart builder: SelectControl pickers, channel chooser, per-type aggregates

This commit is contained in:
2026-06-12 14:45:31 +03:00
parent 4dcff4601d
commit ed66fe9c05
15 changed files with 1232 additions and 276 deletions
+296 -174
View File
@@ -1,15 +1,19 @@
/**
* Chart Builder — the modal body (spec §06).
*
* A two-pane composer: left is the configuration (dataset name, mark selector, one
* 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
* A two-pane composer. Left: the Data section (filters, calculated fields, row
* preview), the mark selector, the field shelf (columns as type-glyphed chips), the
* Marks card (Colour/Size — field or constant), chart-level sort/stacking, guidance,
* and Create. Right: the on-chart Columns/Rows shelves, the live preview, and the
* chart-properties strip (title/subtitle/size). All spec logic and defaults/guards
* come from `@core/chart-builder` via `ChartBuilderStore`; this component is the view.
*
* Assignment is field-first: a chip click opens an explicit channel chooser, or
* assigns directly when a channel is armed (the slot's "Pick a field…" state, made
* visible at the shelf). A bound channel renders as a pill — a field-type chip that
* opens a direct type pick, the field name, a remove ✕ — with its per-type
* transforms (aggregate / bin / granularity) inline beside it. Pickers use
* `SelectControl`, not native selects. 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.
*/
@@ -29,12 +33,14 @@ import {
filterOpArity,
isBuilderConfigValid,
isChannelTypeAllowed,
isColumnAllowedOnChannel,
isValueMapping,
supportsAggregate,
supportsBin,
supportsSort,
supportsStack,
supportsTimeUnit,
validAggregateOps,
validFieldTypes,
validFilterOps,
type BuilderCalculate,
@@ -65,6 +71,7 @@ import {
useChartBuilderStore,
} from '../stores/ChartBuilderStore';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import { SelectControl } from './SelectControl';
import { Icon } from './Icon';
import styles from './ChartBuilderModal.module.css';
@@ -124,8 +131,16 @@ const TYPE_ABBR: Record<FieldType, string> = {
temporal: 'T',
};
/** Non-count aggregate operators offered for a quantitative field. */
const FIELD_AGGREGATES: readonly AggregateOp[] = ['sum', 'mean', 'median', 'min', 'max'];
/** Readable labels for the non-count aggregate operators (`validAggregateOps`
* supplies the per-type menu — e.g. a Nominal field offers only Count distinct). */
const AGGREGATE_LABELS: Record<Exclude<AggregateOp, 'count'>, string> = {
sum: 'Sum',
mean: 'Mean',
median: 'Median',
min: 'Min',
max: 'Max',
distinct: 'Count distinct',
};
/** Friendly labels for each temporal granularity. */
const TIME_UNIT_LABELS: Record<TimeUnit, string> = {
@@ -286,14 +301,9 @@ function ChannelPill({
const count = isCount(mapping);
const typeOptions = count ? [] : channelTypeOptions(channel, mapping, columns);
const canCycle = typeOptions.length > 1;
const canPick = typeOptions.length > 1;
const currentType: FieldType = count ? 'quantitative' : mapping.type;
const label = count ? 'Count' : (mapping.field ?? '');
const cycleType = () => {
if (!canCycle) return;
const i = typeOptions.indexOf(mapping.type);
setChannelType(channel, typeOptions[(i + 1) % typeOptions.length]);
};
const hasTransforms =
!count &&
@@ -304,18 +314,20 @@ function ChannelPill({
return (
<div className={styles.pillWrap}>
<div className={styles.pill}>
{/* TODO(ux-second-pass): the type chip cycles N→O→Q→T — no direct pick for
keyboard/SR users. Cycle vs. explicit radio is parked for a batched council
review (docs/ux-second-pass.md). */}
<button
type="button"
className={styles.pillType}
aria-label={`Field type: ${titleCase(currentType)}${canCycle ? ' — activate to change' : ''}`}
disabled={!canCycle}
onClick={cycleType}
>
{TYPE_ABBR[currentType]}
</button>
{/* The type chip opens a direct pick of the valid types (council 2026-06-12:
a cycling button gave keyboard/SR users no way to jump to a type). */}
<SelectControl
id={`cb-type-${channel}`}
label={`Field type for ${CHANNEL_LABELS[channel]}`}
heading="Field type"
options={typeOptions.map((t) => ({ value: t, label: titleCase(t) }))}
value={currentType}
onSelect={(t) => setChannelType(channel, t)}
triggerClassName={styles.pillType}
triggerContent={TYPE_ABBR[currentType]}
triggerTitle={titleCase(currentType)}
disabled={!canPick}
/>
<span className={styles.pillName} title={label}>
{label}
</span>
@@ -327,26 +339,25 @@ function ChannelPill({
{hasTransforms && (
<div className={styles.pillControls}>
{supportsAggregate(mapping.type) && (
<label className={styles.transform}>
<span className={styles.miniLabel}>Aggregate</span>
<select
className={styles.mini}
<div className={styles.transform}>
<span className={styles.miniLabel} aria-hidden="true">
Aggregate
</span>
<SelectControl
id={`cb-agg-${channel}`}
label={`Aggregate for ${CHANNEL_LABELS[channel]}`}
heading="Aggregate"
options={[
{ value: '', label: 'None' },
...validAggregateOps(mapping.type).map((op) => ({
value: op,
label: AGGREGATE_LABELS[op],
})),
]}
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>
onSelect={(v) => setChannelAggregate(channel, v || undefined)}
/>
</div>
)}
{supportsBin(mapping.type) && (
@@ -361,23 +372,22 @@ function ChannelPill({
)}
{supportsTimeUnit(mapping.type) && (
<label className={styles.transform}>
<span className={styles.miniLabel}>Granularity</span>
<select
className={styles.mini}
<div className={styles.transform}>
<span className={styles.miniLabel} aria-hidden="true">
Granularity
</span>
<SelectControl
id={`cb-tu-${channel}`}
label={`Granularity for ${CHANNEL_LABELS[channel]}`}
heading="Granularity"
options={[
{ value: '', label: 'None (raw)' },
...TIME_UNITS.map((u) => ({ value: u, label: TIME_UNIT_LABELS[u] })),
]}
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>
onSelect={(v) => setChannelTimeUnit(channel, v || undefined)}
/>
</div>
)}
</div>
)}
@@ -421,36 +431,75 @@ function ChannelSlot({ channel, hint }: { channel: ChannelName; hint?: string })
{active ? 'Pick a field…' : (hint ?? 'Add a field')}
</button>
{channelAcceptsValue(channel) && (
// A ghost button, not a link-styled affordance: it acts (binds a constant),
// and Carbon draws the line at links navigate / buttons act (council
// 2026-06-12). Verb-first label per the content rules.
<button
type="button"
className={styles.slotConst}
onClick={() => setChannelConstant(channel, String(defaultChannelValue(channel)))}
>
or constant
Use a constant
</button>
)}
</div>
);
}
/** What a channel currently shows, for the assignment chooser's "replaces …" hint. */
function occupantLabel(mapping: ChannelMapping | null | undefined): string | undefined {
if (!mapping) return undefined;
if (mapping.value !== undefined) return 'replaces the constant';
if (mapping.aggregate === 'count' && !mapping.field) return 'replaces Count';
return mapping.field ? `replaces ${mapping.field}` : undefined;
}
/** Channel names as the assignment chooser shows them (the on-chart shelf words). */
const ASSIGN_LABELS: Record<ChannelName, string> = {
x: 'Columns (X)',
y: 'Rows (Y)',
color: 'Color',
size: 'Size',
};
/**
* The field shelf (spec §06 → Encoding, field-first): the dataset's columns (plus any
* calculated fields and a field-less "Count of records") as clickable chips with a type
* glyph. Clicking a field assigns it to the armed channel, else the first empty channel
* that accepts it (`assignField`). Past `FIELD_SHELF_SPLIT_MIN` columns it groups into
* Dimensions (categories/dates) and Measures (numerics); a small dataset stays flat.
* Already-mapped fields are dimmed (a field may still be placed on several channels).
* glyph. Clicking a chip opens an explicit channel chooser (the channels that accept the
* column; an occupied one says what it would replace); with a channel armed, the click
* assigns straight there instead and Esc disarms. Past `FIELD_SHELF_SPLIT_MIN` columns it
* groups into Dimensions (categories/dates) and Measures (numerics); a small dataset stays
* flat. Already-mapped fields are dimmed (a field may still be placed on several channels).
*/
function FieldShelf() {
const baseColumns = useChartBuilderStore((s) => s.columns);
const calculates = useChartBuilderStore((s) => s.config.calculates);
const encodings = useChartBuilderStore((s) => s.config.encodings);
const assignField = useChartBuilderStore((s) => s.assignField);
const activeChannel = useChartBuilderStore((s) => s.activeChannel);
const focusChannel = useChartBuilderStore((s) => s.focusChannel);
const columns = useMemo(
() => effectiveColumns(baseColumns, calculates),
[baseColumns, calculates],
);
// Esc disarms the armed channel (captured so the modal itself doesn't close).
// TODO: with a channel armed AND a SelectControl popover open, one Esc both closes
// the popover and disarms — both are capture-phase document listeners, so
// stopPropagation can't serialize them. Rare combination; needs a shared
// escape-layer stack if it ever matters.
useEffect(() => {
if (!activeChannel) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
focusChannel(null);
}
};
document.addEventListener('keydown', onKey, true);
return () => document.removeEventListener('keydown', onKey, true);
}, [activeChannel, focusChannel]);
const assigned = useMemo(() => {
const set = new Set<string>();
for (const ch of CHANNELS) {
@@ -463,33 +512,59 @@ function FieldShelf() {
const colTypeOf = (name: string): ColumnType =>
columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
const fieldButton = (name: string) => (
<button
key={name}
type="button"
className={`${styles.shelfField} ${assigned.has(name) ? styles.shelfFieldUsed : ''}`}
onClick={() => assignField(name)}
>
<span className={styles.shelfGlyph} aria-hidden="true">
{TYPE_ABBR[defaultFieldType(colTypeOf(name))]}
</span>
<span className={styles.shelfFieldName}>{name}</span>
</button>
);
// A field chip: with a channel armed, clicking assigns straight there (the fast
// path); unarmed, it opens an explicit channel chooser instead of silently
// grabbing the first empty seat (council 2026-06-12 — NN/g #3, user choice).
const fieldChip = (
name: string,
display: string,
domId: string,
colType: ColumnType,
glyph: string,
) => {
const choices = CHANNELS.filter((ch) => isColumnAllowedOnChannel(ch, colType));
return (
<SelectControl
key={domId}
id={`cb-assign-${domId}`}
label={`Add ${display} to a channel`}
heading="Add to"
options={choices.map((ch) => ({
value: ch,
label: ASSIGN_LABELS[ch],
detail: occupantLabel(encodings[ch]),
}))}
onSelect={(ch) => assignField(name, ch)}
beforeOpen={() => {
if (activeChannel) {
assignField(name);
return false;
}
return true;
}}
triggerClassName={`${styles.shelfField} ${assigned.has(name) ? styles.shelfFieldUsed : ''}`}
triggerContent={
<>
<span className={styles.shelfGlyph} aria-hidden="true">
{glyph}
</span>
<span className={styles.shelfFieldName}>{display}</span>
</>
}
/>
);
};
const countButton = (
<button
key="__count"
type="button"
className={styles.shelfField}
onClick={() => assignField(COUNT_FIELD)}
>
<span className={styles.shelfGlyph} aria-hidden="true">
</span>
<span className={styles.shelfFieldName}>Count of records</span>
</button>
);
const fieldButton = (name: string) =>
fieldChip(
name,
name,
`f${columns.columns.indexOf(name)}`,
colTypeOf(name),
TYPE_ABBR[defaultFieldType(colTypeOf(name))],
);
const countButton = fieldChip(COUNT_FIELD, 'Count of records', 'count', 'number', '∑');
const dimensions = columns.columns.filter((n) => colTypeOf(n) !== 'number');
const measures = columns.columns.filter((n) => colTypeOf(n) === 'number');
@@ -499,22 +574,33 @@ function FieldShelf() {
return (
<div className={styles.fieldShelf}>
<span className={styles.fieldLabel}>Fields</span>
{split ? (
<>
<span className={styles.shelfGroupHead}>Dimensions</span>
<div className={styles.shelfList}>{dimensions.map(fieldButton)}</div>
<span className={styles.shelfGroupHead}>Measures</span>
{/* Arming a channel must be visible at the place the next click happens
(NN/g #1): the shelf gains an accent ring and a status line naming the
target. The hint is a polite status so AT hears the mode change too. */}
{activeChannel && (
<p className={styles.armedHint} role="status">
Assigning to <strong>{CHANNEL_LABELS[activeChannel]}</strong> choose a field below. Esc
cancels.
</p>
)}
<div className={`${styles.shelfScroll} ${activeChannel ? styles.shelfArmed : ''}`}>
{split ? (
<>
<span className={styles.shelfGroupHead}>Dimensions</span>
<div className={styles.shelfList}>{dimensions.map(fieldButton)}</div>
<span className={styles.shelfGroupHead}>Measures</span>
<div className={styles.shelfList}>
{measures.map(fieldButton)}
{countButton}
</div>
</>
) : (
<div className={styles.shelfList}>
{measures.map(fieldButton)}
{columns.columns.map(fieldButton)}
{countButton}
</div>
</>
) : (
<div className={styles.shelfList}>
{columns.columns.map(fieldButton)}
{countButton}
</div>
)}
)}
</div>
</div>
);
}
@@ -687,19 +773,15 @@ function FilterRow({ filter, columns }: { filter: BuilderFilter; columns: Builde
onChange={(e) => updateFilter(filter.id, { expr: e.target.value })}
/>
) : (
<select
className={styles.filterField}
aria-label="Filter field"
value={filter.field ?? ''}
onChange={(e) => setFilterField(filter.id, e.target.value)}
>
{!filter.field && <option value="">Choose a field</option>}
{columns.columns.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
<SelectControl
id={`cb-ffield-${filter.id}`}
label="Filter field"
heading="Filter field"
options={columns.columns.map((name) => ({ value: name, label: name }))}
value={filter.field}
onSelect={(name) => setFilterField(filter.id, name)}
triggerContent={filter.field ? undefined : 'Choose a field…'}
/>
)}
<button
type="button"
@@ -713,18 +795,17 @@ function FilterRow({ filter, columns }: { filter: BuilderFilter; columns: Builde
{!expressionMode && (
<div className={styles.filterPredicate}>
<select
className={styles.mini}
aria-label="Filter operator"
<SelectControl
id={`cb-fop-${filter.id}`}
label="Filter operator"
heading="Operator"
options={validFilterOps(fieldType).map((o) => ({
value: o,
label: FILTER_OP_LABELS[o],
}))}
value={op}
onChange={(e) => updateFilter(filter.id, { op: e.target.value as FilterOp })}
>
{validFilterOps(fieldType).map((o) => (
<option key={o} value={o}>
{FILTER_OP_LABELS[o]}
</option>
))}
</select>
onSelect={(o) => updateFilter(filter.id, { op: o })}
/>
{arity === 'range' ? (
<>
<input
@@ -985,6 +1066,11 @@ function BuilderPreview() {
const specText = useChartBuilderStore(selectBuilderSpecText);
const valid = useChartBuilderStore(selectBuilderValid);
// An explicit Chart size must show up in the preview — the 'width' fit mode
// overwrites width AND drops height, so it only applies while sizing is auto.
const explicitSize = useChartBuilderStore(
(s) => s.config.width !== undefined || s.config.height !== undefined,
);
const uiTheme = useAppStore((s) => s.uiTheme);
const datasets = useDatasetStore(useShallow((s) => s.datasets));
@@ -1005,7 +1091,10 @@ function BuilderPreview() {
const t0 = performance.now();
const parsed: unknown = JSON.parse(specText);
const t1 = performance.now();
const prepared = prepareSpecForRender(parsed, { fitMode: 'width', datasets });
const prepared = prepareSpecForRender(parsed, {
fitMode: explicitSize ? 'default' : 'width',
datasets,
});
const t2 = performance.now();
handleRef.current?.destroy(); // finalizing a huge prior SVG is itself a cost
handleRef.current = null;
@@ -1069,7 +1158,7 @@ function BuilderPreview() {
}, RENDER_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [specText, valid, uiTheme, datasets]);
}, [specText, valid, explicitSize, uiTheme, datasets]);
useEffect(
() => () => {
@@ -1110,19 +1199,87 @@ function BuilderPreview() {
);
}
/** A positive integer from a dimension input; blank/garbage reads as "auto". */
function parseDim(raw: string): number | undefined {
if (raw.trim() === '') return undefined;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined;
}
/**
* Chart properties — title/subtitle and explicit width/height, as a slim strip
* pinned under the preview: these describe *the chart*, so they live on the chart
* side, not in the encoding pane (council 2026-06-12 — NN/g #4, the Tableau/Lyra
* convention). The subtitle is disabled until a title exists because Vega-Lite has
* no standalone subtitle (it nests under `title`).
*/
function ChartProps() {
const title = useChartBuilderStore((s) => s.config.title ?? '');
const subtitle = useChartBuilderStore((s) => s.config.subtitle ?? '');
const width = useChartBuilderStore((s) => s.config.width);
const height = useChartBuilderStore((s) => s.config.height);
const setTitle = useChartBuilderStore((s) => s.setTitle);
const setSubtitle = useChartBuilderStore((s) => s.setSubtitle);
const setWidth = useChartBuilderStore((s) => s.setWidth);
const setHeight = useChartBuilderStore((s) => s.setHeight);
const hasTitle = title.trim() !== '';
return (
<div className={styles.chartProps}>
<label className={styles.propField}>
<span>Title</span>
<input
className={styles.propInput}
value={title}
placeholder="None"
onChange={(e) => setTitle(e.target.value)}
/>
</label>
<label className={styles.propField}>
<span>Subtitle</span>
<input
className={styles.propInput}
value={subtitle}
placeholder={hasTitle ? 'None' : 'Add a title first'}
disabled={!hasTitle}
onChange={(e) => setSubtitle(e.target.value)}
/>
</label>
<label className={styles.propField}>
<span>Width</span>
<input
type="number"
min={1}
className={styles.dimInput}
value={width ?? ''}
placeholder="auto"
onChange={(e) => setWidth(parseDim(e.target.value))}
/>
</label>
<label className={styles.propField}>
<span>Height</span>
<input
type="number"
min={1}
className={styles.dimInput}
value={height ?? ''}
placeholder="auto"
onChange={(e) => setHeight(parseDim(e.target.value))}
/>
</label>
</div>
);
}
export function ChartBuilderModal() {
const datasetId = useChartBuilderStore((s) => s.datasetId);
const datasetName = useChartBuilderStore((s) => s.config.datasetName);
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 setSort = useChartBuilderStore((s) => s.setSort);
const setStack = useChartBuilderStore((s) => s.setStack);
const setWidth = useChartBuilderStore((s) => s.setWidth);
const setHeight = useChartBuilderStore((s) => s.setHeight);
const applyWarningFix = useChartBuilderStore((s) => s.applyWarningFix);
const runCreate = useChartBuilderStore((s) => s.createSnippet);
@@ -1170,12 +1327,6 @@ export function ChartBuilderModal() {
return <p className={styles.muted}>No dataset loaded. Open this from a dataset in Datasets.</p>;
}
const parseDim = (raw: string): number | undefined => {
if (raw.trim() === '') return undefined;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined;
};
return (
<div className={styles.builder}>
<div className={styles.configPane} ref={configPaneRef} tabIndex={-1}>
@@ -1228,36 +1379,6 @@ export function ChartBuilderModal() {
</div>
)}
<div className={styles.dimensions}>
{/* "Chart size", not just "Size" — the Marks card now has a Size *encoding*
channel; this is the rendered chart's width/height. */}
<span className={styles.fieldLabel}>Chart size (optional)</span>
<div className={styles.dimInputs}>
<label className={styles.dimField}>
<span>Width</span>
<input
type="number"
min={1}
className={styles.dimInput}
value={width ?? ''}
placeholder="auto"
onChange={(e) => setWidth(parseDim(e.target.value))}
/>
</label>
<label className={styles.dimField}>
<span>Height</span>
<input
type="number"
min={1}
className={styles.dimInput}
value={height ?? ''}
placeholder="auto"
onChange={(e) => setHeight(parseDim(e.target.value))}
/>
</label>
</div>
</div>
{warnings.length > 0 && (
<ul
className={styles.warnings}
@@ -1314,6 +1435,7 @@ export function ChartBuilderModal() {
<div className={styles.previewSide}>
<OnChartShelves />
<BuilderPreview />
<ChartProps />
</div>
</div>
);