mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
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:
@@ -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) +
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,33 +119,52 @@ 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>
|
||||
<div className={styles.channel}>
|
||||
<div className={styles.channelTop}>
|
||||
<span className={styles.channelLabel}>{CHANNEL_LABELS[channel]}</span>
|
||||
<select
|
||||
id={`ch-${channel}`}
|
||||
className={styles.select}
|
||||
value={mapping?.field ?? ''}
|
||||
aria-label={`${CHANNEL_LABELS[channel]} column`}
|
||||
value={selectValue}
|
||||
onChange={(e) => setChannelColumn(channel, e.target.value === '' ? null : e.target.value)}
|
||||
>
|
||||
<option value="">None</option>
|
||||
<option value={COUNT_FIELD}>Count of records</option>
|
||||
{columns.columns.map((name) => {
|
||||
const allowed = columnAllowedOnChannel(channel, colTypeOf(name));
|
||||
return (
|
||||
@@ -115,25 +175,89 @@ function ChannelRow({ channel }: { channel: ChannelName }) {
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{mapping && typeOptions.length > 1 && (
|
||||
<select
|
||||
className={styles.typeSelect}
|
||||
aria-label={`${CHANNEL_LABELS[channel]} field type`}
|
||||
{mapping && !isCount(mapping) && (
|
||||
<div className={styles.channelControls}>
|
||||
<SegmentedControl
|
||||
label={`${CHANNEL_LABELS[channel]} field type`}
|
||||
options={typeSegments}
|
||||
value={mapping.type}
|
||||
onChange={(e) => setChannelType(channel, e.target.value as FieldType)}
|
||||
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,
|
||||
)
|
||||
}
|
||||
>
|
||||
{typeOptions.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{titleCase(t)}
|
||||
<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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.
@@ -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
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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])),
|
||||
|
||||
Reference in New Issue
Block a user