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
+5
View File
@@ -284,6 +284,11 @@ the reference.
**Goal:** no-JSON chart composition from a dataset → a new snippet.
> **Enhancement backlog** beyond the Tier-B floor (aggregation, binning, stacking,
> temporal granularity, sort/orientation, cardinality-based warnings, Tier C
> intent-first) lives in [`docs/chart-builder-research.md`](chart-builder-research.md) §8
> — its single home, so these stop living in chat.
**Core**
- `chart-builder.ts` — pure spec assembler: (mark ∈ Bar/Line/Point/Area/Circle) +
+20
View File
@@ -87,6 +87,26 @@ const { activeModal, uiTheme } = useAppStore(
);
```
**`useShallow` only helps when the elements are stable.** It shallow-compares the
result — array elements (or object values) by `Object.is`. A selector that
**computes** a fresh collection of fresh objects each call (e.g.
`useShallow((s) => buildWarnings(s.config))`) defeats it: every element is a new
reference, so the result never compares equal, `useSyncExternalStore` re-renders
forever, and React throws _"Maximum update depth exceeded"_ (a white screen). A
selector must return a **primitive** or a **stored reference** — never a freshly
built array/object. Derive computed collections in the component with `useMemo`
over a stable slice instead:
```tsx
const config = useChartBuilderStore((s) => s.config); // stored ref, stable between updates
const warnings = useMemo(() => builderWarnings(config), [config]); // recompute only on change
```
This is a render-time loop, so core/store unit tests stay green and miss it. A bare
`react-dom/client` + `react`'s `act` mount test catches it with **no test-library
dependency** — mount the component in the looping config and assert it doesn't throw
(prove the guard by reverting the fix first). See `ChartBuilderModal.test.tsx`.
### Reading/writing outside components
Services, orchestration, infrastructure, and tests use the store object directly —
@@ -352,6 +352,18 @@ blank mid-edit.
- **Don't** render synchronously on every keystroke.
- **Don't** await a render inside an input/keydown handler.
### A second preview surface: the Chart Builder
The editor's `LivePreview` is **bound to the snippet editor** — it reads `SnippetStore`
(shown spec), `AppStore` (fit mode/theme), and `PreviewStore` (shared error). The
**Chart Builder modal** needs a preview of a _different_ spec source (its config), so it
does **not** reuse `LivePreview`; it runs its own small debounced render over the same
`chart-renderer.renderSpec` + `prepareSpecForRender`, with **local** error state (never the
shared `PreviewStore`, which would cross-talk with the editor). Two preview surfaces, one
renderer service. Builder flow: `chart-builder.ts` (pure spec assembler) → `ChartBuilderStore`
(config + create) → `ChartBuilderModal`'s `BuilderPreview`. Reach for a reusable preview
component only if a _third_ surface appears.
---
## 6. Rendering Contract Lives Upstream (reference)
+68
View File
@@ -198,6 +198,74 @@ The highest-value guardrails — encodings a naive UI emits that the canon rejec
- An **all-categorical chart with no measure** (Draco soft w=30, the loudest) — warned.
- A **number typed Nominal** (Draco soft w=10) — discouraged via default = Quantitative.
## 8. Future enhancements (backlog)
The Tier-B build is the floor, not the ceiling. The enhancements below were surfaced by
the research; this is their single home (the milestone plan's M4 row points here). Status
as of 2026-06-06.
**A · Cheap wins inside the current 5-mark / 4-channel scope**
- **A1 · Sort-on-ranking** _(done)_ — chart-level Sort control (Asc/Desc/None) sorts the
categorical axis by the measure (FT: "bars display ranks much more easily when sorted").
Appears only for a category-vs-measure pair.
- **A2 · Bar orientation** _(partly done)_ — the **Swap X/Y** control is the manual path to
a horizontal bar, and the crowded-axis hint (A3, below) now auto-suggests it for the
un-aggregated case. A general "long labels → go horizontal" suggestion on _any_ vertical
bar is still deferred (needs a label-length / cardinality signal); a blanket warning was
rejected — it would fire on every ordinary vertical bar.
- **A3 · Crowded-axis & high-cardinality warnings** _(partly done)_
- _Done:_ the **un-aggregated crowded axis** — a bar/line/area with a category axis and a
**raw** measure draws one mark (and one label) per row, so over `CROWDED_CATEGORY_ROWS`
(30) rows it warns and points to aggregating, or a horizontal bar. Row-count-based:
`builderWarnings(config, rowCount)`, with `rowCount` from the loaded dataset; detects
exactly the mark-count == row-count case (URL/non-tabular → `rowCount` null → skipped).
- _Remaining (needs the profiling extension below):_ an **aggregated** axis that still has
many distinct **categories**, an unreadable **Color legend** (>10/>20 categories), and
**number-typed-Nominal**. All need per-column **cardinality**, which the profile lacks.
- **A4 · Data-aware Size guard** _(deferred — needs the profiling extension)_ — exclude
**negative**-valued columns from Size (Draco `hard.lp:56`; size implies positive
magnitude). Today only the type-level Size discipline is enforced.
> **TODO — profiling extension (the A3-remaining + A4 enabler).** `profile.ts` computes only
> `rowCount` / `columnCount` / `columnTypes`. Extend it, in the **same sample pass** that
> already feeds `inferColumnType` (so it's nearly free), to also derive per column: a
> **capped distinct count** (cardinality — cap at ~50; a sampled count is enough for a
> ">N categories" threshold, no full scan) and a **numeric extent** (min/max → sign).
> Surface them on `DatasetProfile` (alongside `columnTypes`). Then: `builderWarnings` consumes
> cardinality (legend/axis crowding) and the Size gate consumes sign (A4). **Caveats:**
> URL / non-tabular datasets have no rows at profile time → these fields are null and the
> dependent warnings simply skip; and stored datasets predate the field, so this needs a
> recompute-on-read or a `dataset-migrations` bump (see architecture 02 / 06). Keep
> thresholds in `chart-builder.ts` constants like `CROWDED_CATEGORY_ROWS`.
**B · Transform-enabled coverage (new core capability + §06 extension)** _(done)_
- **B5 · Aggregation** _(done)_ — per-channel `sum` / `mean` / `median` / `min` / `max`, plus
a field-less "Count of records" measure (Voyager's `count(*)`). The priority item.
- **B6 · Binning** _(done)_`bin` on a quantitative field → true histograms (closes the
Distribution gap); mutually exclusive with aggregate on the same field.
- **B7 · Stacking** _(done)_`stack` (`zero` / `normalize`) for bar/area + a Color series →
part-to-whole (closes that gap; enables 100%-stacked).
- **Temporal granularity** _(done)_ — Vega-Lite `timeUnit` (Year / Quarter / Month / Week /
Day / Hour, plus combined units) on a Temporal field; defaults to None (raw).
- **B8 · Faceting (Row / Column → small multiples)** _(next increment, after A+B + UI land)_
— two more channels that multiply the chart into a trellis, the clean way to compare many
categories (Voyager has it; FT/Datawrapper recommend small multiples; we currently can't
express them). Still mark-first, so a Tier-B extension. **Axis alignment** is the design
crux: Vega-Lite facets default to **shared scales** (aligned axes) — keep that as the
default; expose an "independent axes" toggle (`resolve.scale`) only as an advanced option.
**Verify** faceting against the preview's `"container"` fit modes before trusting it
(per-cell sizing on facets is finicky). Sequenced as additive after the current build.
**C · Intent-first front door (Tier C)** _(deferred)_ — see §5. "What do you want to
show?" → recommend mark + channels from the FT/Datawrapper taxonomy × column types; where
Munzner + Wilke would be seated.
**D · Plumbing****D9** URL hash routing for the open builder (owned by **M6**, spec
§01E); **D10** a GOV.UK/NN-g copy pass over the guidance-hint wording (the M4 council
seating's residual one-off debt).
---
_Citations are to files under `/Users/oleh/code/reference/`. The seated chart-choice
+22 -4
View File
@@ -33,12 +33,30 @@ A two-pane modal:
- Optionally overrides the channel's **field type**. The override appears only once a column is selected, and offers only the **types valid for that column** (Tier B valid-type locking) — a string/boolean column never offers Quantitative, and only a date column offers Temporal. Concretely: number → {Quantitative (default), Ordinal, Nominal}; date → {Temporal}; text → {Nominal (default), Ordinal}; boolean → {Nominal}. When a column admits only one valid type, no override control is shown.
- When a column is chosen, its field type defaults from the dataset's inferred column type (numeric → Quantitative, date → Temporal, otherwise Nominal); the user may change it within the valid set above.
- **Size discipline:** the **Size** channel accepts only Quantitative or Ordinal columns — size implies an ordered magnitude, so categorical (Nominal) and Temporal columns are not offered for Size (they remain available on X/Y/Color). A column that can't go on Size is shown disabled there with a brief reason.
- The column dropdown also offers a field-less **"Count of records"** measure (Vega-Lite `count`) — a quantitative count of the rows, with no column.
- Clearing a channel back to "None" leaves it out of the produced spec.
- A **Swap X/Y** control exchanges the X and Y mappings (field and type) in one click, for quickly flipping the axes of the pre-populated default without re-selecting both columns.
The field-type override is presented as a fixed **`N | O | Q | T`** segmented control (abbreviations with full-name tooltips, after _Datasets_' Nominal/Ordinal/Quantitative/Temporal), always showing all four with the column's invalid types **disabled** rather than hidden — so the control keeps one shape on every channel.
### Transforms (per channel)
Once a column is mapped, the channel offers the transforms that apply to its field type — and only those:
- **Aggregate** (a measure / Quantitative field): one of `Sum`, `Mean`, `Median`, `Min`, `Max`, or `None`. (The field-less `Count` measure is chosen via the "Count of records" column option above.)
- **Bin** (a Quantitative field): bins the values into ranges — e.g. a Quantitative X binned with a Count Y is a histogram. Binning and aggregating the same field are mutually exclusive (setting one clears the other).
- **Granularity** (a Temporal field): a Vega-Lite `timeUnit` — Year, Year-Quarter, Year-Month, Year-Month-Day, Quarter, Month, Week, Day of month, Day of week, Hour — or `None` (raw timestamps). Defaults to **None** (no silent change to what the raw data shows).
### Sort and stacking (chart-level)
These controls appear only when they apply:
- **Sort** (when X and Y form a category-vs-measure pair): sorts the categorical axis by the measure — `Ascending`, `Descending`, or `None` — the standard way to rank a bar chart.
- **Stacking** (a Bar or Area mark with a Color series): `Stacked` (absolute) or `100%` (normalized, part-to-whole). Bars/areas without a Color series, or other marks, show no stacking control.
### Default pre-population
- On open, the first detected column is assigned to **X** and the second (if any) to **Y**, each with its derived field type. Remaining channels start unmapped. The mark starts at the smart default for that X/Y shape (see _Mark type_), not unconditionally Bar.
- On open, the first detected column is assigned to **X** and the second (if any) to **Y**, each with its derived field type and no transforms. Remaining channels start unmapped. The mark starts at the smart default for that X/Y shape (see _Mark type_), not unconditionally Bar.
### Guidance (non-blocking)
@@ -48,6 +66,7 @@ The builder surfaces short, plain-language hints for configurations that render
- A **Bar/Line/Area** whose X and Y are both categories (nothing to measure).
- **Two measures** on a non-scatter mark (a scatter — Point/Circle — usually reads better).
- An **Area** chart split into multiple colour series (per-series change is hard to see).
- A **Bar/Line/Area** that pairs a category axis with a **raw (un-aggregated) measure** over a many-row dataset — it draws one mark, and one axis label, per row, so the category axis becomes an unreadable picket fence. The hint suggests aggregating the measure (one mark per category) or, for a bar, flipping to a horizontal bar (Swap X/Y) where long labels stay readable (FT Visual Vocabulary / Datawrapper). Only the un-aggregated case (mark-count = row-count) is detected; flagging an _aggregated_ axis that still has many distinct categories needs per-column distinct counts the profiler does not yet compute (a known gap).
A clean configuration shows no hints.
@@ -72,12 +91,11 @@ A clean configuration shows no hints.
Selecting "Create Snippet" produces the final artifact:
- Builds a complete Vega-Lite spec containing: the schema reference, a named data reference to the dataset, the chosen mark (with tooltips enabled), the mapped encodings (each with its field and field type), and any explicit width/height.
- Builds a complete Vega-Lite spec containing: the schema reference, a named data reference to the dataset, the chosen mark (with tooltips enabled), the mapped encodings (each with its field and field type, plus any aggregate / bin / `timeUnit` transform), chart-level sort and stacking where set, and any explicit width/height.
- Channels left unmapped are omitted; if no encodings exist the spec omits the encoding block entirely (prevented by validation here).
- Creates a new snippet from that spec with an auto-generated descriptive name, adds it to the snippet library, and records that it was built from the dataset.
- Links the snippet to the dataset by recording the dataset reference, so the bidirectional snippet↔dataset relationship is established (see _Datasets_).
- Raises a success toast naming the created snippet.
- Closes the builder; the newly created snippet becomes the active snippet in the library/editor.
- Closes the builder; the newly created snippet becomes the active snippet in the library/editor. **No success toast** — the result is immediately visible (the new snippet opens in the editor), so a toast would be noise (architecture 10 §1, "toast only what the user can't already see"). This refines the earlier blanket "every action toasts" rule, consistent with the Extract-to-dataset / publish reconciliation.
## Closing
@@ -80,9 +80,20 @@
outline-offset: 1px;
}
.channelRow {
/* Each channel is a small block: a top row (label + column) and, when mapped, a
controls row (N|O|Q|T type + contextual transforms). */
.channel {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
border: var(--border-width) solid var(--border);
border-radius: var(--radius);
}
.channelTop {
display: grid;
grid-template-columns: 48px 1fr auto;
grid-template-columns: 44px 1fr;
align-items: center;
gap: var(--space-2);
}
@@ -93,8 +104,15 @@
color: var(--text);
}
.select,
.typeSelect {
.channelControls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
padding-left: 52px;
}
.select {
padding: var(--space-2) var(--space-3);
border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius);
@@ -102,14 +120,52 @@
color: var(--text);
font: inherit;
font-size: 13px;
width: 100%;
}
.typeSelect {
/* The N|O|Q|T type control: monospace abbreviations so the four segments line up. */
.typeSeg button {
font-family: var(--font-mono);
font-size: 11px;
padding: var(--space-1) var(--space-2);
}
.transform {
display: inline-flex;
align-items: center;
gap: 6px;
}
.miniLabel {
font-size: 11px;
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;
gap: 6px;
font-size: 12px;
color: var(--text-secondary);
}
.chartControls {
display: flex;
gap: var(--space-5);
}
.select:focus-visible,
.typeSelect:focus-visible,
.mini:focus-visible,
.dimInput:focus-visible {
outline: 2px solid var(--focus);
outline-offset: -1px;
@@ -168,6 +224,15 @@
color: var(--support-warning, var(--text-secondary));
}
/* Explains the disabled Create action (contract 10: a disabled control must say
why). `margin-top: auto` pins it just above the actions so the two read as one. */
.createHint {
margin: auto 0 0;
font-size: 12px;
color: var(--text-secondary);
text-align: right;
}
.actions {
display: flex;
justify-content: flex-end;
+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
@@ -40,3 +40,11 @@
background: var(--layer-02);
color: var(--text);
}
/* Disabled option: perceivable but inert (dimmed, no hover affordance). */
.option[aria-disabled='true'],
.option[aria-disabled='true']:hover {
color: var(--text-placeholder);
background: var(--bg);
cursor: not-allowed;
}
+38 -13
View File
@@ -20,6 +20,15 @@ import styles from './SegmentedControl.module.css';
export interface SegmentedOption<T extends string> {
value: T;
label: string;
/**
* When true the option is shown but not selectable: `aria-disabled`, never the
* roving tab stop, skipped by Arrow/Home/End, and ignores clicks (APG radio —
* disabled options remain perceivable but inert). The selected value must never
* be a disabled option (callers keep the active value valid).
*/
disabled?: boolean;
/** Full accessible name + hover tooltip when `label` is a terse abbreviation. */
title?: string;
}
interface SegmentedControlProps<T extends string> {
@@ -47,28 +56,38 @@ export function SegmentedControl<T extends string>({
}: SegmentedControlProps<T>) {
const refs = useRef<Array<HTMLButtonElement | null>>([]);
/** Select the option at `index` (wrapping) and move focus to it (APG radio). */
const selectAt = (index: number) => {
const next = (index + options.length) % options.length;
onChange(options[next].value);
refs.current[next]?.focus();
/**
* Select the first **enabled** option reached from `from` stepping by `dir`
* (wrapping), and move focus to it (APG radio). Skips disabled options so arrow
* keys never land on an inert segment; a group with no enabled option is a no-op.
*/
const move = (from: number, dir: 1 | -1) => {
const n = options.length;
for (let step = 1; step <= n; step++) {
const idx = (((from + dir * step) % n) + n) % n;
if (!options[idx].disabled) {
onChange(options[idx].value);
refs.current[idx]?.focus();
return;
}
}
};
const onKeyDown = (e: React.KeyboardEvent, index: number) => {
switch (e.key) {
case 'ArrowRight':
case 'ArrowDown':
selectAt(index + 1);
move(index, 1);
break;
case 'ArrowLeft':
case 'ArrowUp':
selectAt(index - 1);
move(index, -1);
break;
case 'Home':
selectAt(0);
move(-1, 1); // first enabled from the start
break;
case 'End':
selectAt(options.length - 1);
move(0, -1); // last enabled from the end
break;
default:
return; // not ours — let it bubble
@@ -93,9 +112,13 @@ export function SegmentedControl<T extends string>({
type="button"
role="radio"
aria-checked={selected}
// Roving tabindex: only the selected option is a tab stop; arrows
// move within the group.
tabIndex={selected ? 0 : -1}
aria-disabled={opt.disabled || undefined}
aria-label={opt.title}
title={opt.title}
// Roving tabindex: only the selected (always enabled) option is a tab
// stop; arrows move within the group, skipping disabled options. A
// disabled option is never a tab stop.
tabIndex={selected && !opt.disabled ? 0 : -1}
className={[
styles.option,
optionClassName,
@@ -104,7 +127,9 @@ export function SegmentedControl<T extends string>({
]
.filter(Boolean)
.join(' ')}
onClick={() => onChange(opt.value)}
onClick={() => {
if (!opt.disabled) onChange(opt.value);
}}
onKeyDown={(e) => onKeyDown(e, i)}
>
{opt.label}
+55 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, test } from 'vitest';
import { createDataset } from '@core/dataset';
import { useChartBuilderStore } from './ChartBuilderStore';
import { COUNT_FIELD, useChartBuilderStore } from './ChartBuilderStore';
import { useDatasetStore } from './DatasetStore';
import { useSnippetStore } from './SnippetStore';
@@ -71,6 +71,60 @@ describe('channel editing', () => {
});
});
describe('transforms — aggregate / bin / timeUnit / count', () => {
test('the Count-of-records option maps a field-less count measure', () => {
const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
cb().init(id);
cb().setChannelColumn('y', COUNT_FIELD);
expect(cb().config.encodings.y).toEqual({ type: 'quantitative', aggregate: 'count' });
});
test('aggregate and bin are mutually exclusive on a channel', () => {
const id = seedDataset('S', [{ revenue: 5 }]);
cb().init(id);
cb().setChannelColumn('y', 'revenue');
cb().setChannelAggregate('y', 'sum');
expect(cb().config.encodings.y).toMatchObject({ field: 'revenue', aggregate: 'sum' });
cb().setChannelBin('y', true);
expect(cb().config.encodings.y?.aggregate).toBeUndefined();
expect(cb().config.encodings.y?.bin).toBe(true);
cb().setChannelAggregate('y', 'mean');
expect(cb().config.encodings.y?.bin).toBeUndefined();
expect(cb().config.encodings.y?.aggregate).toBe('mean');
});
test('changing field type drops transforms that no longer apply', () => {
const id = seedDataset('S', [{ price: 5 }]);
cb().init(id);
cb().setChannelColumn('x', 'price');
cb().setChannelBin('x', true);
cb().setChannelType('x', 'nominal'); // leaving quantitative
expect(cb().config.encodings.x).toEqual({ field: 'price', type: 'nominal' });
});
test('setChannelTimeUnit sets and clears granularity', () => {
const id = seedDataset('S', [{ day: '2026-01-01', v: 1 }]);
cb().init(id);
cb().setChannelTimeUnit('x', 'yearmonth');
expect(cb().config.encodings.x?.timeUnit).toBe('yearmonth');
cb().setChannelTimeUnit('x', undefined);
expect(cb().config.encodings.x?.timeUnit).toBeUndefined();
});
});
describe('sort / stack', () => {
test('setSort and setStack set and clear the chart-level fields', () => {
const id = seedDataset('S', [{ region: 'N', revenue: 5 }]);
cb().init(id);
cb().setSort('descending');
expect(cb().config.sort).toBe('descending');
cb().setSort(undefined);
expect(cb().config.sort).toBeUndefined();
cb().setStack('normalize');
expect(cb().config.stack).toBe('normalize');
});
});
describe('createSnippet', () => {
test('builds a linked snippet, activates it, and resets the builder', () => {
const id = seedDataset('Sales', [
Binary file not shown.
+214
View File
@@ -4,6 +4,12 @@ import {
validFieldTypes,
defaultMark,
isChannelTypeAllowed,
supportsAggregate,
supportsBin,
supportsTimeUnit,
supportsSort,
supportsStack,
sortableCategoryChannel,
builderWarnings,
defaultBuilderConfig,
isBuilderConfigValid,
@@ -12,6 +18,7 @@ import {
generateChartName,
type BuilderColumns,
type BuilderConfig,
type ChannelMapping,
} from './chart-builder';
import { VEGA_LITE_SCHEMA_URL } from './snippet';
@@ -143,6 +150,79 @@ describe('builderWarnings (Tier B advisories)', () => {
});
expect(w).toEqual([]);
});
describe('crowded category axis (one mark per row)', () => {
const crowded = (overrides: Partial<ChannelMapping> = {}) =>
builderWarnings(
{
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'name', type: 'nominal' },
y: { field: 'mpg', type: 'quantitative', ...overrides },
},
},
406,
);
it('warns when a raw measure draws one bar per row over a large dataset', () => {
const w = crowded();
const hint = w.find((m) => /one mark per row/.test(m.message));
expect(hint?.channel).toBe('x'); // the category axis
expect(hint?.message).toContain('406 in this dataset');
expect(hint?.message).toMatch(/Swap X\/Y/); // bar → horizontal-bar remedy
});
it('is silent once the measure is aggregated (one bar per category)', () => {
const w = crowded({ aggregate: 'mean' });
expect(w.some((m) => /one mark per row/.test(m.message))).toBe(false);
});
it('is silent for a small dataset even with a raw measure', () => {
const w = builderWarnings(
{
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'name', type: 'nominal' },
y: { field: 'mpg', type: 'quantitative' },
},
},
12,
);
expect(w.some((m) => /one mark per row/.test(m.message))).toBe(false);
});
it('is silent when the row count is unknown (URL/non-tabular)', () => {
const w = builderWarnings({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'name', type: 'nominal' },
y: { field: 'mpg', type: 'quantitative' },
},
});
expect(w.some((m) => /one mark per row/.test(m.message))).toBe(false);
});
it('uses non-bar wording (no Swap X/Y) for a line mark', () => {
const w = builderWarnings(
{
datasetName: 'D',
mark: 'line',
encodings: {
x: { field: 'name', type: 'nominal' },
y: { field: 'mpg', type: 'quantitative' },
},
},
406,
);
const hint = w.find((m) => /one mark per row/.test(m.message));
expect(hint).toBeDefined();
expect(hint?.message).not.toMatch(/Swap X\/Y/);
expect(hint?.message).toMatch(/reduce the number of categories/);
});
});
});
describe('defaultBuilderConfig', () => {
@@ -263,6 +343,140 @@ describe('buildChartSpec', () => {
});
});
describe('transforms — aggregate / bin / timeUnit', () => {
it('emits a field-less count encoding', () => {
const spec = buildChartSpec({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { type: 'quantitative', aggregate: 'count' },
},
});
const enc = spec.encoding as Record<string, Record<string, unknown>>;
expect(enc.y).toEqual({ aggregate: 'count', type: 'quantitative' });
expect(enc.y.field).toBeUndefined();
});
it('emits a non-count aggregate with its field', () => {
const spec = buildChartSpec({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'revenue', type: 'quantitative', aggregate: 'sum' },
},
});
const enc = spec.encoding as Record<string, Record<string, unknown>>;
expect(enc.y).toEqual({ field: 'revenue', type: 'quantitative', aggregate: 'sum' });
});
it('emits bin on a quantitative field (histogram shape) and timeUnit on a temporal one', () => {
const hist = buildChartSpec({
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'price', type: 'quantitative', bin: true },
y: { type: 'quantitative', aggregate: 'count' },
},
});
const henc = hist.encoding as Record<string, Record<string, unknown>>;
expect(henc.x).toEqual({ field: 'price', type: 'quantitative', bin: true });
const ts = buildChartSpec({
datasetName: 'D',
mark: 'line',
encodings: {
x: { field: 'day', type: 'temporal', timeUnit: 'yearmonth' },
y: { field: 'v', type: 'quantitative' },
},
});
const tenc = ts.encoding as Record<string, Record<string, unknown>>;
expect(tenc.x).toEqual({ field: 'day', type: 'temporal', timeUnit: 'yearmonth' });
});
it('exposes the transform-applicability predicates by field type', () => {
expect(supportsAggregate('quantitative')).toBe(true);
expect(supportsAggregate('nominal')).toBe(false);
expect(supportsBin('quantitative')).toBe(true);
expect(supportsBin('temporal')).toBe(false);
expect(supportsTimeUnit('temporal')).toBe(true);
expect(supportsTimeUnit('quantitative')).toBe(false);
});
});
describe('sort (ranking)', () => {
const ranking: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'region', type: 'nominal' },
y: { field: 'revenue', type: 'quantitative', aggregate: 'sum' },
},
};
it('sorts the category axis by the measure axis (descending → "-y")', () => {
expect(sortableCategoryChannel(ranking)).toBe('x');
expect(supportsSort(ranking)).toBe(true);
const enc = buildChartSpec({ ...ranking, sort: 'descending' }).encoding as Record<
string,
Record<string, unknown>
>;
expect(enc.x.sort).toBe('-y');
const asc = buildChartSpec({ ...ranking, sort: 'ascending' }).encoding as Record<
string,
Record<string, unknown>
>;
expect(asc.x.sort).toBe('y');
});
it('does not offer sort when both axes are measures', () => {
const scatter: BuilderConfig = {
datasetName: 'D',
mark: 'point',
encodings: {
x: { field: 'a', type: 'quantitative' },
y: { field: 'b', type: 'quantitative' },
},
};
expect(supportsSort(scatter)).toBe(false);
expect(buildChartSpec({ ...scatter, sort: 'descending' }).encoding).toBeDefined();
const enc = buildChartSpec({ ...scatter, sort: 'descending' }).encoding as Record<
string,
Record<string, unknown>
>;
expect(enc.x.sort).toBeUndefined();
});
});
describe('stack (part-to-whole)', () => {
const stacked: BuilderConfig = {
datasetName: 'D',
mark: 'bar',
encodings: {
x: { field: 'month', type: 'ordinal' },
y: { field: 'sales', type: 'quantitative', aggregate: 'sum' },
color: { field: 'product', type: 'nominal' },
},
};
it('stacks the quantitative axis for a bar/area + colour series', () => {
expect(supportsStack(stacked)).toBe(true);
const enc = buildChartSpec({ ...stacked, stack: 'normalize' }).encoding as Record<
string,
Record<string, unknown>
>;
expect(enc.y.stack).toBe('normalize');
});
it('does not stack without a colour series or on a point mark', () => {
expect(supportsStack({ ...stacked, encodings: { ...stacked.encodings, color: null } })).toBe(
false,
);
expect(supportsStack({ ...stacked, mark: 'point' })).toBe(false);
});
});
describe('buildSnippetSpecText', () => {
it('produces pretty-printed JSON that parses back to the spec', () => {
const config = defaultBuilderConfig('Sales', columns);
+248 -38
View File
@@ -2,17 +2,24 @@
* Chart Builder — pure Vega-Lite spec assembler (spec §06).
*
* Portable core: no browser APIs, no React, no store access. Turns a no-JSON
* builder configuration (a mark, four optional encoding channels mapped to
* dataset columns, optional pixel dimensions) into a complete Vega-Lite spec that
* references the source dataset by name. The UI layer owns the controls; this
* module owns the spec grammar — what a configuration *means* as Vega-Lite — and
* the defaults the spec prescribes (pre-population, field-type derivation).
* builder configuration (a mark, four optional encoding channels mapped to dataset
* columns, with per-channel transforms, plus chart-level sort/stack and optional
* pixel dimensions) into a complete Vega-Lite spec that references the source
* dataset by name. The UI layer owns the controls; this module owns the spec
* grammar — what a configuration *means* as Vega-Lite — and the defaults the spec
* prescribes (pre-population, field-type derivation, smart mark).
*
* The produced spec mirrors what the rest of Astrolabe authors by hand: a
* `$schema` stamp (shared with the sample template), a named-data reference the
* renderer resolves at preview time (rendering.ts), a mark with tooltips enabled,
* the mapped encodings, and any explicit width/height. It is the same string-spec
* shape the editor and preview consume — `buildSnippetSpecText` serializes it.
* Beyond the Tier-B floor, a channel may carry a **transform** — an `aggregate`
* (count/sum/mean/…), a quantitative `bin`, or a temporal `timeUnit` granularity —
* and the chart may carry a `sort` (rank a categorical axis by its measure) and a
* `stack` (part-to-whole for bar/area + colour). See
* docs/chart-builder-research.md §8 for why each exists.
*
* The produced spec mirrors what the rest of Astrolabe authors by hand: a `$schema`
* stamp (shared with the sample template), a named-data reference the renderer
* resolves at preview time (rendering.ts), a mark with tooltips enabled, the mapped
* encodings, and any explicit width/height. It is the same string-spec shape the
* editor and preview consume — `buildSnippetSpecText` serializes it.
*/
import type { ColumnType } from './type-inference';
@@ -31,12 +38,56 @@ export const CHANNELS = ['x', 'y', 'color', 'size'] as const;
export type ChannelName = (typeof CHANNELS)[number];
/**
* One channel's mapping: a dataset column `field` plus its `type`. A channel left
* on "None" is represented by `null` in the config (omitted from the spec).
* 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`.
*/
export const AGGREGATE_OPS = ['count', 'sum', 'mean', 'median', 'min', 'max'] as const;
export type AggregateOp = (typeof AGGREGATE_OPS)[number];
/**
* Temporal granularities (Vega-Lite `timeUnit`), coarse → fine, with the combined
* units that make a real time axis. Offered when a channel's type is Temporal.
*/
export const TIME_UNITS = [
'year',
'yearquarter',
'yearmonth',
'yearmonthdate',
'quarter',
'month',
'week',
'date',
'day',
'hours',
] as const;
export type TimeUnit = (typeof TIME_UNITS)[number];
/** Sort the categorical axis by its measure (spec §06 → Ranking). */
export const SORT_ORDERS = ['ascending', 'descending'] as const;
export type SortOrder = (typeof SORT_ORDERS)[number];
/** Part-to-whole stacking for bar/area + a colour series: absolute vs 100%. */
export const STACK_MODES = ['zero', 'normalize'] as const;
export type StackMode = (typeof STACK_MODES)[number];
/**
* One channel's mapping: a dataset column `field` plus its `type`, with optional
* transforms. `field` is omitted only for a `count` aggregate (which counts records
* rather than reducing a column). A channel left on "None" is `null` in the config
* (omitted from the spec).
*/
export interface ChannelMapping {
field: string;
/** The dataset column. Omitted only when `aggregate === 'count'`. */
field?: string;
/** The Vega-Lite field type (see `validFieldTypes`). */
type: FieldType;
/** Aggregation op; `count` is field-less, the rest reduce a quantitative field. */
aggregate?: AggregateOp;
/** Bin a quantitative field into ranges (e.g. for a histogram). */
bin?: boolean;
/** Temporal granularity for a Temporal field. */
timeUnit?: TimeUnit;
}
/** The full builder configuration the assembler consumes. */
@@ -51,6 +102,10 @@ export interface BuilderConfig {
width?: number;
/** Optional explicit chart height in pixels. */
height?: number;
/** Sort the categorical positional axis by the measure axis (spec §06 → Ranking). */
sort?: SortOrder;
/** Stacking for bar/area + a colour series (part-to-whole). */
stack?: StackMode;
}
/**
@@ -110,6 +165,21 @@ export function isChannelTypeAllowed(channel: ChannelName, type: FieldType): boo
return true;
}
/** Whether a non-count aggregate (sum/mean/…) can apply to this field type. */
export function supportsAggregate(type: FieldType): boolean {
return type === 'quantitative';
}
/** Whether binning into ranges can apply to this field type. */
export function supportsBin(type: FieldType): boolean {
return type === 'quantitative';
}
/** Whether a temporal granularity (timeUnit) can apply to this field type. */
export function supportsTimeUnit(type: FieldType): boolean {
return type === 'temporal';
}
/**
* The mark that best fits the X/Y field-type shape (spec §06 → Tier B, smart
* default mark) — the research's strongest convergence (Draco mark-by-shape soft
@@ -153,11 +223,11 @@ function fieldTypeForColumn(name: string, columns: BuilderColumns): FieldType {
/**
* The builder's opening configuration for a dataset (spec §06 → Default
* pre-population, Tier B): the first column on X and the second (if any) on Y, each
* with its derived field type; Color and Size start unmapped. The mark is the
* **smart default** for the resulting X/Y shape (`defaultMark`) rather than always
* Bar — a date-vs-number dataset opens as a Line, two measures as a Point — so the
* first preview is already the conventional chart. A dataset with no detected
* columns yields an all-unmapped config (the modal then prompts / disables Create).
* with its derived field type; Color and Size start unmapped, no transforms. The
* mark is the **smart default** for the resulting X/Y shape (`defaultMark`) rather
* than always Bar — a date-vs-number dataset opens as a Line, two measures as a
* Point — so the first preview is already the conventional chart. A dataset with no
* detected columns yields an all-unmapped config (the modal then prompts).
*/
export function defaultBuilderConfig(datasetName: string, columns: BuilderColumns): BuilderConfig {
const encodings: Partial<Record<ChannelName, ChannelMapping | null>> = {
@@ -177,7 +247,7 @@ export function defaultBuilderConfig(datasetName: string, columns: BuilderColumn
return { datasetName, mark, encodings };
}
/** The channels actually mapped to a column, in canonical order. */
/** The channels actually mapped (a column field, or a field-less count), in order. */
function mappedChannels(config: BuilderConfig): Array<[ChannelName, ChannelMapping]> {
return CHANNELS.flatMap((channel) => {
const mapping = config.encodings[channel];
@@ -185,15 +255,70 @@ function mappedChannels(config: BuilderConfig): Array<[ChannelName, ChannelMappi
});
}
/** The effective field type a mapping encodes (a count is quantitative). */
function effectiveType(mapping: ChannelMapping): FieldType {
return mapping.aggregate === 'count' ? 'quantitative' : mapping.type;
}
/** True when a mapping reads as a continuous measure (count/aggregate or continuous type). */
function isMeasureMapping(mapping: ChannelMapping): boolean {
return isContinuous(effectiveType(mapping));
}
/**
* Whether the configuration is renderable / saveable (spec §06 → Validation): at
* least one channel must be mapped to a column. The modal gates the Create action
* and the preview prompt on this.
* least one channel must be mapped. The modal gates the Create action and the
* preview prompt on this.
*/
export function isBuilderConfigValid(config: BuilderConfig): boolean {
return mappedChannels(config).length > 0;
}
/**
* The categorical positional channel to sort, when exactly one of X/Y is a discrete
* category and the other is a measure (spec §06 → Ranking). Returns the channel to
* carry `sort`, or undefined when sorting doesn't apply (no clear category axis).
*/
export function sortableCategoryChannel(config: BuilderConfig): 'x' | 'y' | undefined {
const x = config.encodings.x ?? null;
const y = config.encodings.y ?? null;
if (!x || !y) return undefined;
const xMeasure = isMeasureMapping(x);
const yMeasure = isMeasureMapping(y);
if (xMeasure && !yMeasure) return 'y';
if (yMeasure && !xMeasure) return 'x';
return undefined;
}
/** The quantitative positional channel (x or y) that stacking applies to, if any. */
function stackMeasureChannel(config: BuilderConfig): 'x' | 'y' | undefined {
for (const channel of ['x', 'y'] as const) {
const mapping = config.encodings[channel];
if (mapping && effectiveType(mapping) === 'quantitative') return channel;
}
return undefined;
}
/**
* Whether sorting can be offered for this config (a clear category-vs-measure axis
* pair exists). The UI shows the Sort control only when true.
*/
export function supportsSort(config: BuilderConfig): boolean {
return sortableCategoryChannel(config) !== undefined;
}
/**
* Whether stacking can be offered: a bar/area mark with a colour series and a
* quantitative positional axis to stack along (spec §06 → part-to-whole).
*/
export function supportsStack(config: BuilderConfig): boolean {
return (
(config.mark === 'bar' || config.mark === 'area') &&
!!config.encodings.color &&
stackMeasureChannel(config) !== undefined
);
}
/** A non-blocking advisory about a configuration (spec §06 → Tier B warnings). */
export interface BuilderWarning {
/** The channel the hint is about, when it's channel-specific. */
@@ -202,14 +327,27 @@ export interface BuilderWarning {
message: string;
}
/**
* Above this many rows, a category-vs-measure bar/line/area with a **raw**
* (un-aggregated) measure draws so many marks — one per row — that the category
* axis becomes an unreadable picket fence of labels. The threshold is a legibility
* estimate, not a hard limit (the chart still renders); it's set where vertical bar
* labels reliably start overlapping. See `builderWarnings`.
*/
const CROWDED_CATEGORY_ROWS = 30;
/**
* Non-blocking advisories for the current configuration (spec §06 → Tier B): the
* encodings that render but read poorly, drawn from the research's soft rules
* (docs/chart-builder-research.md §4, §7). These never block Create — `isBuilder
* ConfigValid` is the only gate — they just steer the user toward a better chart.
* Returned in a stable order so the UI list doesn't jitter as config changes.
*
* `rowCount` (the dataset's row count, when known) powers the crowded-axis hint;
* pass it from the loaded dataset. Omitted/`null` (URL or non-tabular data) simply
* skips that one hint.
*/
export function builderWarnings(config: BuilderConfig): BuilderWarning[] {
export function builderWarnings(config: BuilderConfig, rowCount?: number | null): BuilderWarning[] {
const warnings: BuilderWarning[] = [];
const x = config.encodings.x ?? null;
const y = config.encodings.y ?? null;
@@ -229,11 +367,11 @@ export function builderWarnings(config: BuilderConfig): BuilderWarning[] {
(mark === 'bar' || mark === 'line' || mark === 'area') &&
x !== null &&
y !== null &&
!isContinuous(x.type) &&
!isContinuous(y.type)
!isMeasureMapping(x) &&
!isMeasureMapping(y)
) {
warnings.push({
message: `${markLabel(mark)} charts need a measure (quantitative or temporal) on the X or Y axis.`,
message: `${markLabel(mark)} charts need a measure (a value or count) on the X or Y axis.`,
});
}
@@ -242,8 +380,8 @@ export function builderWarnings(config: BuilderConfig): BuilderWarning[] {
if (
x !== null &&
y !== null &&
x.type === 'quantitative' &&
y.type === 'quantitative' &&
effectiveType(x) === 'quantitative' &&
effectiveType(y) === 'quantitative' &&
mark !== 'point' &&
mark !== 'circle'
) {
@@ -262,19 +400,66 @@ export function builderWarnings(config: BuilderConfig): BuilderWarning[] {
});
}
// Crowded category axis: a bar/line/area pairing a discrete category against a
// *raw* (un-aggregated, un-binned) measure draws one mark — and one axis label —
// per row, so a large dataset becomes an unreadable picket fence of labels (the
// builder's own default does this: first column on X, second on Y, no aggregate).
// We can only flag the un-aggregated case, where mark-count == rowCount exactly;
// an *aggregated* axis that still has many distinct categories needs per-column
// distinct counts we don't profile yet (backlog A2/A3). The fix follows the canon:
// aggregate the measure to one mark per category, or — for a bar — flip to a
// horizontal bar where long labels stay readable (FT Visual Vocabulary: bar is
// "good when … labels have long category names"; Datawrapper: long category lists
// belong on a horizontal bar).
if (
(mark === 'bar' || mark === 'line' || mark === 'area') &&
typeof rowCount === 'number' &&
rowCount > CROWDED_CATEGORY_ROWS
) {
const category = sortableCategoryChannel(config); // the discrete axis of a category-vs-measure pair
const measure = category ? config.encodings[category === 'x' ? 'y' : 'x'] : null;
if (category && measure && !measure.aggregate && !measure.bin) {
const fix =
mark === 'bar'
? 'Aggregate the measure (e.g. Sum or Mean) for one bar per category, or use Swap X/Y for a horizontal bar where long labels stay readable.'
: 'Aggregate the measure (e.g. Sum or Mean) so there is one mark per category, or reduce the number of categories.';
warnings.push({
channel: category,
message: `This draws one mark per row (${rowCount} in this dataset), so the category-axis labels will overlap. ${fix}`,
});
}
}
return warnings;
}
/** A built Vega-Lite spec, as a plain object (serialize with `buildSnippetSpecText`). */
export type ChartSpec = Record<string, unknown>;
/** Build one channel's Vega-Lite encoding object from its mapping + transforms. */
function encodingObject(mapping: ChannelMapping): Record<string, unknown> {
// A field-less count: `{ aggregate: 'count', type: 'quantitative' }`.
if (mapping.aggregate === 'count') {
return { aggregate: 'count', type: 'quantitative' };
}
const enc: Record<string, unknown> = {};
if (mapping.field !== undefined) enc.field = mapping.field;
enc.type = mapping.type;
if (mapping.aggregate) enc.aggregate = mapping.aggregate;
if (mapping.bin) enc.bin = true;
if (mapping.timeUnit) enc.timeUnit = mapping.timeUnit;
return enc;
}
/**
* Assemble the complete Vega-Lite spec from a builder configuration (spec §06 →
* Output). Includes the schema reference, a named data reference to the dataset,
* the mark with tooltips enabled, every mapped encoding (field + field type), 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).
* 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
* 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).
*/
export function buildChartSpec(config: BuilderConfig): ChartSpec {
const spec: ChartSpec = {
@@ -283,10 +468,27 @@ export function buildChartSpec(config: BuilderConfig): ChartSpec {
mark: { type: config.mark, tooltip: true },
};
const encoding: Record<string, { field: string; type: FieldType }> = {};
const encoding: Record<string, Record<string, unknown>> = {};
for (const [channel, mapping] of mappedChannels(config)) {
encoding[channel] = { field: mapping.field, type: mapping.type };
encoding[channel] = encodingObject(mapping);
}
// Sort: the categorical positional axis sorts by the value of the measure axis
// ("-y" descending, "y" ascending) — the conventional Vega-Lite ranking idiom.
if (config.sort) {
const category = sortableCategoryChannel(config);
if (category && encoding[category]) {
const measure = category === 'x' ? 'y' : 'x';
encoding[category].sort = config.sort === 'descending' ? `-${measure}` : measure;
}
}
// Stack: part-to-whole on the quantitative positional axis of a bar/area + colour.
if (config.stack && supportsStack(config)) {
const measure = stackMeasureChannel(config);
if (measure && encoding[measure]) encoding[measure].stack = config.stack;
}
if (Object.keys(encoding).length > 0) spec.encoding = encoding;
if (config.width !== undefined) spec.width = config.width;
@@ -305,19 +507,27 @@ function markLabel(mark: MarkType): string {
return mark.charAt(0).toUpperCase() + mark.slice(1);
}
/** A human phrase for what a channel encodes, e.g. "sum of revenue", "count". */
function describeMapping(mapping: ChannelMapping): string {
if (mapping.aggregate === 'count') return 'count';
const field = mapping.field ?? '';
if (mapping.aggregate) return `${mapping.aggregate} of ${field}`;
return field;
}
/**
* An auto-generated, descriptive name for the created snippet (spec §06 → Output:
* "an auto-generated descriptive name"). When both X and Y are mapped it reads
* "Bar chart of <y> by <x>"; otherwise it falls back to naming the dataset:
* "Bar chart of <dataset>". Deterministic — no timestamp — so the name describes
* the chart, not when it was made.
* "Bar chart of <y> by <x>" (using each channel's measure phrase, e.g. "count" or
* "sum of revenue"); otherwise it falls back to naming the dataset. Deterministic —
* no timestamp — so the name describes the chart, not when it was made.
*/
export function generateChartName(config: BuilderConfig): string {
const mark = markLabel(config.mark);
const x = config.encodings.x;
const y = config.encodings.y;
if (x && y) return `${mark} chart of ${y.field} by ${x.field}`;
if (x && y) return `${mark} chart of ${describeMapping(y)} by ${describeMapping(x)}`;
const only = mappedChannels(config)[0];
if (only) return `${mark} chart of ${only[1].field}`;
if (only) return `${mark} chart of ${describeMapping(only[1])}`;
return `${mark} chart of ${config.datasetName}`;
}
+4
View File
@@ -84,6 +84,10 @@ export function profileData(
if (columns.length === 0) return naProfile(size);
const sample = sampleRows(rows);
// TODO (backlog: docs/chart-builder-research.md §8 — A3/A4 enabler): in this same
// sample pass, also derive a capped per-column distinct count (cardinality, cap ~50)
// and numeric extent (min/max → sign), surfaced on DatasetProfile, to power the Chart
// Builder's crowded-legend / high-cardinality warnings and its negative-value Size guard.
const columnTypes = columns.map((name) => ({
name,
type: inferColumnType(sample.map((r) => r[name])),