From af9ee1e4c08dabd03d9b173cadc8368bb5e2192b Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Sat, 6 Jun 2026 18:04:24 +0300 Subject: [PATCH] Add aggregation, binning, granularity, sort, and stacking to the Chart Builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- docs/IMPLEMENTATION-PLAN.md | 5 + docs/architecture/01-state-and-stores.md | 20 ++ .../05-rendering-theming-preview.md | 12 + docs/chart-builder-research.md | 68 +++++ docs/spec/06-chart-builder.md | 26 +- .../components/ChartBuilderModal.module.css | 77 ++++- src/app/components/ChartBuilderModal.tsx | 274 +++++++++++++---- .../components/SegmentedControl.module.css | 8 + src/app/components/SegmentedControl.tsx | 51 +++- src/app/stores/ChartBuilderStore.test.ts | 56 +++- src/app/stores/ChartBuilderStore.ts | Bin 6902 -> 10496 bytes src/core/chart-builder.test.ts | 214 +++++++++++++ src/core/chart-builder.ts | 286 +++++++++++++++--- src/core/profile.ts | 4 + 14 files changed, 982 insertions(+), 119 deletions(-) diff --git a/docs/IMPLEMENTATION-PLAN.md b/docs/IMPLEMENTATION-PLAN.md index 9c0c5de..b130eaa 100644 --- a/docs/IMPLEMENTATION-PLAN.md +++ b/docs/IMPLEMENTATION-PLAN.md @@ -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) + diff --git a/docs/architecture/01-state-and-stores.md b/docs/architecture/01-state-and-stores.md index 5afa193..ae5f695 100644 --- a/docs/architecture/01-state-and-stores.md +++ b/docs/architecture/01-state-and-stores.md @@ -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 — diff --git a/docs/architecture/05-rendering-theming-preview.md b/docs/architecture/05-rendering-theming-preview.md index 3e5fd7d..b5b6a37 100644 --- a/docs/architecture/05-rendering-theming-preview.md +++ b/docs/architecture/05-rendering-theming-preview.md @@ -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) diff --git a/docs/chart-builder-research.md b/docs/chart-builder-research.md index 58099b0..f6a3813 100644 --- a/docs/chart-builder-research.md +++ b/docs/chart-builder-research.md @@ -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 diff --git a/docs/spec/06-chart-builder.md b/docs/spec/06-chart-builder.md index 5fc532d..455c4cb 100644 --- a/docs/spec/06-chart-builder.md +++ b/docs/spec/06-chart-builder.md @@ -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 diff --git a/src/app/components/ChartBuilderModal.module.css b/src/app/components/ChartBuilderModal.module.css index b752e8e..1be91a0 100644 --- a/src/app/components/ChartBuilderModal.module.css +++ b/src/app/components/ChartBuilderModal.module.css @@ -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; diff --git a/src/app/components/ChartBuilderModal.tsx b/src/app/components/ChartBuilderModal.tsx index f5eda51..7c0beef 100644 --- a/src/app/components/ChartBuilderModal.tsx +++ b/src/app/components/ChartBuilderModal.tsx @@ -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 = { 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 = { + 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 = { + year: 'Year', + yearquarter: 'Year-Quarter', + yearmonth: 'Year-Month', + yearmonthdate: 'Year-Month-Day', + quarter: 'Quarter', + month: 'Month', + week: 'Week', + date: 'Day of month', + day: 'Day of week', + hours: 'Hour', +}; + +/** A compact type indicator for a column option (# / date / bool / text). */ function typeBadge(type: ColumnType): string { switch (type) { case 'number': @@ -78,62 +119,145 @@ function columnAllowedOnChannel(channel: ChannelName, colType: ColumnType): bool return isChannelTypeAllowed(channel, defaultFieldType(colType)); } -function ChannelRow({ channel }: { channel: ChannelName }) { +/** True when the mapping is the field-less Count-of-records measure. */ +function isCount(mapping: ChannelMapping | null): boolean { + return !!mapping && mapping.aggregate === 'count' && mapping.field === undefined; +} + +function ChannelBlock({ channel }: { channel: ChannelName }) { const columns = useChartBuilderStore((s) => s.columns); const mapping = useChartBuilderStore((s) => s.config.encodings[channel] ?? null); const setChannelColumn = useChartBuilderStore((s) => s.setChannelColumn); const setChannelType = useChartBuilderStore((s) => s.setChannelType); + const setChannelAggregate = useChartBuilderStore((s) => s.setChannelAggregate); + const setChannelBin = useChartBuilderStore((s) => s.setChannelBin); + const setChannelTimeUnit = useChartBuilderStore((s) => s.setChannelTimeUnit); const colTypeOf = (name: string): ColumnType => columns.columnTypes.find((c) => c.name === name)?.type ?? 'string'; - // Type options valid for this column AND allowed on this channel (e.g. Size hides - // Nominal). Shown only when >1 option and a column is selected (spec §06). - const typeOptions: FieldType[] = mapping - ? validFieldTypes(colTypeOf(mapping.field)).filter((t) => isChannelTypeAllowed(channel, t)) - : []; + // The fixed N|O|Q|T control: a column's invalid types and types disallowed on this + // channel (e.g. a category on Size) are disabled, never hidden, so the control keeps + // one shape on every channel (APG radio with disabled options). + const typeSegments: ReadonlyArray> = 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 ( -
- - - - {mapping && typeOptions.length > 1 && ( +
+
+ {CHANNEL_LABELS[channel]} +
+ + {mapping && !isCount(mapping) && ( +
+ setChannelType(channel, t)} + className={styles.typeSeg} + /> + + {supportsAggregate(mapping.type) && ( + + )} + + {supportsBin(mapping.type) && ( + + )} + + {supportsTimeUnit(mapping.type) && ( + + )} +
)}
); } +/** Sort control values: 'none' maps to an unsorted config. */ +const SORT_OPTIONS: ReadonlyArray> = [ + { value: 'none', label: 'None' }, + { value: 'ascending', label: 'Asc' }, + { value: 'descending', label: 'Desc' }, +]; + +const STACK_OPTIONS: ReadonlyArray> = [ + { value: 'zero', label: 'Stacked' }, + { value: 'normalize', label: '100%' }, +]; + function BuilderPreview() { const hostRef = useRef(null); const handleRef = useRef(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

No dataset loaded. Open this from a dataset in Datasets.

; } - /** 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() {
{CHANNELS.map((channel) => ( - + ))} + {(canSort || canStack) && ( +
+ {canSort && ( +
+ Sort + setSort(v === 'none' ? undefined : v)} + /> +
+ )} + {canStack && ( +
+ Stacking + +
+ )} +
+ )} +
- Dimensions (optional) + Size (optional)