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
+102 -51
View File
@@ -103,18 +103,6 @@
gap: var(--space-2);
}
.filterField {
flex: 1;
min-width: 0;
padding: var(--space-2) var(--space-3);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 13px;
}
.filterPredicate {
display: flex;
align-items: center;
@@ -173,7 +161,6 @@
font-family: var(--font-mono);
}
.filterField:focus-visible,
.calcName:focus-visible,
.valueInput:focus-visible,
.exprInput:focus-visible {
@@ -428,16 +415,6 @@
color: var(--text-placeholder);
}
.mini {
padding: var(--space-1) var(--space-2);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 12px;
}
.toggle {
display: inline-flex;
align-items: center;
@@ -451,31 +428,11 @@
gap: var(--space-5);
}
.mini:focus-visible,
.dimInput:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -1px;
}
.dimensions {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.dimInputs {
display: flex;
gap: var(--space-3);
}
.dimField {
display: flex;
flex-direction: column;
gap: var(--space-1);
font-size: 12px;
color: var(--text-secondary);
}
.dimInput {
width: 100px;
padding: var(--space-2) var(--space-3);
@@ -713,6 +670,44 @@
color: var(--text-secondary);
}
/* Scroll viewport around the field lists: a wide dataset (Superstore: 25 columns)
must not push the Marks card and chart controls below the fold. Group headings
pin to the top while their list scrolls under them. */
.shelfScroll {
display: flex;
flex-direction: column;
gap: var(--space-2);
max-height: 280px;
overflow-y: auto;
}
/* An armed channel makes the shelf the live target — say so where the next click
happens (NN/g #1): an accent ring on the list plus the status line above it. */
.shelfArmed {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--radius);
}
.armedHint {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
}
.armedHint strong {
color: var(--accent);
}
.shelfScroll .shelfGroupHead {
position: sticky;
top: 0;
z-index: 1;
margin-top: 0;
padding: var(--space-2) 0;
background: var(--layer-01);
}
.shelfList {
display: flex;
flex-direction: column;
@@ -785,11 +780,14 @@
gap: var(--space-2);
}
/* Pill — a bound channel (field/count or constant). */
/* Pill — a bound channel (field/count or constant). The per-channel transform
controls (Aggregate/Bin/Granularity) flow inline beside the pill, wrapping only
when the row truly runs out of room (a narrow window; the Marks card). */
.pillWrap {
display: flex;
flex-direction: column;
gap: var(--space-2);
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-3);
min-width: 0;
}
@@ -827,6 +825,10 @@
opacity: 0.85;
}
.pillType[aria-expanded='true'] {
background: color-mix(in srgb, #000 30%, var(--accent));
}
.pillType:focus-visible {
outline: 2px solid var(--accent-contrast);
outline-offset: -2px;
@@ -841,6 +843,8 @@
}
.pillName {
/* Grow into any pill slack so the remove ✕ stays at the pill's right edge. */
flex: 1;
min-width: 0;
padding: var(--space-2) var(--space-3);
overflow: hidden;
@@ -937,19 +941,22 @@
outline-offset: 1px;
}
/* Ghost button (Carbon: links navigate, buttons act — this one acts). */
.slotConst {
padding: var(--space-1);
padding: var(--space-1) var(--space-2);
border: none;
border-radius: var(--radius);
background: none;
color: var(--accent);
font: inherit;
font-size: 11px;
white-space: nowrap;
text-decoration: underline;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.slotConst:hover {
background: var(--layer-02);
color: var(--accent-hover);
}
@@ -966,10 +973,12 @@
padding: var(--space-5) var(--space-5) 0;
}
/* Swap sits beside the heading — an action flushed to the far edge of a very wide
pane reads as unrelated to the shelves it acts on. */
.shelvesHead {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-4);
}
.shelfStrip {
@@ -1000,11 +1009,53 @@
padding: var(--space-2) var(--space-3);
}
.shelfSlots .slot,
.shelfSlots .pillWrap {
/* Empty slots keep a minimum presence; a filled pill hugs its content so the
remove ✕ sits at its right edge rather than adrift in dead pill background. */
.shelfSlots .slot {
min-width: 190px;
}
/* Chart properties — title/subtitle/size as a slim strip pinned under the preview
(chart-level properties live on the chart side; council 2026-06-12). */
.chartProps {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-4);
padding: var(--space-3) var(--space-5);
border-top: var(--border-width) solid var(--border);
}
.propField {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: 12px;
color: var(--text-secondary);
white-space: nowrap;
}
.propInput {
width: 200px;
padding: var(--space-1) var(--space-2);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 12px;
}
.propInput:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.propInput:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -1px;
}
/* Reserved faceting placeholder (Phase 4) — non-interactive, signals where it'll live. */
.facetSlot {
display: flex;
+93 -5
View File
@@ -194,7 +194,7 @@ describe('ChartBuilderModal', () => {
await vi.advanceTimersByTimeAsync(400); // drive the debounced preview render
});
expect(container.querySelector('select[aria-label="Filter operator"]')).toBeTruthy();
expect(container.querySelector('button[aria-label^="Filter operator"]')).toBeTruthy();
const calls = vi.mocked(renderSpec).mock.calls;
const lastSpec = calls[calls.length - 1][1] as { transform?: unknown };
expect(lastSpec.transform).toEqual([{ filter: { field: 'revenue', gt: 60 } }]);
@@ -297,7 +297,7 @@ describe('ChartBuilderModal', () => {
expect(refLink()!.getAttribute('href')).toContain('vega.github.io');
});
test('clicking a field in the shelf assigns it to a channel as a pill (field-first, 2B)', async () => {
test('clicking a field opens the channel chooser; picking a channel assigns it (field-first, 2B)', async () => {
const ds = createDataset({
name: 'Shop',
data: [{ region: 'E', sales: 5 }],
@@ -309,7 +309,6 @@ describe('ChartBuilderModal', () => {
const id = useDatasetStore.getState().datasets[0].id;
const store = useChartBuilderStore.getState();
store.init(id);
// Clear the smart-default axes so the click lands on the first empty channel (X).
store.setChannelColumn('x', null);
store.setChannelColumn('y', null);
@@ -328,12 +327,100 @@ describe('ChartBuilderModal', () => {
await Promise.resolve();
});
// Unarmed, the click opens an explicit channel chooser (portaled to <body>)
// rather than silently filling the first empty seat (council 2026-06-12).
expect(useChartBuilderStore.getState().config.encodings.x).toBeNull();
const option = Array.from(document.body.querySelectorAll('button')).find((b) =>
b.textContent?.includes('Columns (X)'),
);
expect(option).toBeDefined();
await act(async () => {
option!.click();
await Promise.resolve();
});
expect(useChartBuilderStore.getState().config.encodings.x).toEqual({
field: 'region',
type: 'nominal',
});
});
test('an armed channel short-circuits the chooser: the field assigns directly (2B)', async () => {
const ds = createDataset({
name: 'Shop',
data: [{ region: 'E', sales: 5 }],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
const store = useChartBuilderStore.getState();
store.init(id);
store.setChannelColumn('x', null);
store.setChannelColumn('y', null);
store.focusChannel('y');
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
// The armed state announces itself at the shelf.
expect(container.textContent).toContain('Assigning to Y');
const fieldButton = Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes('region'),
);
await act(async () => {
fieldButton!.click();
await Promise.resolve();
});
expect(useChartBuilderStore.getState().config.encodings.y).toEqual({
field: 'region',
type: 'nominal',
});
expect(useChartBuilderStore.getState().activeChannel).toBeNull();
});
test('opening a SelectControl lands focus on the selected option, not the first (regression)', async () => {
const ds = createDataset({
name: 'Sales',
data: [{ day: '2026-01-01', v: 1 }],
format: 'json',
source: 'inline',
now: T,
});
useDatasetStore.getState().add(ds);
const id = useDatasetStore.getState().datasets[0].id;
const store = useChartBuilderStore.getState();
store.init(id);
store.setChannelColumn('x', 'day'); // temporal → the pill offers Granularity
store.setChannelTimeUnit('x', 'month'); // "Month" sits mid-list, after "None (raw)"
await act(async () => {
root.render(<ChartBuilderModal />);
await Promise.resolve();
});
const trigger = container.querySelector<HTMLButtonElement>(
'button[aria-label^="Granularity for"]',
);
expect(trigger).toBeTruthy();
await act(async () => {
trigger!.click();
await Promise.resolve();
});
// A selector list ('[aria-current="true"], button') would return the first
// button in document order — the "None (raw)" option — instead of the selection.
const focused = document.activeElement as HTMLElement;
expect(focused.getAttribute('aria-current')).toBe('true');
expect(focused.textContent).toContain('Month');
});
test('Colour can be switched to a constant value (the Property model, 2A/2B)', async () => {
const ds = createDataset({
name: 'Shop',
@@ -351,9 +438,10 @@ describe('ChartBuilderModal', () => {
await Promise.resolve();
});
// The empty Colour slot offers an "or constant" affordance (Colour is first in Marks).
// The empty Colour slot offers a "Use a constant" ghost button (Colour is
// first in Marks).
const constButton = Array.from(container.querySelectorAll('button')).find(
(b) => b.textContent === 'or constant',
(b) => b.textContent === 'Use a constant',
);
expect(constButton).toBeDefined();
+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>
);
+123
View File
@@ -0,0 +1,123 @@
/* SelectControl — the app's value-picker disclosure (replaces native <select>;
arch 10 §5). Trigger + panel mirror SortControl's geometry and tokens. */
.trigger {
display: inline-flex;
align-items: center;
gap: var(--space-1);
padding: var(--space-1) var(--space-2);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font: inherit;
font-size: 12px;
white-space: nowrap;
cursor: pointer;
transition:
background var(--dur-fast) var(--ease),
color var(--dur-fast) var(--ease);
}
.trigger:hover:not(:disabled) {
background: var(--layer-01);
}
.trigger[aria-expanded='true'] {
background: var(--layer-02);
}
.trigger:disabled {
cursor: default;
opacity: 0.6;
}
.trigger:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 1px;
}
.triggerLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.caret {
font-size: 9px;
color: var(--text-secondary);
}
/* The disclosed panel — portaled to <body>, fixed, above modal content. */
.pop {
position: fixed;
z-index: 2000;
min-width: 150px;
max-width: 320px;
padding: var(--space-2);
background: var(--layer-01);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
box-shadow: 0 8px 24px rgb(0 0 0 / 0.4);
}
.heading {
margin: 0 0 var(--space-2);
padding: 0 var(--space-2);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-secondary);
}
/* A long option list (a 25-column filter-field picker) scrolls in place. */
.list {
display: flex;
flex-direction: column;
max-height: min(320px, 50vh);
overflow-y: auto;
}
.option {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
width: 100%;
padding: var(--space-2) var(--space-3);
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--text);
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background var(--dur-fast) var(--ease);
}
.option:hover {
background: var(--layer-02);
}
.option:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -1px;
}
.selected {
font-weight: 600;
}
.optionLabel {
display: flex;
flex-direction: column;
min-width: 0;
}
.detail {
font-size: 11px;
font-weight: 400;
color: var(--text-secondary);
}
+243
View File
@@ -0,0 +1,243 @@
/**
* SelectControl — the app's value picker: a disclosure trigger showing the current
* choice plus a portaled, non-modal popover listing the options as plain buttons.
*
* This replaces native `<select>` elements wherever a control is part of a designed
* surface: a native select's popup can't be token-styled and renders differently on
* every browser/OS, which reads as a foreign object inside an otherwise consistent
* UI (arch 10 §5 records the resolution). It is the same APG **disclosure**
* primitive as SortControl/SettingsPopover — deliberately NOT an ARIA menu and not
* a combobox; a short list of buttons needs neither's contract.
*
* Behaviour (mirrors SortControl): at most one popover is open app-wide
* (`useSettingsPopoverStore`); Esc closes and refocuses the trigger; an outside
* pointer press closes; opening focuses the selected option (or the first);
* Arrow/Home/End move focus through the options; choosing one fires `onSelect`,
* closes, and refocuses the trigger. The panel is portaled to <body> and positioned
* fixed so it escapes pane/modal overflow clipping, flipping above the trigger when
* the viewport below is too short.
*
* The same control doubles as an **action picker** (e.g. "Add field to which
* channel?"): pass no `value` and a custom `triggerContent`; `beforeOpen` lets the
* caller intercept the click entirely (the armed-channel fast path).
*/
import { useCallback, useEffect, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useSettingsPopoverStore } from '../stores/SettingsPopoverStore';
import styles from './SelectControl.module.css';
/** Gap (px) between the trigger and the disclosed panel (matches SortControl). */
const GAP = 6;
export interface SelectControlOption<V extends string> {
value: V;
label: string;
/** Optional secondary line (e.g. "replaces Ship Mode" on an occupied channel). */
detail?: string;
}
export interface SelectControlProps<V extends string> {
/** Unique id — popover registry key and the panel's DOM id. */
id: string;
/** Accessible name for the control ("Aggregate for Y", "Add Region to…"). */
label: string;
options: ReadonlyArray<SelectControlOption<V>>;
/** Current value; omit for an action picker (no option reads as selected). */
value?: V;
onSelect: (value: V) => void;
/** Trigger body; defaults to the current option's label plus a caret. */
triggerContent?: ReactNode;
/** Replaces (not extends) the default trigger styling — for chip-styled triggers. */
triggerClassName?: string;
triggerTitle?: string;
disabled?: boolean;
/** Popover heading; defaults to `label`. */
heading?: string;
/** Return false to swallow the trigger click without opening (fast paths). */
beforeOpen?: () => boolean;
}
export function SelectControl<V extends string>({
id,
label,
options,
value,
onSelect,
triggerContent,
triggerClassName,
triggerTitle,
disabled,
heading,
beforeOpen,
}: SelectControlProps<V>) {
const open = useSettingsPopoverStore((s) => s.openId === id);
const toggle = useSettingsPopoverStore((s) => s.toggle);
const close = useSettingsPopoverStore((s) => s.close);
const triggerRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement | null>(null);
const current = value !== undefined ? options.find((o) => o.value === value) : undefined;
// Fixed-position from the trigger's rect (no React state → no re-render on
// scroll). Below the trigger by default; above when the viewport below is short.
const place = useCallback(() => {
const trigger = triggerRef.current;
const pop = popRef.current;
if (!trigger || !pop) return;
const r = trigger.getBoundingClientRect();
const below = window.innerHeight - r.bottom - GAP;
const height = pop.offsetHeight;
pop.style.top =
below < height && r.top > height + GAP ? `${r.top - GAP - height}px` : `${r.bottom + GAP}px`;
// Keep the panel on-screen when the trigger sits near the right edge.
const left = Math.min(r.left, window.innerWidth - pop.offsetWidth - GAP);
pop.style.left = `${Math.max(GAP, left)}px`;
}, []);
useEffect(() => {
if (!open) return;
window.addEventListener('resize', place);
window.addEventListener('scroll', place, true);
return () => {
window.removeEventListener('resize', place);
window.removeEventListener('scroll', place, true);
};
}, [open, place]);
// Esc closes + restores focus to the trigger; an outside pointer press closes
// (APG disclosure; non-modal). Esc is captured so it settles here, not on a
// parent (the builder modal also listens for Esc).
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
close();
triggerRef.current?.focus();
}
};
const onPointer = (e: PointerEvent) => {
const t = e.target as Node;
if (!popRef.current?.contains(t) && !triggerRef.current?.contains(t)) close();
};
document.addEventListener('keydown', onKey, true);
document.addEventListener('pointerdown', onPointer, true);
return () => {
document.removeEventListener('keydown', onKey, true);
document.removeEventListener('pointerdown', onPointer, true);
};
}, [open, close]);
// Arrow/Home/End roving among the option buttons — a convenience on top of the
// natural Tab order, matching what a native select's popup offers.
const onPopKeyDown = (e: React.KeyboardEvent) => {
const pop = popRef.current;
if (!pop) return;
// Tab closes the popup and resumes tabbing from the trigger (the native-select
// convention) — also keeps focus inside a host modal's trap, since the panel is
// portaled outside it.
if (e.key === 'Tab') {
close();
triggerRef.current?.focus();
return;
}
const items = Array.from(pop.querySelectorAll<HTMLButtonElement>('button'));
const i = items.indexOf(document.activeElement as HTMLButtonElement);
let next = -1;
if (e.key === 'ArrowDown') next = i < 0 ? 0 : Math.min(i + 1, items.length - 1);
else if (e.key === 'ArrowUp') next = i < 0 ? items.length - 1 : Math.max(i - 1, 0);
else if (e.key === 'Home') next = 0;
else if (e.key === 'End') next = items.length - 1;
if (next >= 0) {
e.preventDefault();
items[next]?.focus();
}
};
// On mount: position before paint, then land focus on the selected option (or
// the first) so keyboard users arrive inside the popover.
const setPopNode = useCallback(
(node: HTMLDivElement | null) => {
popRef.current = node;
if (node) {
place();
// Two queries, not one selector list — `querySelector('a, b')` returns the
// first match in document order, which would always be the first button.
const target =
node.querySelector<HTMLElement>('[aria-current="true"]') ??
node.querySelector<HTMLElement>('button');
target?.focus();
}
},
[place],
);
const choose = (v: V) => {
onSelect(v);
close();
triggerRef.current?.focus();
};
return (
<>
<button
ref={triggerRef}
type="button"
className={triggerClassName ?? styles.trigger}
aria-expanded={open}
aria-controls={open ? id : undefined}
aria-label={current ? `${label}: ${current.label}` : label}
title={triggerTitle}
disabled={disabled}
onClick={() => {
if (beforeOpen && !beforeOpen()) return;
toggle(id);
}}
>
{triggerContent ?? (
<>
<span className={styles.triggerLabel}>{current?.label ?? '—'}</span>
<span className={styles.caret} aria-hidden="true">
</span>
</>
)}
</button>
{open &&
createPortal(
<div
ref={setPopNode}
id={id}
className={styles.pop}
role="group"
aria-label={label}
onKeyDown={onPopKeyDown}
>
<h4 className={styles.heading}>{heading ?? label}</h4>
<div className={styles.list}>
{options.map((o) => {
const selected = value !== undefined && o.value === value;
return (
<button
key={o.value}
type="button"
className={`${styles.option} ${selected ? styles.selected : ''}`}
aria-current={selected || undefined}
onClick={() => choose(o.value)}
>
<span className={styles.optionLabel}>
{o.label}
{o.detail !== undefined && <span className={styles.detail}>{o.detail}</span>}
</span>
{selected && <span aria-hidden="true"></span>}
</button>
);
})}
</div>
</div>,
document.body,
)}
</>
);
}
+32
View File
@@ -104,6 +104,38 @@ describe('transforms — aggregate / bin / timeUnit / count', () => {
expect(cb().config.encodings.x).toEqual({ field: 'price', type: 'nominal' });
});
test('assignField with an explicit channel places the field there, replacing any occupant', () => {
const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
cb().init(id);
cb().setChannelColumn('x', 'revenue');
cb().assignField('region', 'x'); // explicit target from the chip's channel chooser
expect(cb().config.encodings.x).toEqual({ field: 'region', type: 'nominal' });
expect(cb().activeChannel).toBeNull();
});
test('setTitle / setSubtitle set and clear the chart title fields', () => {
const id = seedDataset('S', [{ v: 1 }]);
cb().init(id);
cb().setTitle('My chart');
cb().setSubtitle('A closer look');
expect(cb().config.title).toBe('My chart');
expect(cb().config.subtitle).toBe('A closer look');
cb().setTitle('');
expect(cb().config.title).toBeUndefined();
});
test('retyping drops a now-invalid aggregate but keeps a distinct (valid for any type)', () => {
const id = seedDataset('S', [{ price: 5 }]);
cb().init(id);
cb().setChannelColumn('x', 'price');
cb().setChannelAggregate('x', 'sum');
cb().setChannelType('x', 'nominal'); // sum can't reduce a nominal field
expect(cb().config.encodings.x?.aggregate).toBeUndefined();
cb().setChannelAggregate('x', 'distinct');
cb().setChannelType('x', 'ordinal'); // distinct counts unique values of anything
expect(cb().config.encodings.x?.aggregate).toBe('distinct');
});
test('setChannelTimeUnit sets and clears granularity', () => {
const id = seedDataset('S', [{ day: '2026-01-01', v: 1 }]);
cb().init(id);
+27 -8
View File
@@ -27,9 +27,9 @@ import {
isChannelTypeAllowed,
isColumnAllowedOnChannel,
pruneEncodings,
supportsAggregate,
supportsBin,
supportsTimeUnit,
validAggregateOps,
validFieldTypes,
validFilterOps,
type AggregateOp,
@@ -106,9 +106,11 @@ export interface ChartBuilderState {
/**
* Place a clicked shelf field on a channel: the armed channel if it accepts the
* column, else the first empty channel that does (X→Y→Color→Size). A no-op when no
* channel can take it. Clears the armed slot.
* channel can take it. Clears the armed slot. An explicit `channel` (the field
* chip's channel chooser) overrides both — it places the column there even when
* the channel is occupied (the chooser labels that as a replacement).
*/
assignField: (columnName: string) => void;
assignField: (columnName: string, channel?: ChannelName) => void;
/**
* Set/replace a channel's binding with a **constant value** (the Property model,
* spec §06) — a fixed colour/size emitted as Vega-Lite `{ value }`. Only Color/Size
@@ -151,6 +153,10 @@ export interface ChartBuilderState {
removeCalculate: (id: string) => void;
setWidth: (width: number | undefined) => void;
setHeight: (height: number | undefined) => void;
/** Set/clear the chart title (whitespace-only clears). */
setTitle: (raw: string) => void;
/** Set/clear the subtitle; emitted into the spec only alongside a title. */
setSubtitle: (raw: string) => void;
/** Build the spec, create + activate a linked snippet, toast, and close. */
createSnippet: (now?: Date) => boolean;
reset: () => void;
@@ -259,9 +265,9 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
focusChannel: (channel) => set({ activeChannel: channel }),
assignField: (columnName) =>
assignField: (columnName, channel) =>
set((s) => {
const target = chooseTargetChannel(s, columnName);
const target = channel ?? chooseTargetChannel(s, columnName);
if (!target) return { activeChannel: null }; // nothing can take it → just disarm
const mapping = mappingForColumn(s, target, columnName);
return {
@@ -288,10 +294,18 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
set((s) => {
const current = s.config.encodings[channel];
if (!current) return s; // no field on this channel → nothing to retype
// Drop transforms that no longer apply to the new type (e.g. an aggregate or
// bin when leaving Quantitative, a granularity when leaving Temporal).
// Drop transforms that no longer apply to the new type (e.g. a sum or bin when
// leaving Quantitative, a granularity when leaving Temporal). A `distinct`
// survives any retype (it applies to every field type); `count` is field-less
// and never reaches here (a count pill offers no type cycle).
const next: ChannelMapping = { ...current, type };
if (!supportsAggregate(type)) delete next.aggregate;
if (
next.aggregate &&
next.aggregate !== 'count' &&
!validAggregateOps(type).includes(next.aggregate)
) {
delete next.aggregate;
}
if (!supportsBin(type)) delete next.bin;
if (!supportsTimeUnit(type)) delete next.timeUnit;
return updateEncoding(s, channel, next);
@@ -451,6 +465,11 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
setWidth: (width) => set((s) => ({ config: { ...s.config, width } })),
setHeight: (height) => set((s) => ({ config: { ...s.config, height } })),
// The raw input value is kept (mid-edit spaces and all) so typing feels normal;
// the assembler trims and skips a blank/whitespace-only title at emission.
setTitle: (raw) => set((s) => ({ config: { ...s.config, title: raw || undefined } })),
setSubtitle: (raw) => set((s) => ({ config: { ...s.config, subtitle: raw || undefined } })),
createSnippet: (now) => {
const { config } = get();
if (!isBuilderConfigValid(config)) return false; // guarded by a disabled action too
+86 -1
View File
@@ -6,6 +6,7 @@ import {
isChannelTypeAllowed,
isColumnAllowedOnChannel,
supportsAggregate,
validAggregateOps,
supportsBin,
supportsTimeUnit,
supportsSort,
@@ -840,12 +841,96 @@ describe('transforms — aggregate / bin / timeUnit', () => {
it('exposes the transform-applicability predicates by field type', () => {
expect(supportsAggregate('quantitative')).toBe(true);
expect(supportsAggregate('nominal')).toBe(false);
// Every type now takes at least one aggregate (`distinct` applies to anything).
expect(supportsAggregate('nominal')).toBe(true);
expect(supportsBin('quantitative')).toBe(true);
expect(supportsBin('temporal')).toBe(false);
expect(supportsTimeUnit('temporal')).toBe(true);
expect(supportsTimeUnit('quantitative')).toBe(false);
});
it('offers per-type aggregate menus: arithmetic needs numbers, min/max an ordering, distinct anything', () => {
expect(validAggregateOps('quantitative')).toEqual([
'sum',
'mean',
'median',
'min',
'max',
'distinct',
]);
expect(validAggregateOps('temporal')).toEqual(['min', 'max', 'distinct']);
expect(validAggregateOps('ordinal')).toEqual(['min', 'max', 'distinct']);
expect(validAggregateOps('nominal')).toEqual(['distinct']);
});
it('emits a distinct-count of a categorical field as a quantitative measure', () => {
const spec = buildChartSpec({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'customer', type: 'nominal', aggregate: 'distinct' },
},
});
const enc = spec.encoding as Record<string, Record<string, unknown>>;
// The carried (nominal) type is for round-tripping; the emitted type is the
// effective one — a count of unique values reads as a quantitative measure.
expect(enc.y).toEqual({ field: 'customer', type: 'quantitative', aggregate: 'distinct' });
});
it('keeps the field type on order-preserving aggregates (a temporal min is still temporal)', () => {
const spec = buildChartSpec({
datasetName: 'D',
mark: 'point',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'orderDate', type: 'temporal', aggregate: 'min' },
},
});
const enc = spec.encoding as Record<string, Record<string, unknown>>;
expect(enc.y).toEqual({ field: 'orderDate', type: 'temporal', aggregate: 'min' });
});
it('emits title as a bare string, the object form with a subtitle, nothing without a title', () => {
const base: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: { x: { field: 'region', type: 'nominal' } },
};
expect(buildChartSpec({ ...base, title: 'Sales by region' }).title).toBe('Sales by region');
expect(buildChartSpec({ ...base, title: 'Sales', subtitle: 'FY26' }).title).toEqual({
text: 'Sales',
subtitle: 'FY26',
});
// A subtitle alone is not emitted (VL has no standalone subtitle), nor is a
// whitespace-only title.
expect(buildChartSpec({ ...base, subtitle: 'orphan' }).title).toBeUndefined();
expect(buildChartSpec({ ...base, title: ' ' }).title).toBeUndefined();
});
it('prefers a user-written title as the generated snippet name', () => {
expect(
generateChartName({
datasetName: 'D',
mark: 'bar',
title: 'Quarterly revenue',
encodings: { x: { field: 'region', type: 'nominal' } },
}),
).toBe('Quarterly revenue');
});
it('names a distinct-count chart with a "unique" phrase', () => {
expect(
generateChartName({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'customer', type: 'nominal', aggregate: 'distinct' },
},
}),
).toBe('Bar chart of unique customer by region');
});
});
describe('sort (ranking)', () => {
+54 -8
View File
@@ -44,9 +44,12 @@ export type ChannelName = (typeof CHANNELS)[number];
/**
* Aggregation operators a channel may apply (Vega-Lite `aggregate`). `count` is
* special — it is **field-less** (counts records), so a `count` mapping carries no
* `field`. The rest reduce a quantitative `field`.
* `field`. `distinct` counts a field's unique values, so it applies to **any** field
* type and reads as a quantitative measure. The arithmetic ops (sum/mean/median)
* reduce a quantitative field; min/max also order a temporal or ordinal one. See
* `validAggregateOps` for the per-type menu.
*/
export const AGGREGATE_OPS = ['count', 'sum', 'mean', 'median', 'min', 'max'] as const;
export const AGGREGATE_OPS = ['count', 'distinct', 'sum', 'mean', 'median', 'min', 'max'] as const;
export type AggregateOp = (typeof AGGREGATE_OPS)[number];
/**
@@ -193,6 +196,10 @@ export interface BuilderConfig {
mark: MarkType;
/** Per-channel mapping; `null` (or absent) means the channel is unmapped. */
encodings: Partial<Record<ChannelName, ChannelMapping | null>>;
/** Optional chart title (Vega-Lite top-level `title`). */
title?: string;
/** Optional subtitle; emitted only alongside a title (VL nests it under `title`). */
subtitle?: string;
/** Optional explicit chart width in pixels. */
width?: number;
/** Optional explicit chart height in pixels. */
@@ -276,9 +283,29 @@ export function isColumnAllowedOnChannel(channel: ChannelName, columnType: Colum
return isChannelTypeAllowed(channel, defaultFieldType(columnType));
}
/** Whether a non-count aggregate (sum/mean/…) can apply to this field type. */
/**
* The non-count aggregates that legitimately apply to a field of this type — the
* channel's Aggregate menu. Arithmetic reduction (sum/mean/median) needs numbers;
* min/max need an ordering (numbers, dates, asserted-ordinal values); `distinct`
* (count of unique values) applies to anything — the natural measure to wring out
* of a category ("how many unique customers"), which is why the menu is per-type
* rather than quantitative-only.
*/
export function validAggregateOps(type: FieldType): Exclude<AggregateOp, 'count'>[] {
switch (type) {
case 'quantitative':
return ['sum', 'mean', 'median', 'min', 'max', 'distinct'];
case 'temporal':
case 'ordinal':
return ['min', 'max', 'distinct'];
case 'nominal':
return ['distinct'];
}
}
/** Whether any non-count aggregate (sum/…/distinct) can apply to this field type. */
export function supportsAggregate(type: FieldType): boolean {
return type === 'quantitative';
return validAggregateOps(type).length > 0;
}
/** Whether binning into ranges can apply to this field type. */
@@ -498,9 +525,13 @@ function mappedChannels(config: BuilderConfig): Array<[ChannelName, ChannelMappi
});
}
/** The effective field type a mapping encodes (a count is quantitative). */
/** The effective field type a mapping encodes: a count or a distinct-count reads as
* a quantitative measure whatever the underlying field; other aggregates (sum, a
* temporal min/…) keep the field's own type. */
function effectiveType(mapping: ChannelMapping): FieldType {
return mapping.aggregate === 'count' ? 'quantitative' : mapping.type;
return mapping.aggregate === 'count' || mapping.aggregate === 'distinct'
? 'quantitative'
: mapping.type;
}
/** True when a mapping reads as a continuous measure (count/aggregate or continuous type). */
@@ -1034,7 +1065,9 @@ function encodingObject(mapping: ChannelMapping): Record<string, unknown> {
// Escape `.`/`[`/`]` so a column literally named e.g. `user.age` is read as that
// field, not a nested-property accessor (docs/architecture/05 §4).
if (mapping.field !== undefined) enc.field = escapeVegaField(mapping.field);
enc.type = mapping.type;
// The emitted type is the *effective* one: a distinct-count of any field is a
// quantitative measure (the carried field type is preserved for a later un-aggregate).
enc.type = effectiveType(mapping);
if (mapping.aggregate) enc.aggregate = mapping.aggregate;
if (mapping.bin) enc.bin = true;
if (mapping.timeUnit) enc.timeUnit = mapping.timeUnit;
@@ -1047,7 +1080,7 @@ function encodingObject(mapping: ChannelMapping): Record<string, unknown> {
* any top-level `transform` (calculated fields then row filters), the mark with
* tooltips enabled, every mapped encoding (field, type, and any aggregate/bin/
* timeUnit transform), chart-level sort (rank a categorical axis by its measure)
* and stack (part-to-whole), and any explicit width/height. Unmapped
* and stack (part-to-whole), any title/subtitle, and any explicit width/height. Unmapped
* channels are omitted; if nothing is mapped the `encoding` block is omitted
* entirely (validation prevents saving that, but the live preview may render a bare
* mark while the user is still configuring).
@@ -1063,6 +1096,15 @@ export function buildChartSpec(config: BuilderConfig): ChartSpec {
const transform = buildTransforms(config);
if (transform.length > 0) spec.transform = transform;
// Title/subtitle: a bare string for a lone title, the object form when a
// subtitle rides along. A subtitle without a title is not emitted (VL has no
// standalone subtitle; the UI disables the input until a title exists).
const title = config.title?.trim();
if (title) {
const subtitle = config.subtitle?.trim();
spec.title = subtitle ? { text: title, subtitle } : title;
}
spec.mark = { type: config.mark, tooltip: true };
const encoding: Record<string, Record<string, unknown>> = {};
@@ -1109,6 +1151,7 @@ function describeMapping(mapping: ChannelMapping): string {
if (mapping.value !== undefined) return 'a constant';
if (mapping.aggregate === 'count') return 'count';
const field = mapping.field ?? '';
if (mapping.aggregate === 'distinct') return `unique ${field}`;
if (mapping.aggregate) return `${mapping.aggregate} of ${field}`;
return field;
}
@@ -1121,6 +1164,9 @@ function describeMapping(mapping: ChannelMapping): string {
* no timestamp — so the name describes the chart, not when it was made.
*/
export function generateChartName(config: BuilderConfig): string {
// A user-written chart title is the best possible name — prefer it verbatim.
const title = config.title?.trim();
if (title) return title;
const mark = markLabel(config.mark);
const x = config.encodings.x;
const y = config.encodings.y;