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. **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** **Core**
- `chart-builder.ts` — pure spec assembler: (mark ∈ Bar/Line/Point/Area/Circle) + - `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 ### Reading/writing outside components
Services, orchestration, infrastructure, and tests use the store object directly — 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** render synchronously on every keystroke.
- **Don't** await a render inside an input/keydown handler. - **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) ## 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. - 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. - 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 _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. - 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. - 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. - **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. - 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. - 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 ### 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) ### 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). - 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). - **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). - 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. A clean configuration shows no hints.
@@ -72,12 +91,11 @@ A clean configuration shows no hints.
Selecting "Create Snippet" produces the final artifact: 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). - 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. - 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_). - 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. **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.
- Closes the builder; the newly created snippet becomes the active snippet in the library/editor.
## Closing ## Closing
@@ -80,9 +80,20 @@
outline-offset: 1px; 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; display: grid;
grid-template-columns: 48px 1fr auto; grid-template-columns: 44px 1fr;
align-items: center; align-items: center;
gap: var(--space-2); gap: var(--space-2);
} }
@@ -93,8 +104,15 @@
color: var(--text); color: var(--text);
} }
.select, .channelControls {
.typeSelect { display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
padding-left: 52px;
}
.select {
padding: var(--space-2) var(--space-3); padding: var(--space-2) var(--space-3);
border: var(--border-width) solid var(--border-strong); border: var(--border-width) solid var(--border-strong);
border-radius: var(--radius); border-radius: var(--radius);
@@ -102,14 +120,52 @@
color: var(--text); color: var(--text);
font: inherit; font: inherit;
font-size: 13px; 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; 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, .select:focus-visible,
.typeSelect:focus-visible, .mini:focus-visible,
.dimInput:focus-visible { .dimInput:focus-visible {
outline: 2px solid var(--focus); outline: 2px solid var(--focus);
outline-offset: -1px; outline-offset: -1px;
@@ -168,6 +224,15 @@
color: var(--support-warning, var(--text-secondary)); 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 { .actions {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
+217 -57
View File
@@ -2,12 +2,16 @@
* Chart Builder — the modal body (spec §06). * Chart Builder — the modal body (spec §06).
* *
* A two-pane composer: left is the configuration (dataset name, mark selector, one * 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 * block per channel, chart-level sort/stacking, optional dimensions, guidance,
* of the spec the configuration produces. All spec logic and Tier-B defaults/guards * Create), right is a live preview of the spec the configuration produces. All spec
* come from `@core/chart-builder` via `ChartBuilderStore`; this component is the * logic and Tier-B defaults/guards come from `@core/chart-builder` via
* view. The preview is builder-local (its own debounced render over the shared * `ChartBuilderStore`; this component is the view. Each channel is a small block:
* `chart-renderer` service) rather than a reuse of `LivePreview`, which is bound to * a column dropdown (with a field-less "Count of records" option), a fixed
* the snippet editor's stores. * `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'; import { useEffect, useMemo, useRef, useState } from 'react';
@@ -15,15 +19,25 @@ import { useShallow } from 'zustand/react/shallow';
import type { VisualizationSpec } from 'vega-embed'; import type { VisualizationSpec } from 'vega-embed';
import { import {
CHANNELS, CHANNELS,
FIELD_TYPES,
MARK_TYPES, MARK_TYPES,
TIME_UNITS,
builderWarnings, builderWarnings,
defaultFieldType, defaultFieldType,
isBuilderConfigValid, isBuilderConfigValid,
isChannelTypeAllowed, isChannelTypeAllowed,
supportsAggregate,
supportsBin,
supportsSort,
supportsStack,
supportsTimeUnit,
validFieldTypes, validFieldTypes,
type AggregateOp,
type ChannelMapping,
type ChannelName, type ChannelName,
type FieldType, type FieldType,
type MarkType, type MarkType,
type TimeUnit,
} from '@core/chart-builder'; } from '@core/chart-builder';
import type { ColumnType } from '@core/type-inference'; import type { ColumnType } from '@core/type-inference';
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering'; import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
@@ -33,6 +47,7 @@ import { closeModal } from '../modals/ModalCoordinator';
import { useAppStore } from '../stores/AppStore'; import { useAppStore } from '../stores/AppStore';
import { useDatasetStore } from '../stores/DatasetStore'; import { useDatasetStore } from '../stores/DatasetStore';
import { import {
COUNT_FIELD,
selectBuilderSpecText, selectBuilderSpecText,
selectBuilderValid, selectBuilderValid,
useChartBuilderStore, useChartBuilderStore,
@@ -42,7 +57,7 @@ import styles from './ChartBuilderModal.module.css';
const RENDER_DEBOUNCE_MS = 300; 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 { function titleCase(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1); return s.charAt(0).toUpperCase() + s.slice(1);
} }
@@ -59,7 +74,33 @@ const CHANNEL_LABELS: Record<ChannelName, string> = {
size: 'Size', 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 { function typeBadge(type: ColumnType): string {
switch (type) { switch (type) {
case 'number': case 'number':
@@ -78,62 +119,145 @@ function columnAllowedOnChannel(channel: ChannelName, colType: ColumnType): bool
return isChannelTypeAllowed(channel, defaultFieldType(colType)); 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 columns = useChartBuilderStore((s) => s.columns);
const mapping = useChartBuilderStore((s) => s.config.encodings[channel] ?? null); const mapping = useChartBuilderStore((s) => s.config.encodings[channel] ?? null);
const setChannelColumn = useChartBuilderStore((s) => s.setChannelColumn); const setChannelColumn = useChartBuilderStore((s) => s.setChannelColumn);
const setChannelType = useChartBuilderStore((s) => s.setChannelType); 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 => const colTypeOf = (name: string): ColumnType =>
columns.columnTypes.find((c) => c.name === name)?.type ?? 'string'; columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
// Type options valid for this column AND allowed on this channel (e.g. Size hides // The fixed N|O|Q|T control: a column's invalid types and types disallowed on this
// Nominal). Shown only when >1 option and a column is selected (spec §06). // channel (e.g. a category on Size) are disabled, never hidden, so the control keeps
const typeOptions: FieldType[] = mapping // one shape on every channel (APG radio with disabled options).
? validFieldTypes(colTypeOf(mapping.field)).filter((t) => isChannelTypeAllowed(channel, t)) 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 ( return (
<div className={styles.channelRow}> <div className={styles.channel}>
<label className={styles.channelLabel} htmlFor={`ch-${channel}`}> <div className={styles.channelTop}>
{CHANNEL_LABELS[channel]} <span className={styles.channelLabel}>{CHANNEL_LABELS[channel]}</span>
</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 && (
<select <select
className={styles.typeSelect} className={styles.select}
aria-label={`${CHANNEL_LABELS[channel]} field type`} aria-label={`${CHANNEL_LABELS[channel]} column`}
value={mapping.type} value={selectValue}
onChange={(e) => setChannelType(channel, e.target.value as FieldType)} onChange={(e) => setChannelColumn(channel, e.target.value === '' ? null : e.target.value)}
> >
{typeOptions.map((t) => ( <option value="">None</option>
<option key={t} value={t}> <option value={COUNT_FIELD}>Count of records</option>
{titleCase(t)} {columns.columns.map((name) => {
</option> const allowed = columnAllowedOnChannel(channel, colTypeOf(name));
))} return (
<option key={name} value={name} disabled={!allowed}>
{name} · {typeBadge(colTypeOf(name))}
{allowed ? '' : ' (needs a measure)'}
</option>
);
})}
</select> </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> </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() { function BuilderPreview() {
const hostRef = useRef<HTMLDivElement>(null); const hostRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<RenderHandle | null>(null); const handleRef = useRef<RenderHandle | null>(null);
@@ -150,8 +274,6 @@ function BuilderPreview() {
const timer = setTimeout(() => { const timer = setTimeout(() => {
void (async () => { void (async () => {
const mine = ++generationRef.current; 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) { if (!valid) {
handleRef.current?.destroy(); handleRef.current?.destroy();
handleRef.current = null; handleRef.current = null;
@@ -189,7 +311,6 @@ function BuilderPreview() {
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [specText, valid, uiTheme, datasets]); }, [specText, valid, uiTheme, datasets]);
// Finalize the view on unmount so the Vega view and its listeners don't leak.
useEffect( useEffect(
() => () => { () => () => {
handleRef.current?.destroy(); handleRef.current?.destroy();
@@ -221,24 +342,30 @@ export function ChartBuilderModal() {
const mark = useChartBuilderStore((s) => s.config.mark); const mark = useChartBuilderStore((s) => s.config.mark);
const width = useChartBuilderStore((s) => s.config.width); const width = useChartBuilderStore((s) => s.config.width);
const height = useChartBuilderStore((s) => s.config.height); 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 setMark = useChartBuilderStore((s) => s.setMark);
const swapXY = useChartBuilderStore((s) => s.swapXY); 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 setWidth = useChartBuilderStore((s) => s.setWidth);
const setHeight = useChartBuilderStore((s) => s.setHeight); const setHeight = useChartBuilderStore((s) => s.setHeight);
const runCreate = useChartBuilderStore((s) => s.createSnippet); 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 // Validity + guidance + which chart-level controls apply are derived from the
// call, which no selector-equality (even useShallow, since the element objects // stable `config` reference via useMemo, NOT a store selector that would build a
// differ every time) can stabilize — subscribing to it would re-render forever. // fresh array each render (which loops useSyncExternalStore — see SnippetStore note).
const config = useChartBuilderStore((s) => s.config); const config = useChartBuilderStore((s) => s.config);
const rowCount = useChartBuilderStore((s) => s.rowCount);
const valid = useMemo(() => isBuilderConfigValid(config), [config]); 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) { if (datasetId === null) {
return <p className={styles.muted}>No dataset loaded. Open this from a dataset in Datasets.</p>; 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 => { const parseDim = (raw: string): number | undefined => {
if (raw.trim() === '') return undefined; if (raw.trim() === '') return undefined;
const n = Number(raw); const n = Number(raw);
@@ -270,12 +397,39 @@ export function ChartBuilderModal() {
</button> </button>
</div> </div>
{CHANNELS.map((channel) => ( {CHANNELS.map((channel) => (
<ChannelRow key={channel} channel={channel} /> <ChannelBlock key={channel} channel={channel} />
))} ))}
</div> </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}> <div className={styles.dimensions}>
<span className={styles.fieldLabel}>Dimensions (optional)</span> <span className={styles.fieldLabel}>Size (optional)</span>
<div className={styles.dimInputs}> <div className={styles.dimInputs}>
<label className={styles.dimField}> <label className={styles.dimField}>
<span>Width</span> <span>Width</span>
@@ -312,6 +466,11 @@ export function ChartBuilderModal() {
</ul> </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}> <div className={styles.actions}>
<button type="button" className={styles.action} onClick={() => void closeModal()}> <button type="button" className={styles.action} onClick={() => void closeModal()}>
Cancel Cancel
@@ -320,6 +479,7 @@ export function ChartBuilderModal() {
type="button" type="button"
className={`${styles.action} ${styles.primary}`} className={`${styles.action} ${styles.primary}`}
disabled={!valid} disabled={!valid}
aria-describedby={!valid ? 'cb-create-hint' : undefined}
onClick={() => runCreate()} onClick={() => runCreate()}
> >
Create Snippet Create Snippet
@@ -40,3 +40,11 @@
background: var(--layer-02); background: var(--layer-02);
color: var(--text); 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> { export interface SegmentedOption<T extends string> {
value: T; value: T;
label: string; 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> { interface SegmentedControlProps<T extends string> {
@@ -47,28 +56,38 @@ export function SegmentedControl<T extends string>({
}: SegmentedControlProps<T>) { }: SegmentedControlProps<T>) {
const refs = useRef<Array<HTMLButtonElement | null>>([]); const refs = useRef<Array<HTMLButtonElement | null>>([]);
/** Select the option at `index` (wrapping) and move focus to it (APG radio). */ /**
const selectAt = (index: number) => { * Select the first **enabled** option reached from `from` stepping by `dir`
const next = (index + options.length) % options.length; * (wrapping), and move focus to it (APG radio). Skips disabled options so arrow
onChange(options[next].value); * keys never land on an inert segment; a group with no enabled option is a no-op.
refs.current[next]?.focus(); */
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) => { const onKeyDown = (e: React.KeyboardEvent, index: number) => {
switch (e.key) { switch (e.key) {
case 'ArrowRight': case 'ArrowRight':
case 'ArrowDown': case 'ArrowDown':
selectAt(index + 1); move(index, 1);
break; break;
case 'ArrowLeft': case 'ArrowLeft':
case 'ArrowUp': case 'ArrowUp':
selectAt(index - 1); move(index, -1);
break; break;
case 'Home': case 'Home':
selectAt(0); move(-1, 1); // first enabled from the start
break; break;
case 'End': case 'End':
selectAt(options.length - 1); move(0, -1); // last enabled from the end
break; break;
default: default:
return; // not ours — let it bubble return; // not ours — let it bubble
@@ -93,9 +112,13 @@ export function SegmentedControl<T extends string>({
type="button" type="button"
role="radio" role="radio"
aria-checked={selected} aria-checked={selected}
// Roving tabindex: only the selected option is a tab stop; arrows aria-disabled={opt.disabled || undefined}
// move within the group. aria-label={opt.title}
tabIndex={selected ? 0 : -1} 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={[ className={[
styles.option, styles.option,
optionClassName, optionClassName,
@@ -104,7 +127,9 @@ export function SegmentedControl<T extends string>({
] ]
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
onClick={() => onChange(opt.value)} onClick={() => {
if (!opt.disabled) onChange(opt.value);
}}
onKeyDown={(e) => onKeyDown(e, i)} onKeyDown={(e) => onKeyDown(e, i)}
> >
{opt.label} {opt.label}
+55 -1
View File
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, test } from 'vitest'; import { beforeEach, describe, expect, test } from 'vitest';
import { createDataset } from '@core/dataset'; import { createDataset } from '@core/dataset';
import { useChartBuilderStore } from './ChartBuilderStore'; import { COUNT_FIELD, useChartBuilderStore } from './ChartBuilderStore';
import { useDatasetStore } from './DatasetStore'; import { useDatasetStore } from './DatasetStore';
import { useSnippetStore } from './SnippetStore'; 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', () => { describe('createSnippet', () => {
test('builds a linked snippet, activates it, and resets the builder', () => { test('builds a linked snippet, activates it, and resets the builder', () => {
const id = seedDataset('Sales', [ const id = seedDataset('Sales', [
Binary file not shown.
+214
View File
@@ -4,6 +4,12 @@ import {
validFieldTypes, validFieldTypes,
defaultMark, defaultMark,
isChannelTypeAllowed, isChannelTypeAllowed,
supportsAggregate,
supportsBin,
supportsTimeUnit,
supportsSort,
supportsStack,
sortableCategoryChannel,
builderWarnings, builderWarnings,
defaultBuilderConfig, defaultBuilderConfig,
isBuilderConfigValid, isBuilderConfigValid,
@@ -12,6 +18,7 @@ import {
generateChartName, generateChartName,
type BuilderColumns, type BuilderColumns,
type BuilderConfig, type BuilderConfig,
type ChannelMapping,
} from './chart-builder'; } from './chart-builder';
import { VEGA_LITE_SCHEMA_URL } from './snippet'; import { VEGA_LITE_SCHEMA_URL } from './snippet';
@@ -143,6 +150,79 @@ describe('builderWarnings (Tier B advisories)', () => {
}); });
expect(w).toEqual([]); 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', () => { 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', () => { describe('buildSnippetSpecText', () => {
it('produces pretty-printed JSON that parses back to the spec', () => { it('produces pretty-printed JSON that parses back to the spec', () => {
const config = defaultBuilderConfig('Sales', columns); const config = defaultBuilderConfig('Sales', columns);
+248 -38
View File
@@ -2,17 +2,24 @@
* Chart Builder — pure Vega-Lite spec assembler (spec §06). * Chart Builder — pure Vega-Lite spec assembler (spec §06).
* *
* Portable core: no browser APIs, no React, no store access. Turns a no-JSON * Portable core: no browser APIs, no React, no store access. Turns a no-JSON
* builder configuration (a mark, four optional encoding channels mapped to * builder configuration (a mark, four optional encoding channels mapped to dataset
* dataset columns, optional pixel dimensions) into a complete Vega-Lite spec that * columns, with per-channel transforms, plus chart-level sort/stack and optional
* references the source dataset by name. The UI layer owns the controls; this * pixel dimensions) into a complete Vega-Lite spec that references the source
* module owns the spec grammar — what a configuration *means* as Vega-Lite — and * dataset by name. The UI layer owns the controls; this module owns the spec
* the defaults the spec prescribes (pre-population, field-type derivation). * 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 * Beyond the Tier-B floor, a channel may carry a **transform** — an `aggregate`
* `$schema` stamp (shared with the sample template), a named-data reference the * (count/sum/mean/…), a quantitative `bin`, or a temporal `timeUnit` granularity —
* renderer resolves at preview time (rendering.ts), a mark with tooltips enabled, * and the chart may carry a `sort` (rank a categorical axis by its measure) and a
* the mapped encodings, and any explicit width/height. It is the same string-spec * `stack` (part-to-whole for bar/area + colour). See
* shape the editor and preview consume — `buildSnippetSpecText` serializes it. * 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'; 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]; export type ChannelName = (typeof CHANNELS)[number];
/** /**
* One channel's mapping: a dataset column `field` plus its `type`. A channel left * Aggregation operators a channel may apply (Vega-Lite `aggregate`). `count` is
* on "None" is represented by `null` in the config (omitted from the spec). * 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 { export interface ChannelMapping {
field: string; /** The dataset column. Omitted only when `aggregate === 'count'`. */
field?: string;
/** The Vega-Lite field type (see `validFieldTypes`). */
type: FieldType; 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. */ /** The full builder configuration the assembler consumes. */
@@ -51,6 +102,10 @@ export interface BuilderConfig {
width?: number; width?: number;
/** Optional explicit chart height in pixels. */ /** Optional explicit chart height in pixels. */
height?: number; 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; 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 * 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 * 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 * 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 * 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 * with its derived field type; Color and Size start unmapped, no transforms. The
* **smart default** for the resulting X/Y shape (`defaultMark`) rather than always * mark is the **smart default** for the resulting X/Y shape (`defaultMark`) rather
* Bar — a date-vs-number dataset opens as a Line, two measures as a Point — so the * than always Bar — a date-vs-number dataset opens as a Line, two measures as a
* first preview is already the conventional chart. A dataset with no detected * Point — so the first preview is already the conventional chart. A dataset with no
* columns yields an all-unmapped config (the modal then prompts / disables Create). * detected columns yields an all-unmapped config (the modal then prompts).
*/ */
export function defaultBuilderConfig(datasetName: string, columns: BuilderColumns): BuilderConfig { export function defaultBuilderConfig(datasetName: string, columns: BuilderColumns): BuilderConfig {
const encodings: Partial<Record<ChannelName, ChannelMapping | null>> = { const encodings: Partial<Record<ChannelName, ChannelMapping | null>> = {
@@ -177,7 +247,7 @@ export function defaultBuilderConfig(datasetName: string, columns: BuilderColumn
return { datasetName, mark, encodings }; 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]> { function mappedChannels(config: BuilderConfig): Array<[ChannelName, ChannelMapping]> {
return CHANNELS.flatMap((channel) => { return CHANNELS.flatMap((channel) => {
const mapping = config.encodings[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 * 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 * least one channel must be mapped. The modal gates the Create action and the
* and the preview prompt on this. * preview prompt on this.
*/ */
export function isBuilderConfigValid(config: BuilderConfig): boolean { export function isBuilderConfigValid(config: BuilderConfig): boolean {
return mappedChannels(config).length > 0; 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). */ /** A non-blocking advisory about a configuration (spec §06 → Tier B warnings). */
export interface BuilderWarning { export interface BuilderWarning {
/** The channel the hint is about, when it's channel-specific. */ /** The channel the hint is about, when it's channel-specific. */
@@ -202,14 +327,27 @@ export interface BuilderWarning {
message: string; 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 * 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 * 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 * (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. * 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. * 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 warnings: BuilderWarning[] = [];
const x = config.encodings.x ?? null; const x = config.encodings.x ?? null;
const y = config.encodings.y ?? null; const y = config.encodings.y ?? null;
@@ -229,11 +367,11 @@ export function builderWarnings(config: BuilderConfig): BuilderWarning[] {
(mark === 'bar' || mark === 'line' || mark === 'area') && (mark === 'bar' || mark === 'line' || mark === 'area') &&
x !== null && x !== null &&
y !== null && y !== null &&
!isContinuous(x.type) && !isMeasureMapping(x) &&
!isContinuous(y.type) !isMeasureMapping(y)
) { ) {
warnings.push({ 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 ( if (
x !== null && x !== null &&
y !== null && y !== null &&
x.type === 'quantitative' && effectiveType(x) === 'quantitative' &&
y.type === 'quantitative' && effectiveType(y) === 'quantitative' &&
mark !== 'point' && mark !== 'point' &&
mark !== 'circle' 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; return warnings;
} }
/** A built Vega-Lite spec, as a plain object (serialize with `buildSnippetSpecText`). */ /** A built Vega-Lite spec, as a plain object (serialize with `buildSnippetSpecText`). */
export type ChartSpec = Record<string, unknown>; 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 → * Assemble the complete Vega-Lite spec from a builder configuration (spec §06 →
* Output). Includes the schema reference, a named data reference to the dataset, * Output). Includes the schema reference, a named data reference to the dataset,
* the mark with tooltips enabled, every mapped encoding (field + field type), and * the mark with tooltips enabled, every mapped encoding (field, type, and any
* any explicit width/height. Unmapped channels are omitted; if nothing is mapped * aggregate/bin/timeUnit transform), chart-level sort (rank a categorical axis by
* the `encoding` block is omitted entirely (validation prevents saving that, but * its measure) and stack (part-to-whole), and any explicit width/height. Unmapped
* the live preview may render a bare mark while the user is still configuring). * 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 { export function buildChartSpec(config: BuilderConfig): ChartSpec {
const spec: ChartSpec = { const spec: ChartSpec = {
@@ -283,10 +468,27 @@ export function buildChartSpec(config: BuilderConfig): ChartSpec {
mark: { type: config.mark, tooltip: true }, 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)) { 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 (Object.keys(encoding).length > 0) spec.encoding = encoding;
if (config.width !== undefined) spec.width = config.width; 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); 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 for the created snippet (spec §06 → Output:
* "an auto-generated descriptive name"). When both X and Y are mapped it reads * "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 <y> by <x>" (using each channel's measure phrase, e.g. "count" or
* "Bar chart of <dataset>". Deterministic — no timestamp — so the name describes * "sum of revenue"); otherwise it falls back to naming the dataset. Deterministic —
* the chart, not when it was made. * no timestamp — so the name describes the chart, not when it was made.
*/ */
export function generateChartName(config: BuilderConfig): string { export function generateChartName(config: BuilderConfig): string {
const mark = markLabel(config.mark); const mark = markLabel(config.mark);
const x = config.encodings.x; const x = config.encodings.x;
const y = config.encodings.y; 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]; 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}`; return `${mark} chart of ${config.datasetName}`;
} }
+4
View File
@@ -84,6 +84,10 @@ export function profileData(
if (columns.length === 0) return naProfile(size); if (columns.length === 0) return naProfile(size);
const sample = sampleRows(rows); 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) => ({ const columnTypes = columns.map((name) => ({
name, name,
type: inferColumnType(sample.map((r) => r[name])), type: inferColumnType(sample.map((r) => r[name])),