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