mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart builder: intent front door, Heatmap mark, role-aware guidance
This commit is contained in:
@@ -134,9 +134,19 @@ Review all changes in scope. If changes span multiple patterns below, apply all
|
|||||||
case, active voice, no "please", no exclamation marks in errors). If/when an i18n layer
|
case, active voice, no "please", no exclamation marks in errors). If/when an i18n layer
|
||||||
exists, route strings through it instead of hardcoding.
|
exists, route strings through it instead of hardcoding.
|
||||||
|
|
||||||
|
15. **Chart-builder guidance reasons over role, not raw type** (`src/core/chart-builder.ts`):
|
||||||
|
a `builderWarnings` rule (or any measure/dimension decision) must ask the post-transform
|
||||||
|
**role** via the shared predicates (`isMeasureMapping`, `isReorderableCategory`) — never
|
||||||
|
test `effectiveType(m) === 'quantitative'` directly for measure-ness. `bin` makes a field a
|
||||||
|
discretized _dimension_ (mirrors Vega-Lite's `isDiscrete`); `aggregate` makes it a _measure_.
|
||||||
|
Reasoning over raw type is what made a histogram trip the two-measures→scatter nudge
|
||||||
|
(eng-council 2026-06-13; arch 10 §5). A new taste-heuristic warning should also be
|
||||||
|
high-precision: prefer structural/data-driven hints; lean on the intent front door + smart
|
||||||
|
defaults for positive guidance rather than enumerating bad combinations.
|
||||||
|
|
||||||
### Output
|
### Output
|
||||||
|
|
||||||
15. **Summary**: respond with a summary of changes — choices made due to these instructions,
|
16. **Summary**: respond with a summary of changes — choices made due to these instructions,
|
||||||
choices where multiple approaches existed, and non-obvious architectural assumptions the
|
choices where multiple approaches existed, and non-obvious architectural assumptions the
|
||||||
user should know but might not spot in the diff. If the summary mentions an observation you
|
user should know but might not spot in the diff. If the summary mentions an observation you
|
||||||
chose not to fix (rule #8), confirm a `// TODO:` breadcrumb was placed at the code site.
|
chose not to fix (rule #8), confirm a `// TODO:` breadcrumb was placed at the code site.
|
||||||
|
|||||||
@@ -504,6 +504,27 @@ _acts_ rather than navigates is mis-dressed: such actions are **ghost buttons**
|
|||||||
verb-first labels (Carbon links-vs-buttons; "Use a constant"). _(Consulted via /council →
|
verb-first labels (Carbon links-vs-buttons; "Use a constant"). _(Consulted via /council →
|
||||||
NN/g #1/#3/#4, Carbon button/link usage. This bullet is the contract.)_
|
NN/g #1/#3/#4, Carbon button/link usage. This bullet is the contract.)_
|
||||||
|
|
||||||
|
**Resolved — the intent front door is an APG toolbar of toggle chips (Tier C "do it for me").**
|
||||||
|
The Chart Builder's _"What do you want to show?"_ strip (spec §06 → Intent) is a **WAI-ARIA
|
||||||
|
`toolbar`** (one tab stop, roving tabindex, `aria-labelledby` the visible heading) of chips —
|
||||||
|
**not** seven independently-tabbable buttons (the pane-toggle precedent above). Each chip is a
|
||||||
|
**toggle button** (`aria-pressed`) whose pressed state is **derived from the configuration**
|
||||||
|
(the chip whose recommended layout the live chart matches), never stored — so a hand-edit
|
||||||
|
resolves to "Custom" (none pressed) for free. Arrow keys **move focus only**; **Enter/Space
|
||||||
|
applies** — because applying an intent reshapes the whole chart, a radiogroup's select-on-arrow
|
||||||
|
would do that on every keypress (so this is a toolbar, not a `SegmentedControl` radiogroup).
|
||||||
|
Selection shows as an **accent ring, never an accent fill** (fill stays the primary-action
|
||||||
|
signal — arch 09 §3.3). Intents the dataset can't satisfy are **disabled via `aria-disabled`
|
||||||
|
and kept arrow-reachable** (APG: focusable disabled controls "where discoverability of a
|
||||||
|
function is crucial"), with the reason in the chip's accessible name (`aria-label`
|
||||||
|
"Correlation — needs two number columns") plus a `title` for sighted hover — **never hidden**
|
||||||
|
(Tableau _Show Me_). The chips are framed as **intents, not chart shapes** (FT Visual
|
||||||
|
Vocabulary / Datawrapper organize by intent); _Heatmap_ is the one chart-type label retained
|
||||||
|
— a deliberate divergence for recognizability that also mirrors the mark selector's _Heatmap_
|
||||||
|
label, so the intent and the mark read as one thing (revisit if it confuses). _(Consulted via /council →
|
||||||
|
APG toolbar + button(toggle); FT Visual Vocabulary / Datawrapper intent framing; NN/g #6
|
||||||
|
recognition. This bullet is the contract.)_
|
||||||
|
|
||||||
**Resolved — an error names the right fix, not a boilerplate one.** Don't staple a generic
|
**Resolved — an error names the right fix, not a boilerplate one.** Don't staple a generic
|
||||||
remedy onto every failure. A missing dataset reference is **not** a JSON/spec syntax problem,
|
remedy onto every failure. A missing dataset reference is **not** a JSON/spec syntax problem,
|
||||||
so the preview gives it a tailored, fixable line — _"Dataset «X» not found. Create it from
|
so the preview gives it a tailored, fixable line — _"Dataset «X» not found. Create it from
|
||||||
|
|||||||
@@ -25,6 +25,49 @@
|
|||||||
Newest first. The at-a-glance build-order tracker is §4; per-item detail is §3. This log is
|
Newest first. The at-a-glance build-order tracker is §4; per-item detail is §3. This log is
|
||||||
the quick "where are we" — read it first.
|
the quick "where are we" — read it first.
|
||||||
|
|
||||||
|
- **2026-06-13 (guidance: reason over role, not raw type)** — closed a **false-positive class**
|
||||||
|
in `builderWarnings` (eng-council + council consult). A histogram (bar, binned-Q X, count Y)
|
||||||
|
tripped "two measures → scatter" because `isMeasureMapping`/`effectiveType` ignored `bin`.
|
||||||
|
Fix: `isMeasureMapping` is now **bin-aware** (a binned field is a discretized dimension —
|
||||||
|
mirrors Vega-Lite's own `isDiscrete(fieldDef)`); a new `isReorderableCategory` keeps Sort
|
||||||
|
from being offered on a histogram's binned axis (the council-flagged regression); the scatter
|
||||||
|
rule routes through the role predicate + a positive mark list (deletes the `mark !== 'rect'`
|
||||||
|
bolt-on); `stackMeasureChannel` excludes binned. Pinned with regression tests (no
|
||||||
|
scatter/Sort on a histogram; real bar still sorts; two raw measures still nudge). Promoted to
|
||||||
|
**/alignment check #15** ("reason over role, not raw type"). **Not done (deliberately):**
|
||||||
|
intent-gating the taste warnings — verified no current intent layout trips one, so the gate
|
||||||
|
would be dead code; recorded as a standing principle in `builderWarnings` instead. The
|
||||||
|
broader posture (curate good via the front door > enumerate bad via warnings) is the council's
|
||||||
|
recommended bottom for the combinatorial-warning worry. Verified: typecheck + test (913) +
|
||||||
|
eslint + build.
|
||||||
|
|
||||||
|
- **2026-06-13 (3A + heatmap mark)** — **the intent-first front door shipped (Tier C), and
|
||||||
|
the mark set gained `rect` (Heatmap).**
|
||||||
|
- **Heatmap mark** — `rect` added to `MARK_TYPES` (six marks; labelled **Heatmap** in the
|
||||||
|
picker, which now wraps to two rows in the 320–360px pane). Guidance rewired: both-axes
|
||||||
|
rule covers heatmaps; a new hint nudges a Colour measure on a two-axis heatmap with a
|
||||||
|
one-click **Colour by count**; `rect` is exempt from the two-measures→scatter nudge (a
|
||||||
|
binned 2-D histogram is valid). Never the auto-default mark; names read "Heatmap of …".
|
||||||
|
Spec §06 mark-type + guidance updated.
|
||||||
|
- **3A intent front door** — a **persistent strip** at the top of the config pane (the
|
||||||
|
interaction model chosen with the user over a replace-on-open screen — "shows all
|
||||||
|
controls + a do-it-for-me", the Tableau _Show Me_ parallel). Core (`chart-builder.ts`):
|
||||||
|
`CHART_INTENTS` (Compare/Ranking/Change-over-time/Correlation/Distribution/Part-to-whole/
|
||||||
|
Heatmap), `intentLayout` (intent × column-roles → mark + channels), `intentApplicable`
|
||||||
|
(Show-Me gating), `applyIntent` (reshape, keep dataset/transforms/title), and
|
||||||
|
`activeIntent` (structural match → the live chart's intent highlights with **no stored
|
||||||
|
state**; lights the smart default on open). Store: `setIntent`. UI: an APG **toolbar** of
|
||||||
|
toggle chips (roving tabindex, arrows move / Enter applies, accent-ring selection,
|
||||||
|
`aria-disabled`+reason on inapplicable intents).
|
||||||
|
- **Council** run on the front-door copy/flow → toolbar (not radiogroup — select-on-arrow
|
||||||
|
would reshape the chart), disabled-focusable-with-reason, intent-framing (Heatmap kept as
|
||||||
|
the one chart-type label, mirroring the mark). Recorded in `architecture/10` §5; spec §06
|
||||||
|
gained an **Intent (the front door)** subsection.
|
||||||
|
- **Verified:** `typecheck` + `test` (909, +17: intent core/store, heatmap guidance/naming)
|
||||||
|
- `eslint` + `build`. **Owed:** a visual pass on the live strip (chips, disabled states,
|
||||||
|
keyboard) + a heatmap rendered from real data. **Open (user's call):** whether the
|
||||||
|
_Heatmap_ chip should read as an intent phrase instead (council's intent-framing point).
|
||||||
|
|
||||||
- **2026-06-12 (3D)** — **entry points & discoverability shipped. Up next: 3A (the
|
- **2026-06-12 (3D)** — **entry points & discoverability shipped. Up next: 3A (the
|
||||||
front door now has somewhere to be found).**
|
front door now has somewhere to be found).**
|
||||||
- **Library creation surface forked:** primary **Build Chart** (accent, takes the
|
- **Library creation surface forked:** primary **Build Chart** (accent, takes the
|
||||||
@@ -557,9 +600,10 @@ Phase 1 1A actionable hints ✓ done
|
|||||||
Phase 2 2A value-or-field channels (Property model) ✓ done
|
Phase 2 2A value-or-field channels (Property model) ✓ done
|
||||||
2B field shelf + in-place type cycling ✓ done (field-first + on-chart shelves)
|
2B field shelf + in-place type cycling ✓ done (field-first + on-chart shelves)
|
||||||
Phase 3 3D entry points & discoverability ✓ done (2026-06-12)
|
Phase 3 3D entry points & discoverability ✓ done (2026-06-12)
|
||||||
3A intent-first front door (Tier C) ← next; built on 2B; the defining feature
|
3A intent-first front door (Tier C) ✓ done (2026-06-13); persistent strip
|
||||||
3B starter examples
|
3B starter examples ← next; pairs with 3C
|
||||||
3C open in builder (strict hydration) ← added 2026-06-11; pairs with 3B
|
3C open in builder (strict hydration) ← added 2026-06-11; pairs with 3B
|
||||||
|
Marks +rect (Heatmap) ✓ done (2026-06-13)
|
||||||
Phase 4 (gated) theta/facets/styling-overrides/undo/lookup — decide after Phase 3
|
Phase 4 (gated) theta/facets/styling-overrides/undo/lookup — decide after Phase 3
|
||||||
|
|
||||||
Also shipped (builder UX/perf, from dogfooding): near-fullscreen modal, internal-scroll
|
Also shipped (builder UX/perf, from dogfooding): near-fullscreen modal, internal-scroll
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
The Chart Builder is a visual, no-JSON way to compose a Vega-Lite chart from a selected dataset. The user picks a mark type and maps the dataset's columns to encoding channels; the builder produces a complete Vega-Lite spec and saves it as a new snippet that references the dataset. It is intended for users who want to start a chart quickly without hand-writing JSON in the _Spec Editor & Draft/Published Workflow_.
|
The Chart Builder is a visual, no-JSON way to compose a Vega-Lite chart from a selected dataset. The user picks a mark type and maps the dataset's columns to encoding channels; the builder produces a complete Vega-Lite spec and saves it as a new snippet that references the dataset. It is intended for users who want to start a chart quickly without hand-writing JSON in the _Spec Editor & Draft/Published Workflow_.
|
||||||
|
|
||||||
> **Design level — "smart + guarded" (Tier B).** The builder is field-first — the user works from a shelf of the dataset's columns and drops them onto encoding channels — and stays within the inputs below, but it is not a dumb composer: it picks a sensible default mark for the data shape, offers only field types valid for each column, keeps unsuitable channel mappings out of reach, and surfaces non-blocking guidance for encodings that render poorly. These behaviors are derived from cross-source chart-choice research recorded in [`docs/chart-builder-research.md`](../chart-builder-research.md) (the convergence of Draco, Voyager, the FT Visual Vocabulary, and Datawrapper). The richer "intent-first" front door (ask _what do you want to show?_ and recommend a chart) is explicitly out of scope for now and noted there as a future tier.
|
> **Design level — "smart + guarded" (Tier B).** The builder is field-first — the user works from a shelf of the dataset's columns and drops them onto encoding channels — and stays within the inputs below, but it is not a dumb composer: it picks a sensible default mark for the data shape, offers only field types valid for each column, keeps unsuitable channel mappings out of reach, and surfaces non-blocking guidance for encodings that render poorly. These behaviors are derived from cross-source chart-choice research recorded in [`docs/chart-builder-research.md`](../chart-builder-research.md) (the convergence of Draco, Voyager, the FT Visual Vocabulary, and Datawrapper). On top of this "smart + guarded" base sits the **intent-first front door** (Tier C — _what do you want to show?_, see _Intent_ below): an on-ramp that recommends a whole chart from the user's stated intent, without replacing the mark-first builder beneath it.
|
||||||
|
|
||||||
## Opening
|
## Opening
|
||||||
|
|
||||||
@@ -25,6 +25,17 @@ Which data the chart builds from is itself a builder choice. A **Dataset** picke
|
|||||||
|
|
||||||
With an empty dataset library the builder shows a **no-datasets state** instead of controls: it says what the builder does and offers one primary next step — **Add a dataset**, which opens the _Datasets_ manager on its create form. The guided path never dead-ends.
|
With an empty dataset library the builder shows a **no-datasets state** instead of controls: it says what the builder does and offers one primary next step — **Add a dataset**, which opens the _Datasets_ manager on its create form. The guided path never dead-ends.
|
||||||
|
|
||||||
|
### Intent (the front door)
|
||||||
|
|
||||||
|
A persistent **"What do you want to show?"** strip sits at the top of the configuration pane, under the dataset picker — the builder's guided on-ramp (Tier C). It offers a small set of analytic **intents**, each of which, when chosen, **sets the whole chart up for you** ("do it for me", modelled on Tableau's _Show Me_):
|
||||||
|
|
||||||
|
- The intents map the FT Visual Vocabulary / Datawrapper taxonomy onto the builder's marks and channels: **Compare** (magnitude across categories → a bar of counts), **Ranking** (the same, sorted by value), **Change over time** (a line of the first measure over a date), **Correlation** (a scatter of two measures), **Distribution** (a histogram — a binned measure vs count), **Part-to-whole** (a stacked bar split by a second category), and **Heatmap** (a two-category grid shaded by count).
|
||||||
|
- Picking an intent reshapes the chart — its **mark, encodings, and sort/stack** — to that intent's recommended layout, derived from the dataset's column roles. It **keeps** the dataset, the data transforms (filters / calculated fields), and the chart properties (title/subtitle/size): those are orthogonal to _what kind of chart_.
|
||||||
|
- The strip is an **on-ramp, not a gate**: it seeds the mark-first builder, which the user can then adjust freely or ignore entirely (and can always drop to Monaco). The chosen intent is builder-local steering — it **never enters the produced spec** (the JSON stays the document).
|
||||||
|
- On open, the strip **pre-highlights the intent matching the data-aware default** (so a category-vs-count default opens on _Compare_). The highlight is **derived from the configuration, not stored**: the chip whose recommended layout the live chart currently matches stays highlighted; once the user edits away from any recommended layout, none is highlighted — a **Custom** chart.
|
||||||
|
- Intents the dataset **cannot satisfy** are **disabled** (Tableau _Show Me_): a _Correlation_ needs two number columns, a _Heatmap_ or _Part-to-whole_ needs two category columns, _Change over time_ needs a date, and so on. A disabled chip stays perceivable and carries the reason in its accessible name; it is never hidden.
|
||||||
|
- Keyboard/focus follow [`architecture/10`](../architecture/10-interaction-and-feedback.md) §5 (an APG **toolbar**: one tab stop, a roving tabindex, arrow keys move focus, Enter/Space applies the intent — so navigation never reshapes the chart by accident).
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
A two-pane modal:
|
A two-pane modal:
|
||||||
@@ -68,8 +79,8 @@ The section is ordered **input → shaping** so the distinction reads at a glanc
|
|||||||
|
|
||||||
### Mark type
|
### Mark type
|
||||||
|
|
||||||
- Single selection from an exact set of five mark types: **Bar, Line, Point, Area, Circle**.
|
- Single selection from an exact set of six mark types: **Bar, Line, Point, Area, Circle, Heatmap**. **Heatmap** is the Vega-Lite `rect` mark — an X×Y grid of cells shaded by a Colour measure; it is labelled by the chart it makes rather than its geometry, since "Rect" is opaque to the no-JSON audience.
|
||||||
- On open, the mark **defaults to the type that best fits the pre-populated X/Y field-type shape** (Tier B smart default): a temporal axis against a measure → **Line**; two measures → **Point**; a category against a measure → **Bar**; two categories → **Point**; and **Bar** as the fallback when only one axis (or none) is mapped. The user can switch to any of the five afterward.
|
- On open, the mark **defaults to the type that best fits the pre-populated X/Y field-type shape** (Tier B smart default): a temporal axis against a measure → **Line**; two measures → **Point**; a category against a measure → **Bar**; two categories → **Point**; and **Bar** as the fallback when only one axis (or none) is mapped. **Heatmap is never the auto-default** — it reads only with a Colour measure, which the X/Y shape alone can't determine, so it stays a deliberate pick (guidance nudges the missing Colour). The user can switch to any of the six afterward.
|
||||||
- Exactly one mark type is active at any time; selecting one updates the preview.
|
- Exactly one mark type is active at any time; selecting one updates the preview.
|
||||||
|
|
||||||
### Encoding channels
|
### Encoding channels
|
||||||
@@ -113,9 +124,10 @@ These controls appear only when they apply:
|
|||||||
|
|
||||||
The builder surfaces short, plain-language hints for configurations that render but read poorly — advisory only, never blocking the **Create Snippet** action (validation below is the sole gate). A hint states the _problem_; where there is an obvious remedy, it also offers one or more **one-click fix** buttons that apply the change to the configuration (e.g. _Aggregate as Sum_, _Swap X/Y_, _Switch to Point_, _Stack_, _Remove colour_). A fix is an offer, never a forced change — applying it updates the config and the hint re-derives away. Interaction/accessibility of these actions follows [`architecture/10`](../architecture/10-interaction-and-feedback.md) §5 (polite announcement, focus moved off the removed button). These follow the chart-choice research ([`docs/chart-builder-research.md`](../chart-builder-research.md)) and include, for example:
|
The builder surfaces short, plain-language hints for configurations that render but read poorly — advisory only, never blocking the **Create Snippet** action (validation below is the sole gate). A hint states the _problem_; where there is an obvious remedy, it also offers one or more **one-click fix** buttons that apply the change to the configuration (e.g. _Aggregate as Sum_, _Swap X/Y_, _Switch to Point_, _Stack_, _Remove colour_). A fix is an offer, never a forced change — applying it updates the config and the hint re-derives away. Interaction/accessibility of these actions follows [`architecture/10`](../architecture/10-interaction-and-feedback.md) §5 (polite announcement, focus moved off the removed button). These follow the chart-choice research ([`docs/chart-builder-research.md`](../chart-builder-research.md)) and include, for example:
|
||||||
|
|
||||||
- A **Line** or **Area** mark with only one axis mapped (both axes are needed to draw it).
|
- A **Line**, **Area**, or **Heatmap** mark with only one axis mapped (both axes are needed to draw it).
|
||||||
- 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).
|
- A **Heatmap** with both axes mapped but **no measure on Colour** (its cells have nothing to shade) — offers a one-click _Colour by count_, the canonical cross-tab heatmap. _Two measures on a heatmap are exempt from the scatter nudge below: a binned 2-D histogram is two quantitative axes shaded by count._
|
||||||
|
- **Two measures** on a non-scatter mark (a scatter — Point/Circle — usually reads better; Heatmap excepted).
|
||||||
- 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 **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).
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,79 @@
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Intent front door (spec §06 → Intent) ───────────────────────────────
|
||||||
|
A persistent "what do you want to show?" strip under the dataset picker. A
|
||||||
|
quiet accent-soft wash marks it as the guided on-ramp without competing with
|
||||||
|
the primary Create action (--accent stays reserved for primary actions). */
|
||||||
|
.intentStrip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-4);
|
||||||
|
border: var(--border-width) solid var(--border);
|
||||||
|
background: var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intentQ {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intentSub {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intentChips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intentChip {
|
||||||
|
appearance: none;
|
||||||
|
height: var(--control-height);
|
||||||
|
border: var(--border-width) solid var(--border-strong);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0 var(--space-3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background var(--dur-fast) var(--ease),
|
||||||
|
box-shadow var(--dur-fast) var(--ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intentChip:hover {
|
||||||
|
background: var(--field);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The chip whose layout the live chart matches (council-reserved: selection uses an
|
||||||
|
accent ring, never an accent fill — fill stays the primary-action signal). */
|
||||||
|
.intentChip[aria-pressed='true'] {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Disabled = the dataset can't satisfy this intent: perceivable but inert (the chip's
|
||||||
|
accessible name carries the reason). */
|
||||||
|
.intentChip[aria-disabled='true'] {
|
||||||
|
color: var(--text-placeholder);
|
||||||
|
border-color: var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.intentChip:focus-visible {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Data section: filters, calculated fields, row preview (spec §06 → Data) ──── */
|
/* ── Data section: filters, calculated fields, row preview (spec §06 → Data) ──── */
|
||||||
|
|
||||||
.dataSection {
|
.dataSection {
|
||||||
@@ -346,6 +419,18 @@
|
|||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The mark picker carries six segments — too many for one 320–360px row. Override the
|
||||||
|
SegmentedControl's fixed single-row height so it wraps; each option keeps the control
|
||||||
|
height so the two rows read as even segments inside the shared border. */
|
||||||
|
.markPicker {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markPickerOption {
|
||||||
|
min-height: var(--control-height);
|
||||||
|
}
|
||||||
|
|
||||||
.fieldLabel {
|
.fieldLabel {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -475,4 +475,117 @@ describe('ChartBuilderModal', () => {
|
|||||||
// A colour picker renders for the constant.
|
// A colour picker renders for the constant.
|
||||||
expect(container.querySelector('input[type="color"]')).not.toBeNull();
|
expect(container.querySelector('input[type="color"]')).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The intent front door's core logic (which chip lights, what each applies, gating)
|
||||||
|
// is covered in core/store; these check only the parts that live in the component —
|
||||||
|
// the toolbar's roving tabindex and arrow navigation (a hand-rolled handler, not a
|
||||||
|
// shared primitive), and that a click reshapes the chart.
|
||||||
|
describe('intent front door (the "What do you want to show?" strip)', () => {
|
||||||
|
// Two categories + one date + two measures → every intent applies; enough shape to
|
||||||
|
// light a default and to exercise an applied intent (Heatmap needs two categories).
|
||||||
|
const seedSuperstore = () => {
|
||||||
|
const ds = createDataset({
|
||||||
|
name: 'Superstore',
|
||||||
|
data: [
|
||||||
|
{ region: 'E', segment: 'A', date: '2026-01-01', sales: 5, profit: 1 },
|
||||||
|
{ region: 'W', segment: 'B', date: '2026-02-01', sales: 9, profit: 3 },
|
||||||
|
],
|
||||||
|
format: 'json',
|
||||||
|
source: 'inline',
|
||||||
|
now: T,
|
||||||
|
});
|
||||||
|
useDatasetStore.getState().add(ds);
|
||||||
|
const id = useDatasetStore.getState().datasets[0].id;
|
||||||
|
useChartBuilderStore.getState().init(id);
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const chips = (): HTMLButtonElement[] =>
|
||||||
|
Array.from(container.querySelectorAll<HTMLButtonElement>('[role="toolbar"] button'));
|
||||||
|
|
||||||
|
test('renders one tab stop and lights the active intent (roving tabindex)', async () => {
|
||||||
|
seedSuperstore();
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<ChartBuilderModal />);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const all = chips();
|
||||||
|
expect(all.length).toBe(7); // every intent shows (Tableau Show Me: never hidden)
|
||||||
|
// Exactly one chip is in the tab order; the rest are roving (-1).
|
||||||
|
expect(all.filter((b) => b.tabIndex === 0)).toHaveLength(1);
|
||||||
|
// The smart default for a category+count shape is Compare, so its chip is pressed.
|
||||||
|
const pressed = all.filter((b) => b.getAttribute('aria-pressed') === 'true');
|
||||||
|
expect(pressed).toHaveLength(1);
|
||||||
|
expect(pressed[0].textContent).toBe('Compare');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an inapplicable intent is disabled and names its reason', async () => {
|
||||||
|
// One category, one measure, no date and no second measure → Correlation/Time/
|
||||||
|
// Heatmap/Part-to-whole cannot apply.
|
||||||
|
const ds = createDataset({
|
||||||
|
name: 'Thin',
|
||||||
|
data: [{ region: 'E', sales: 5 }],
|
||||||
|
format: 'json',
|
||||||
|
source: 'inline',
|
||||||
|
now: T,
|
||||||
|
});
|
||||||
|
useDatasetStore.getState().add(ds);
|
||||||
|
const id = useDatasetStore.getState().datasets[0].id;
|
||||||
|
useChartBuilderStore.getState().init(id);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<ChartBuilderModal />);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const correlation = chips().find((b) => b.textContent === 'Correlation')!;
|
||||||
|
expect(correlation.getAttribute('aria-disabled')).toBe('true');
|
||||||
|
expect(correlation.getAttribute('aria-label')).toMatch(/needs two number columns/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking an enabled chip reshapes the chart to that intent', async () => {
|
||||||
|
seedSuperstore();
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<ChartBuilderModal />);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const heatmap = chips().find((b) => b.textContent === 'Heatmap')!;
|
||||||
|
await act(async () => {
|
||||||
|
heatmap.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useChartBuilderStore.getState().config.mark).toBe('rect');
|
||||||
|
expect(useChartBuilderStore.getState().config.encodings.color).toEqual({
|
||||||
|
type: 'quantitative',
|
||||||
|
aggregate: 'count',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ArrowRight moves focus along the toolbar without applying (focus-only)', async () => {
|
||||||
|
seedSuperstore();
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<ChartBuilderModal />);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
const all = chips();
|
||||||
|
const start = all.findIndex((b) => b.tabIndex === 0);
|
||||||
|
const markBefore = useChartBuilderStore.getState().config.mark;
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
all[start].focus();
|
||||||
|
all[start].dispatchEvent(
|
||||||
|
new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true }),
|
||||||
|
);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Focus moved to the next chip; the chart is untouched (arrows navigate, Enter applies).
|
||||||
|
expect(document.activeElement).toBe(all[(start + 1) % all.length]);
|
||||||
|
expect(useChartBuilderStore.getState().config.mark).toBe(markBefore);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -23,14 +23,17 @@ import { useShallow } from 'zustand/react/shallow';
|
|||||||
import type { VisualizationSpec } from 'vega-embed';
|
import type { VisualizationSpec } from 'vega-embed';
|
||||||
import {
|
import {
|
||||||
CHANNELS,
|
CHANNELS,
|
||||||
|
CHART_INTENTS,
|
||||||
MARK_TYPES,
|
MARK_TYPES,
|
||||||
TIME_UNITS,
|
TIME_UNITS,
|
||||||
|
activeIntent,
|
||||||
builderWarnings,
|
builderWarnings,
|
||||||
channelAcceptsValue,
|
channelAcceptsValue,
|
||||||
defaultChannelValue,
|
defaultChannelValue,
|
||||||
defaultFieldType,
|
defaultFieldType,
|
||||||
effectiveColumns,
|
effectiveColumns,
|
||||||
filterOpArity,
|
filterOpArity,
|
||||||
|
intentApplicable,
|
||||||
isBuilderConfigValid,
|
isBuilderConfigValid,
|
||||||
isChannelTypeAllowed,
|
isChannelTypeAllowed,
|
||||||
isColumnAllowedOnChannel,
|
isColumnAllowedOnChannel,
|
||||||
@@ -50,6 +53,7 @@ import {
|
|||||||
type BuilderWarningFix,
|
type BuilderWarningFix,
|
||||||
type ChannelMapping,
|
type ChannelMapping,
|
||||||
type ChannelName,
|
type ChannelName,
|
||||||
|
type ChartIntent,
|
||||||
type FieldType,
|
type FieldType,
|
||||||
type FilterOp,
|
type FilterOp,
|
||||||
type MarkType,
|
type MarkType,
|
||||||
@@ -113,11 +117,46 @@ function titleCase(s: string): string {
|
|||||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** User-facing mark labels. All but `rect` are their geometry name title-cased; `rect`
|
||||||
|
* is shown as **Heatmap** — the chart it makes, since "Rect" is opaque to the no-JSON
|
||||||
|
* audience the builder serves (the spec's mark-set table notes the rect↔heatmap pairing). */
|
||||||
|
const MARK_LABELS: Record<MarkType, string> = {
|
||||||
|
bar: 'Bar',
|
||||||
|
line: 'Line',
|
||||||
|
point: 'Point',
|
||||||
|
area: 'Area',
|
||||||
|
circle: 'Circle',
|
||||||
|
rect: 'Heatmap',
|
||||||
|
};
|
||||||
|
|
||||||
const MARK_OPTIONS: ReadonlyArray<SegmentedOption<MarkType>> = MARK_TYPES.map((m) => ({
|
const MARK_OPTIONS: ReadonlyArray<SegmentedOption<MarkType>> = MARK_TYPES.map((m) => ({
|
||||||
value: m,
|
value: m,
|
||||||
label: titleCase(m),
|
label: MARK_LABELS[m],
|
||||||
|
title: m === 'rect' ? 'Heatmap (rect mark)' : undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/** Intent front-door copy (spec §06 → Intent). Label = the chip; needs = what the
|
||||||
|
* dataset must have for the intent to apply (shown when the chip is disabled). */
|
||||||
|
const INTENT_LABELS: Record<ChartIntent, string> = {
|
||||||
|
compare: 'Compare',
|
||||||
|
ranking: 'Ranking',
|
||||||
|
time: 'Change over time',
|
||||||
|
correlation: 'Correlation',
|
||||||
|
distribution: 'Distribution',
|
||||||
|
partToWhole: 'Part-to-whole',
|
||||||
|
heatmap: 'Heatmap',
|
||||||
|
};
|
||||||
|
|
||||||
|
const INTENT_NEEDS: Record<ChartIntent, string> = {
|
||||||
|
compare: 'a category column',
|
||||||
|
ranking: 'a category column',
|
||||||
|
time: 'a date column',
|
||||||
|
correlation: 'two number columns',
|
||||||
|
distribution: 'a number column',
|
||||||
|
partToWhole: 'two category columns',
|
||||||
|
heatmap: 'two category columns',
|
||||||
|
};
|
||||||
|
|
||||||
const CHANNEL_LABELS: Record<ChannelName, string> = {
|
const CHANNEL_LABELS: Record<ChannelName, string> = {
|
||||||
x: 'X',
|
x: 'X',
|
||||||
y: 'Y',
|
y: 'Y',
|
||||||
@@ -1277,6 +1316,112 @@ function DatasetPicker({ datasetId }: { datasetId: number | null }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The intent-first front door (spec §06 → Intent): a persistent "what do you want to
|
||||||
|
* show?" strip pinned under the dataset picker. Each chip applies a recommended mark +
|
||||||
|
* channel layout — the "do it for me" (Tableau "Show Me"). Intents the dataset can't
|
||||||
|
* satisfy are disabled (with the reason in their name), and the chip whose layout the
|
||||||
|
* live chart matches stays highlighted; once the user edits away from any, none is — a
|
||||||
|
* "Custom" chart. It seeds the builder; it never gates it (the user can ignore it and
|
||||||
|
* drive the channels directly). The active intent is derived from the config, so no
|
||||||
|
* intent state is stored.
|
||||||
|
*/
|
||||||
|
function IntentStrip() {
|
||||||
|
const columns = useChartBuilderStore((s) => s.columns);
|
||||||
|
const config = useChartBuilderStore((s) => s.config);
|
||||||
|
const setIntent = useChartBuilderStore((s) => s.setIntent);
|
||||||
|
|
||||||
|
const active = useMemo(() => activeIntent(config, columns), [config, columns]);
|
||||||
|
const applicable = useMemo(
|
||||||
|
() => new Set(CHART_INTENTS.filter((i) => intentApplicable(i, columns))),
|
||||||
|
[columns],
|
||||||
|
);
|
||||||
|
|
||||||
|
// APG toolbar (arch 10 §5): the chips are a single tab stop with a roving tabindex,
|
||||||
|
// NOT seven independently-tabbable buttons (the pane-toggle precedent). Arrows MOVE
|
||||||
|
// focus only — Enter/Space activates — because applying an
|
||||||
|
// intent reshapes the whole chart; a radiogroup's select-on-arrow would do that on
|
||||||
|
// every keypress. Disabled chips stay arrow-reachable so their "needs …" reason is
|
||||||
|
// discoverable (APG: focusable disabled controls where discoverability is crucial).
|
||||||
|
const btnRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||||
|
const tabStop = useMemo(() => {
|
||||||
|
if (active) return CHART_INTENTS.indexOf(active); // an active intent is always enabled
|
||||||
|
const firstEnabled = CHART_INTENTS.findIndex((i) => applicable.has(i));
|
||||||
|
return firstEnabled >= 0 ? firstEnabled : 0;
|
||||||
|
}, [active, applicable]);
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent, index: number) => {
|
||||||
|
const n = CHART_INTENTS.length;
|
||||||
|
let next: number;
|
||||||
|
switch (e.key) {
|
||||||
|
case 'ArrowRight':
|
||||||
|
case 'ArrowDown':
|
||||||
|
next = (index + 1) % n;
|
||||||
|
break;
|
||||||
|
case 'ArrowLeft':
|
||||||
|
case 'ArrowUp':
|
||||||
|
next = (index - 1 + n) % n;
|
||||||
|
break;
|
||||||
|
case 'Home':
|
||||||
|
next = 0;
|
||||||
|
break;
|
||||||
|
case 'End':
|
||||||
|
next = n - 1;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return; // not ours — let it bubble (Enter/Space activate the native button)
|
||||||
|
}
|
||||||
|
e.preventDefault();
|
||||||
|
btnRefs.current[next]?.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.intentStrip}>
|
||||||
|
<span className={styles.intentQ} id="cb-intent-q">
|
||||||
|
What do you want to show?
|
||||||
|
</span>
|
||||||
|
<p className={styles.intentSub}>
|
||||||
|
{active ? (
|
||||||
|
<>
|
||||||
|
Starting point: <strong>{INTENT_LABELS[active]}</strong>. Pick another, or adjust the
|
||||||
|
controls below.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>Pick a starting point and we will set the chart up — or build it yourself below.</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className={styles.intentChips} role="toolbar" aria-labelledby="cb-intent-q">
|
||||||
|
{CHART_INTENTS.map((intent, i) => {
|
||||||
|
const enabled = applicable.has(intent);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={intent}
|
||||||
|
ref={(el) => {
|
||||||
|
btnRefs.current[i] = el;
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
className={styles.intentChip}
|
||||||
|
aria-pressed={active === intent}
|
||||||
|
aria-disabled={!enabled || undefined}
|
||||||
|
aria-label={
|
||||||
|
enabled ? undefined : `${INTENT_LABELS[intent]} — needs ${INTENT_NEEDS[intent]}`
|
||||||
|
}
|
||||||
|
title={enabled ? undefined : `Needs ${INTENT_NEEDS[intent]}`}
|
||||||
|
tabIndex={i === tabStop ? 0 : -1}
|
||||||
|
onKeyDown={(e) => onKeyDown(e, i)}
|
||||||
|
onClick={() => {
|
||||||
|
if (enabled) setIntent(intent);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{INTENT_LABELS[intent]}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The no-datasets state (spec §06 → Opening; Carbon no-data empty state): says
|
* The no-datasets state (spec §06 → Opening; Carbon no-data empty state): says
|
||||||
* what the builder does and offers the one next step — never a dead end. The
|
* what the builder does and offers the one next step — never a dead end. The
|
||||||
@@ -1377,6 +1522,8 @@ export function ChartBuilderModal() {
|
|||||||
</div>
|
</div>
|
||||||
<DatasetPicker datasetId={datasetId} />
|
<DatasetPicker datasetId={datasetId} />
|
||||||
|
|
||||||
|
<IntentStrip />
|
||||||
|
|
||||||
<DataSection />
|
<DataSection />
|
||||||
|
|
||||||
<div className={styles.field}>
|
<div className={styles.field}>
|
||||||
@@ -1386,6 +1533,10 @@ export function ChartBuilderModal() {
|
|||||||
options={MARK_OPTIONS}
|
options={MARK_OPTIONS}
|
||||||
value={mark}
|
value={mark}
|
||||||
onChange={setMark}
|
onChange={setMark}
|
||||||
|
// Six marks don't fit one 320–360px row: wrap to two, each segment kept at
|
||||||
|
// the control height (the base look is fixed-height, single-row).
|
||||||
|
className={styles.markPicker}
|
||||||
|
optionClassName={styles.markPickerOption}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,23 @@ describe('init', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('setIntent (front door "do it for me")', () => {
|
||||||
|
test('reshapes the chart to the intent layout and disarms any armed channel', () => {
|
||||||
|
const id = seedDataset('Sales', [
|
||||||
|
{ region: 'E', segment: 'A', sales: 5 },
|
||||||
|
{ region: 'W', segment: 'B', sales: 9 },
|
||||||
|
]);
|
||||||
|
cb().init(id);
|
||||||
|
cb().focusChannel('color'); // arm a channel first
|
||||||
|
cb().setIntent('heatmap');
|
||||||
|
expect(cb().config.mark).toBe('rect');
|
||||||
|
expect(cb().config.encodings.color).toEqual({ type: 'quantitative', aggregate: 'count' });
|
||||||
|
expect(cb().config.encodings.x?.field).toBeDefined();
|
||||||
|
expect(cb().config.encodings.y?.field).toBeDefined();
|
||||||
|
expect(cb().activeChannel).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('channel editing', () => {
|
describe('channel editing', () => {
|
||||||
test('mapping a column seeds a channel-appropriate type (Size stays a measure)', () => {
|
test('mapping a column seeds a channel-appropriate type (Size stays a measure)', () => {
|
||||||
const id = seedDataset('Mixed', [{ name: 'A', value: 5 }]);
|
const id = seedDataset('Mixed', [{ name: 'A', value: 5 }]);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import {
|
import {
|
||||||
CHANNELS,
|
CHANNELS,
|
||||||
|
applyIntent,
|
||||||
buildSnippetSpecText,
|
buildSnippetSpecText,
|
||||||
channelAcceptsValue,
|
channelAcceptsValue,
|
||||||
coerceChannelValue,
|
coerceChannelValue,
|
||||||
@@ -40,6 +41,7 @@ import {
|
|||||||
type BuilderColumns,
|
type BuilderColumns,
|
||||||
type BuilderConfig,
|
type BuilderConfig,
|
||||||
type BuilderFilter,
|
type BuilderFilter,
|
||||||
|
type ChartIntent,
|
||||||
type BuilderWarningFix,
|
type BuilderWarningFix,
|
||||||
type ChannelMapping,
|
type ChannelMapping,
|
||||||
type ChannelName,
|
type ChannelName,
|
||||||
@@ -111,6 +113,14 @@ export interface ChartBuilderState {
|
|||||||
* `rebaseBuilderConfig`).
|
* `rebaseBuilderConfig`).
|
||||||
*/
|
*/
|
||||||
switchDataset: (datasetId: number) => void;
|
switchDataset: (datasetId: number) => void;
|
||||||
|
/**
|
||||||
|
* Reshape the chart to an intent's recommended layout — the front door's "do it
|
||||||
|
* for me" (spec §06 → Intent). Replaces mark + encodings + sort/stack with the
|
||||||
|
* intent's; keeps the dataset, data transforms, and chart properties. The active
|
||||||
|
* intent is derived from the config (`activeIntent`), not stored, so an edit after
|
||||||
|
* a pick reads as "Custom" with no extra state.
|
||||||
|
*/
|
||||||
|
setIntent: (intent: ChartIntent) => void;
|
||||||
setMark: (mark: MarkType) => void;
|
setMark: (mark: MarkType) => void;
|
||||||
/**
|
/**
|
||||||
* Map a column to a channel (null = "None", `COUNT_FIELD` = a field-less count);
|
* Map a column to a channel (null = "None", `COUNT_FIELD` = a field-less count);
|
||||||
@@ -310,6 +320,9 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setIntent: (intent) =>
|
||||||
|
set((s) => ({ activeChannel: null, config: applyIntent(s.config, intent, s.columns) })),
|
||||||
|
|
||||||
setMark: (mark) => set((s) => ({ config: { ...s.config, mark } })),
|
setMark: (mark) => set((s) => ({ config: { ...s.config, mark } })),
|
||||||
|
|
||||||
setChannelColumn: (channel, columnName) =>
|
setChannelColumn: (channel, columnName) =>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
MARK_TYPES,
|
||||||
defaultFieldType,
|
defaultFieldType,
|
||||||
validFieldTypes,
|
validFieldTypes,
|
||||||
defaultMark,
|
defaultMark,
|
||||||
@@ -19,6 +20,11 @@ import {
|
|||||||
channelAcceptsValue,
|
channelAcceptsValue,
|
||||||
defaultChannelValue,
|
defaultChannelValue,
|
||||||
coerceChannelValue,
|
coerceChannelValue,
|
||||||
|
CHART_INTENTS,
|
||||||
|
intentApplicable,
|
||||||
|
intentLayout,
|
||||||
|
applyIntent,
|
||||||
|
activeIntent,
|
||||||
buildChartSpec,
|
buildChartSpec,
|
||||||
buildSnippetSpecText,
|
buildSnippetSpecText,
|
||||||
generateChartName,
|
generateChartName,
|
||||||
@@ -199,6 +205,62 @@ describe('builderWarnings (Tier B advisories)', () => {
|
|||||||
expect(w).toEqual([]);
|
expect(w).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('heatmap (rect mark)', () => {
|
||||||
|
it('warns when a rect mark is missing an axis (a heatmap is an X×Y grid)', () => {
|
||||||
|
const w = builderWarnings({
|
||||||
|
datasetName: 'D',
|
||||||
|
mark: 'rect',
|
||||||
|
encodings: { x: { field: 'a', type: 'nominal' } },
|
||||||
|
});
|
||||||
|
expect(w.some((m) => /Heatmaps need both an X and a Y/.test(m.message))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nudges a colour measure when both axes are mapped, offering [Colour by count]', () => {
|
||||||
|
const config: BuilderConfig = {
|
||||||
|
datasetName: 'D',
|
||||||
|
mark: 'rect',
|
||||||
|
encodings: {
|
||||||
|
x: { field: 'region', type: 'nominal' },
|
||||||
|
y: { field: 'segment', type: 'nominal' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const w = builderWarnings(config);
|
||||||
|
const hint = w.find((m) => m.channel === 'color');
|
||||||
|
expect(hint?.message).toMatch(/map a measure \(or Count\) to Colour/);
|
||||||
|
const fixed = hint!.fixes!.find((f) => f.label === 'Colour by count')!.apply(config);
|
||||||
|
expect(fixed.encodings.color).toEqual({ type: 'quantitative', aggregate: 'count' });
|
||||||
|
// With a count on Colour it is a complete heatmap — the hint re-derives away.
|
||||||
|
expect(builderWarnings(fixed).some((m) => m.channel === 'color')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT push two-measures-to-scatter on a rect (a binned 2-D histogram is valid)', () => {
|
||||||
|
const w = builderWarnings({
|
||||||
|
datasetName: 'D',
|
||||||
|
mark: 'rect',
|
||||||
|
encodings: {
|
||||||
|
x: { field: 'a', type: 'quantitative', bin: true },
|
||||||
|
y: { field: 'b', type: 'quantitative', bin: true },
|
||||||
|
color: { type: 'quantitative', aggregate: 'count' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(w.some((m) => /scatter/.test(m.message))).toBe(false);
|
||||||
|
expect(w).toEqual([]); // a binned 2-D histogram with a count colour is clean
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads a colour-measure heatmap as a clean configuration', () => {
|
||||||
|
const w = builderWarnings({
|
||||||
|
datasetName: 'D',
|
||||||
|
mark: 'rect',
|
||||||
|
encodings: {
|
||||||
|
x: { field: 'region', type: 'nominal' },
|
||||||
|
y: { field: 'segment', type: 'nominal' },
|
||||||
|
color: { field: 'revenue', type: 'quantitative', aggregate: 'sum' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(w).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('crowded category axis (one mark per row)', () => {
|
describe('crowded category axis (one mark per row)', () => {
|
||||||
const crowded = (overrides: Partial<ChannelMapping> = {}) =>
|
const crowded = (overrides: Partial<ChannelMapping> = {}) =>
|
||||||
builderWarnings(
|
builderWarnings(
|
||||||
@@ -624,6 +686,194 @@ describe('defaultBuilderConfig — data-aware "safest bet" (profiled datasets)',
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('intent-first front door', () => {
|
||||||
|
const stat = (name: string, distinct: number, capped = false) => ({
|
||||||
|
name,
|
||||||
|
distinct,
|
||||||
|
distinctCapped: capped,
|
||||||
|
numericExtent: null,
|
||||||
|
});
|
||||||
|
// Two categories (Segment 3 < Region 4), one date, two measures → every intent applies.
|
||||||
|
const superstore: BuilderColumns = {
|
||||||
|
columns: ['Segment', 'Region', 'Order Date', 'Sales', 'Profit'],
|
||||||
|
columnTypes: [
|
||||||
|
{ name: 'Segment', type: 'string' },
|
||||||
|
{ name: 'Region', type: 'string' },
|
||||||
|
{ name: 'Order Date', type: 'date' },
|
||||||
|
{ name: 'Sales', type: 'number' },
|
||||||
|
{ name: 'Profit', type: 'number' },
|
||||||
|
],
|
||||||
|
columnStats: [
|
||||||
|
stat('Segment', 3),
|
||||||
|
stat('Region', 4),
|
||||||
|
stat('Order Date', 50, true),
|
||||||
|
stat('Sales', 50, true),
|
||||||
|
stat('Profit', 50, true),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const count = { type: 'quantitative', aggregate: 'count' };
|
||||||
|
|
||||||
|
describe('intentApplicable (Tableau "Show Me" gating)', () => {
|
||||||
|
it('enables every intent on a category+date+measure dataset', () => {
|
||||||
|
for (const intent of CHART_INTENTS) {
|
||||||
|
expect(intentApplicable(intent, superstore)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables intents whose required column roles are missing', () => {
|
||||||
|
const oneMeasureOneCat: BuilderColumns = {
|
||||||
|
columns: ['Segment', 'Sales'],
|
||||||
|
columnTypes: [
|
||||||
|
{ name: 'Segment', type: 'string' },
|
||||||
|
{ name: 'Sales', type: 'number' },
|
||||||
|
],
|
||||||
|
columnStats: [stat('Segment', 3), stat('Sales', 50, true)],
|
||||||
|
};
|
||||||
|
expect(intentApplicable('correlation', oneMeasureOneCat)).toBe(false); // needs 2 measures
|
||||||
|
expect(intentApplicable('time', oneMeasureOneCat)).toBe(false); // needs a date
|
||||||
|
expect(intentApplicable('heatmap', oneMeasureOneCat)).toBe(false); // needs 2 categories
|
||||||
|
expect(intentApplicable('partToWhole', oneMeasureOneCat)).toBe(false);
|
||||||
|
expect(intentApplicable('compare', oneMeasureOneCat)).toBe(true);
|
||||||
|
expect(intentApplicable('distribution', oneMeasureOneCat)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('intentLayout (intent → mark + channels)', () => {
|
||||||
|
it('maps each intent to its recommended layout', () => {
|
||||||
|
expect(intentLayout('compare', superstore)).toEqual({
|
||||||
|
mark: 'bar',
|
||||||
|
encodings: { x: { field: 'Segment', type: 'nominal' }, y: count },
|
||||||
|
});
|
||||||
|
expect(intentLayout('ranking', superstore)).toEqual({
|
||||||
|
mark: 'bar',
|
||||||
|
sort: 'descending',
|
||||||
|
encodings: { x: { field: 'Segment', type: 'nominal' }, y: count },
|
||||||
|
});
|
||||||
|
expect(intentLayout('time', superstore)).toEqual({
|
||||||
|
mark: 'line',
|
||||||
|
encodings: {
|
||||||
|
x: { field: 'Order Date', type: 'temporal' },
|
||||||
|
y: { field: 'Sales', type: 'quantitative' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(intentLayout('correlation', superstore)).toEqual({
|
||||||
|
mark: 'point',
|
||||||
|
encodings: {
|
||||||
|
x: { field: 'Sales', type: 'quantitative' },
|
||||||
|
y: { field: 'Profit', type: 'quantitative' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(intentLayout('distribution', superstore)).toEqual({
|
||||||
|
mark: 'bar',
|
||||||
|
encodings: { x: { field: 'Sales', type: 'quantitative', bin: true }, y: count },
|
||||||
|
});
|
||||||
|
expect(intentLayout('partToWhole', superstore)).toEqual({
|
||||||
|
mark: 'bar',
|
||||||
|
stack: 'zero',
|
||||||
|
encodings: {
|
||||||
|
x: { field: 'Segment', type: 'nominal' },
|
||||||
|
y: count,
|
||||||
|
color: { field: 'Region', type: 'nominal' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(intentLayout('heatmap', superstore)).toEqual({
|
||||||
|
mark: 'rect',
|
||||||
|
encodings: {
|
||||||
|
x: { field: 'Segment', type: 'nominal' },
|
||||||
|
y: { field: 'Region', type: 'nominal' },
|
||||||
|
color: count,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('applyIntent', () => {
|
||||||
|
it('reshapes mark/encodings/sort/stack but keeps dataset, transforms, and title', () => {
|
||||||
|
const start: BuilderConfig = {
|
||||||
|
datasetName: 'Superstore',
|
||||||
|
mark: 'point',
|
||||||
|
encodings: { x: { field: 'Sales', type: 'quantitative' } },
|
||||||
|
title: 'My chart',
|
||||||
|
calculates: [{ id: 'c1', expr: 'datum.Sales * 2', as: 'double' }],
|
||||||
|
filters: [{ id: 'f1', mode: 'expression', expr: 'datum.Sales > 0' }],
|
||||||
|
};
|
||||||
|
const out = applyIntent(start, 'heatmap', superstore);
|
||||||
|
expect(out.mark).toBe('rect');
|
||||||
|
expect(out.encodings).toEqual({
|
||||||
|
x: { field: 'Segment', type: 'nominal' },
|
||||||
|
y: { field: 'Region', type: 'nominal' },
|
||||||
|
color: count,
|
||||||
|
size: null,
|
||||||
|
});
|
||||||
|
expect(out.datasetName).toBe('Superstore');
|
||||||
|
expect(out.title).toBe('My chart');
|
||||||
|
expect(out.calculates).toBe(start.calculates);
|
||||||
|
expect(out.filters).toBe(start.filters);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears a stale channel and sort/stack when switching intents', () => {
|
||||||
|
const heat = applyIntent(
|
||||||
|
{ datasetName: 'D', mark: 'bar', encodings: {} },
|
||||||
|
'heatmap',
|
||||||
|
superstore,
|
||||||
|
);
|
||||||
|
expect(heat.encodings.color).toEqual(count);
|
||||||
|
const ranked = applyIntent(heat, 'ranking', superstore);
|
||||||
|
expect(ranked.encodings.color).toBeNull(); // heatmap's colour is dropped
|
||||||
|
expect(ranked.sort).toBe('descending');
|
||||||
|
const compared = applyIntent(ranked, 'compare', superstore);
|
||||||
|
expect(compared.sort).toBeUndefined(); // ranking's sort is dropped
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('activeIntent (which chip the live chart lights)', () => {
|
||||||
|
it('lights the smart default on open (the coupling guard)', () => {
|
||||||
|
// The data-aware default IS an intent layout, so the strip auto-highlights it.
|
||||||
|
expect(activeIntent(defaultBuilderConfig('D', superstore), superstore)).toBe('compare');
|
||||||
|
|
||||||
|
const dateShape: BuilderColumns = {
|
||||||
|
columns: ['Order ID', 'Order Date', 'Sales'],
|
||||||
|
columnTypes: [
|
||||||
|
{ name: 'Order ID', type: 'string' },
|
||||||
|
{ name: 'Order Date', type: 'date' },
|
||||||
|
{ name: 'Sales', type: 'number' },
|
||||||
|
],
|
||||||
|
columnStats: [
|
||||||
|
stat('Order ID', 50, true),
|
||||||
|
stat('Order Date', 50, true),
|
||||||
|
stat('Sales', 50, true),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(activeIntent(defaultBuilderConfig('D', dateShape), dateShape)).toBe('time');
|
||||||
|
|
||||||
|
const scatterShape: BuilderColumns = {
|
||||||
|
columns: ['Order ID', 'Sales', 'Profit'],
|
||||||
|
columnTypes: [
|
||||||
|
{ name: 'Order ID', type: 'string' },
|
||||||
|
{ name: 'Sales', type: 'number' },
|
||||||
|
{ name: 'Profit', type: 'number' },
|
||||||
|
],
|
||||||
|
columnStats: [
|
||||||
|
stat('Order ID', 50, true),
|
||||||
|
stat('Sales', 50, true),
|
||||||
|
stat('Profit', 50, true),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(activeIntent(defaultBuilderConfig('D', scatterShape), scatterShape)).toBe(
|
||||||
|
'correlation',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reflects the picked intent, then reads Custom after a hand edit', () => {
|
||||||
|
const base = defaultBuilderConfig('D', superstore);
|
||||||
|
expect(activeIntent(applyIntent(base, 'heatmap', superstore), superstore)).toBe('heatmap');
|
||||||
|
// An edit that no intent produces (an area mark) → no chip lit.
|
||||||
|
const edited: BuilderConfig = { ...base, mark: 'area' };
|
||||||
|
expect(activeIntent(edited, superstore)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('isBuilderConfigValid', () => {
|
describe('isBuilderConfigValid', () => {
|
||||||
const base: BuilderConfig = { datasetName: 'D', mark: 'bar', encodings: {} };
|
const base: BuilderConfig = { datasetName: 'D', mark: 'bar', encodings: {} };
|
||||||
|
|
||||||
@@ -689,7 +939,7 @@ describe('buildChartSpec', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('carries every mark type through to the spec', () => {
|
it('carries every mark type through to the spec', () => {
|
||||||
for (const mark of ['bar', 'line', 'point', 'area', 'circle'] as const) {
|
for (const mark of MARK_TYPES) {
|
||||||
const spec = buildChartSpec({
|
const spec = buildChartSpec({
|
||||||
datasetName: 'D',
|
datasetName: 'D',
|
||||||
mark,
|
mark,
|
||||||
@@ -932,6 +1182,69 @@ describe('transforms — aggregate / bin / timeUnit', () => {
|
|||||||
}),
|
}),
|
||||||
).toBe('Bar chart of unique customer by region');
|
).toBe('Bar chart of unique customer by region');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('names a heatmap "Heatmap of …" (not "Rect chart of …")', () => {
|
||||||
|
expect(
|
||||||
|
generateChartName({
|
||||||
|
datasetName: 'D',
|
||||||
|
mark: 'rect',
|
||||||
|
encodings: {
|
||||||
|
x: { field: 'region', type: 'nominal' },
|
||||||
|
y: { field: 'segment', type: 'nominal' },
|
||||||
|
color: { field: 'revenue', type: 'quantitative', aggregate: 'sum' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe('Heatmap of segment by region');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('role-aware guidance (bin is a dimension, not a measure)', () => {
|
||||||
|
// A 1-D histogram: bar, binned quantitative X, count Y. Both axes are quantitative by
|
||||||
|
// *type*, but the binned X is a discretized dimension by *role* — the false-positive
|
||||||
|
// class the role fix closes (eng-council 2026-06-13).
|
||||||
|
const histogram: BuilderConfig = {
|
||||||
|
datasetName: 'D',
|
||||||
|
mark: 'bar',
|
||||||
|
encodings: {
|
||||||
|
x: { field: 'amount', type: 'quantitative', bin: true },
|
||||||
|
y: { type: 'quantitative', aggregate: 'count' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('does not nudge a histogram toward a scatter (the binned axis is a dimension)', () => {
|
||||||
|
expect(builderWarnings(histogram).some((m) => /scatter/.test(m.message))).toBe(false);
|
||||||
|
expect(builderWarnings(histogram)).toEqual([]); // a clean histogram has no hints
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not offer Sort on a histogram (binned bins carry an inherent order)', () => {
|
||||||
|
expect(sortableCategoryChannel(histogram)).toBeUndefined();
|
||||||
|
expect(supportsSort(histogram)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still offers Sort on a real category-vs-measure bar', () => {
|
||||||
|
const bar: BuilderConfig = {
|
||||||
|
datasetName: 'D',
|
||||||
|
mark: 'bar',
|
||||||
|
encodings: {
|
||||||
|
x: { field: 'region', type: 'nominal' },
|
||||||
|
y: { type: 'quantitative', aggregate: 'count' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(sortableCategoryChannel(bar)).toBe('x');
|
||||||
|
expect(supportsSort(bar)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still nudges two RAW quantitative measures on a bar toward a scatter', () => {
|
||||||
|
const twoRaw: BuilderConfig = {
|
||||||
|
datasetName: 'D',
|
||||||
|
mark: 'bar',
|
||||||
|
encodings: {
|
||||||
|
x: { field: 'sales', type: 'quantitative' },
|
||||||
|
y: { field: 'profit', type: 'quantitative' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(builderWarnings(twoRaw).some((m) => /scatter/.test(m.message))).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('sort (ranking)', () => {
|
describe('sort (ranking)', () => {
|
||||||
|
|||||||
+340
-27
@@ -29,8 +29,9 @@ import { VEGA_LITE_SCHEMA_URL } from './snippet';
|
|||||||
import { validateExpression } from './expr-validate';
|
import { validateExpression } from './expr-validate';
|
||||||
import { escapeVegaField } from './rendering';
|
import { escapeVegaField } from './rendering';
|
||||||
|
|
||||||
/** The five mark types the builder offers, in selector order (spec §06). */
|
/** The six mark types the builder offers, in selector order (spec §06). `rect` is the
|
||||||
export const MARK_TYPES = ['bar', 'line', 'point', 'area', 'circle'] as const;
|
* heatmap mark — an X×Y grid of cells shaded by a Colour measure. */
|
||||||
|
export const MARK_TYPES = ['bar', 'line', 'point', 'area', 'circle', 'rect'] as const;
|
||||||
export type MarkType = (typeof MARK_TYPES)[number];
|
export type MarkType = (typeof MARK_TYPES)[number];
|
||||||
|
|
||||||
/** The four Vega-Lite field types a channel may carry, in override-menu order. */
|
/** The four Vega-Lite field types a channel may carry, in override-menu order. */
|
||||||
@@ -361,7 +362,9 @@ export function coerceChannelValue(channel: ChannelName, raw: string): string |
|
|||||||
* - a single mapped axis, or nothing yet → **Bar** (the safe default)
|
* - a single mapped axis, or nothing yet → **Bar** (the safe default)
|
||||||
*
|
*
|
||||||
* `null` means the channel is unmapped. This is the *default*; the user can switch
|
* `null` means the channel is unmapped. This is the *default*; the user can switch
|
||||||
* to any of the five marks afterward.
|
* to any of the six marks afterward. **Heatmap (`rect`) is never auto-defaulted** —
|
||||||
|
* it reads only with a Colour measure, which the X/Y shape alone can't determine, so
|
||||||
|
* it stays a deliberate pick (the heatmap guidance nudges the missing Colour).
|
||||||
*/
|
*/
|
||||||
export function defaultMark(xType: FieldType | null, yType: FieldType | null): MarkType {
|
export function defaultMark(xType: FieldType | null, yType: FieldType | null): MarkType {
|
||||||
if (xType === null || yType === null) return 'bar';
|
if (xType === null || yType === null) return 'bar';
|
||||||
@@ -503,6 +506,237 @@ export function defaultBuilderConfig(datasetName: string, columns: BuilderColumn
|
|||||||
return { datasetName, mark, encodings };
|
return { datasetName, mark, encodings };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Intent-first front door (spec §06 → Intent) ────────────────────────────────
|
||||||
|
//
|
||||||
|
// "What do you want to show?" — a small set of analytic intents (FT Visual
|
||||||
|
// Vocabulary / Datawrapper taxonomy) that each recommend a mark + channel layout
|
||||||
|
// from the dataset's column roles. The front door is an *on-ramp*, not a gate: it
|
||||||
|
// seeds the mark-first builder, which the user can then adjust or ignore, and the
|
||||||
|
// intent never enters the produced spec — it is builder-local steering only (the
|
||||||
|
// JSON spec stays the document). Mirrors Tableau "Show Me": intents the data can't
|
||||||
|
// satisfy are disabled, and the live chart's matching intent stays highlighted.
|
||||||
|
|
||||||
|
/** The intents the front door offers, in display order. */
|
||||||
|
export const CHART_INTENTS = [
|
||||||
|
'compare',
|
||||||
|
'ranking',
|
||||||
|
'time',
|
||||||
|
'correlation',
|
||||||
|
'distribution',
|
||||||
|
'partToWhole',
|
||||||
|
'heatmap',
|
||||||
|
] as const;
|
||||||
|
export type ChartIntent = (typeof CHART_INTENTS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dataset columns split by the role they play in a chart. Categories are ordered
|
||||||
|
* **most-readable first** — a low-cardinality category (2…`CATEGORY_DEFAULT_MAX_DISTINCT`
|
||||||
|
* distinct) ahead of a constant or a high-cardinality one, then by ascending distinct
|
||||||
|
* — so `categories[0]` is the same readable axis `smartDefaultEncodings` prefers. That
|
||||||
|
* shared preference is what lets `activeIntent` light the smart default on open.
|
||||||
|
*/
|
||||||
|
function columnRoles(columns: BuilderColumns): {
|
||||||
|
categories: string[];
|
||||||
|
measures: string[];
|
||||||
|
temporals: string[];
|
||||||
|
} {
|
||||||
|
const typeOf = (name: string): ColumnType =>
|
||||||
|
columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
|
||||||
|
const distinctOf = (name: string): number => {
|
||||||
|
const s = columns.columnStats?.find((x) => x.name === name);
|
||||||
|
return s && !s.distinctCapped ? s.distinct : Number.POSITIVE_INFINITY;
|
||||||
|
};
|
||||||
|
const readable = (name: string): boolean => {
|
||||||
|
const d = distinctOf(name);
|
||||||
|
return d >= 2 && d <= CATEGORY_DEFAULT_MAX_DISTINCT;
|
||||||
|
};
|
||||||
|
const measures = columns.columns.filter((n) => typeOf(n) === 'number');
|
||||||
|
const temporals = columns.columns.filter((n) => typeOf(n) === 'date');
|
||||||
|
const categories = columns.columns
|
||||||
|
.filter((n) => typeOf(n) === 'string' || typeOf(n) === 'boolean')
|
||||||
|
.sort((a, b) => {
|
||||||
|
const ra = readable(a);
|
||||||
|
const rb = readable(b);
|
||||||
|
if (ra !== rb) return ra ? -1 : 1; // readable categories first
|
||||||
|
return distinctOf(a) - distinctOf(b); // then lowest-cardinality
|
||||||
|
});
|
||||||
|
return { categories, measures, temporals };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fresh field-less count measure (never share a reference — configs are immutable). */
|
||||||
|
function countMapping(): ChannelMapping {
|
||||||
|
return { type: 'quantitative', aggregate: 'count' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the dataset has the column roles an intent needs to be meaningful. The
|
||||||
|
* front door **disables** the intents that don't apply (Tableau "Show Me": an
|
||||||
|
* unavailable chart is shown but inert) rather than producing a broken chart.
|
||||||
|
*/
|
||||||
|
export function intentApplicable(intent: ChartIntent, columns: BuilderColumns): boolean {
|
||||||
|
const { categories, measures, temporals } = columnRoles(columns);
|
||||||
|
switch (intent) {
|
||||||
|
case 'compare':
|
||||||
|
case 'ranking':
|
||||||
|
return categories.length >= 1; // a category axis vs a count
|
||||||
|
case 'time':
|
||||||
|
return temporals.length >= 1;
|
||||||
|
case 'correlation':
|
||||||
|
return measures.length >= 2; // two measures to cross
|
||||||
|
case 'distribution':
|
||||||
|
return measures.length >= 1; // a measure to bin
|
||||||
|
case 'partToWhole':
|
||||||
|
case 'heatmap':
|
||||||
|
return categories.length >= 2; // two categorical dimensions
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep only the channels an intent actually sets, in canonical order. */
|
||||||
|
function pruneLayout(
|
||||||
|
enc: Partial<Record<ChannelName, ChannelMapping | undefined>>,
|
||||||
|
): Partial<Record<ChannelName, ChannelMapping>> {
|
||||||
|
const out: Partial<Record<ChannelName, ChannelMapping>> = {};
|
||||||
|
for (const ch of CHANNELS) {
|
||||||
|
const m = enc[ch];
|
||||||
|
if (m) out[ch] = m;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mark + channel layout an intent recommends for these columns (spec §06 →
|
||||||
|
* Intent). A *partial* config — only the channels the intent sets, plus the mark and
|
||||||
|
* any sort/stack; `applyIntent` merges it onto the working config. Best-effort when a
|
||||||
|
* preferred column is absent (the UI disables fully-inapplicable intents via
|
||||||
|
* `intentApplicable`, so a returned partial is always at least renderable).
|
||||||
|
*/
|
||||||
|
// TODO: exported only for its direct unit test — `applyIntent`/`activeIntent` are its
|
||||||
|
// sole production callers, both in this module. If no external caller appears, drop the
|
||||||
|
// export and assert the layout table through `applyIntent` (the file's convention for
|
||||||
|
// internal helpers like `smartDefaultEncodings`).
|
||||||
|
export function intentLayout(
|
||||||
|
intent: ChartIntent,
|
||||||
|
columns: BuilderColumns,
|
||||||
|
): Pick<BuilderConfig, 'mark' | 'sort' | 'stack'> & {
|
||||||
|
encodings: Partial<Record<ChannelName, ChannelMapping>>;
|
||||||
|
} {
|
||||||
|
const { categories, measures, temporals } = columnRoles(columns);
|
||||||
|
const cat = (i: number): ChannelMapping | undefined =>
|
||||||
|
categories[i] !== undefined ? { field: categories[i], type: 'nominal' } : undefined;
|
||||||
|
const measure = (i: number): ChannelMapping | undefined =>
|
||||||
|
measures[i] !== undefined ? { field: measures[i], type: 'quantitative' } : undefined;
|
||||||
|
const temporal = (i: number): ChannelMapping | undefined =>
|
||||||
|
temporals[i] !== undefined ? { field: temporals[i], type: 'temporal' } : undefined;
|
||||||
|
|
||||||
|
switch (intent) {
|
||||||
|
case 'compare': // magnitude across categories → a tidy bar of counts
|
||||||
|
return { mark: 'bar', encodings: pruneLayout({ x: cat(0), y: countMapping() }) };
|
||||||
|
case 'ranking': // the same, ordered by value
|
||||||
|
return {
|
||||||
|
mark: 'bar',
|
||||||
|
sort: 'descending',
|
||||||
|
encodings: pruneLayout({ x: cat(0), y: countMapping() }),
|
||||||
|
};
|
||||||
|
case 'time': // a trend over time → a line of the first measure (or a count)
|
||||||
|
return {
|
||||||
|
mark: 'line',
|
||||||
|
encodings: pruneLayout({ x: temporal(0), y: measure(0) ?? countMapping() }),
|
||||||
|
};
|
||||||
|
case 'correlation': // two measures crossed → a scatter
|
||||||
|
return { mark: 'point', encodings: pruneLayout({ x: measure(0), y: measure(1) }) };
|
||||||
|
case 'distribution': {
|
||||||
|
// the spread of one measure → a histogram (binned measure × count)
|
||||||
|
const m = measure(0);
|
||||||
|
return {
|
||||||
|
mark: 'bar',
|
||||||
|
encodings: pruneLayout({ x: m ? { ...m, bin: true } : undefined, y: countMapping() }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'partToWhole': // shares of a total → a stacked bar by a second category
|
||||||
|
return {
|
||||||
|
mark: 'bar',
|
||||||
|
stack: 'zero',
|
||||||
|
encodings: pruneLayout({ x: cat(0), y: countMapping(), color: cat(1) }),
|
||||||
|
};
|
||||||
|
case 'heatmap': // a two-category grid shaded by count
|
||||||
|
return {
|
||||||
|
mark: 'rect',
|
||||||
|
encodings: pruneLayout({ x: cat(0), y: cat(1), color: countMapping() }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reshape the working config to an intent's recommended layout (spec §06 → Intent),
|
||||||
|
* the front door's "do it for me". Replaces mark + encodings + sort + stack with the
|
||||||
|
* intent's; **keeps** the dataset, the data transforms (filters / calculated fields)
|
||||||
|
* and the chart-level title/subtitle/size — all orthogonal to *what kind of chart*.
|
||||||
|
* Pure; the store calls it when the user picks an intent.
|
||||||
|
*/
|
||||||
|
export function applyIntent(
|
||||||
|
config: BuilderConfig,
|
||||||
|
intent: ChartIntent,
|
||||||
|
columns: BuilderColumns,
|
||||||
|
): BuilderConfig {
|
||||||
|
const layout = intentLayout(intent, columns);
|
||||||
|
const next: BuilderConfig = {
|
||||||
|
...config,
|
||||||
|
mark: layout.mark,
|
||||||
|
encodings: { x: null, y: null, color: null, size: null, ...layout.encodings },
|
||||||
|
};
|
||||||
|
if (layout.sort) next.sort = layout.sort;
|
||||||
|
else delete next.sort;
|
||||||
|
if (layout.stack) next.stack = layout.stack;
|
||||||
|
else delete next.stack;
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Deep-equal two channel mappings (or nulls) on every field the builder emits. */
|
||||||
|
function sameMapping(a: ChannelMapping | null, b: ChannelMapping | null): boolean {
|
||||||
|
if (a === null || b === null) return a === b;
|
||||||
|
return (
|
||||||
|
a.field === b.field &&
|
||||||
|
a.type === b.type &&
|
||||||
|
a.aggregate === b.aggregate &&
|
||||||
|
!!a.bin === !!b.bin &&
|
||||||
|
a.timeUnit === b.timeUnit &&
|
||||||
|
a.value === b.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the config's mark/encodings/sort/stack match an intent's layout exactly. */
|
||||||
|
function configMatchesIntent(
|
||||||
|
config: BuilderConfig,
|
||||||
|
intent: ChartIntent,
|
||||||
|
columns: BuilderColumns,
|
||||||
|
): boolean {
|
||||||
|
const layout = intentLayout(intent, columns);
|
||||||
|
if (config.mark !== layout.mark) return false;
|
||||||
|
if ((config.sort ?? null) !== (layout.sort ?? null)) return false;
|
||||||
|
if ((config.stack ?? null) !== (layout.stack ?? null)) return false;
|
||||||
|
const want = { x: null, y: null, color: null, size: null, ...layout.encodings };
|
||||||
|
for (const ch of CHANNELS) {
|
||||||
|
if (!sameMapping(config.encodings[ch] ?? null, want[ch] ?? null)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The intent whose recommended layout the current config matches, or `null`
|
||||||
|
* ("Custom") once the user has edited away from any. Lets the front-door strip
|
||||||
|
* highlight the live chart's intent — and auto-light the smart default on open —
|
||||||
|
* **without storing the intent** (the config stays the single source of truth, so a
|
||||||
|
* dataset switch or a hand-built layout resolves correctly too). A structural match,
|
||||||
|
* preferring the first applicable intent on the rare tie.
|
||||||
|
*/
|
||||||
|
export function activeIntent(config: BuilderConfig, columns: BuilderColumns): ChartIntent | null {
|
||||||
|
return (
|
||||||
|
CHART_INTENTS.find(
|
||||||
|
(intent) => intentApplicable(intent, columns) && configMatchesIntent(config, intent, columns),
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The channels actually mapped (a column field, or a field-less count), in 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) => {
|
||||||
@@ -520,10 +754,21 @@ function effectiveType(mapping: ChannelMapping): FieldType {
|
|||||||
: mapping.type;
|
: mapping.type;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True when a mapping reads as a continuous measure (count/aggregate or continuous type). */
|
/**
|
||||||
|
* Whether a mapping reads as a continuous **measure** — the post-transform *role*, not
|
||||||
|
* the raw field type. A constant encodes no data; a **binned** field is a discretized
|
||||||
|
* *dimension* (the same notion as Vega-Lite's own `isDiscrete(fieldDef)`, which returns
|
||||||
|
* true for a binned quantitative); everything else continuous (raw quantitative/temporal,
|
||||||
|
* or a count/sum/etc. aggregate) is a measure. Bin and aggregate are mutually exclusive,
|
||||||
|
* so the bin guard never hides a real aggregate measure.
|
||||||
|
*
|
||||||
|
* The builder's guidance must ask this *role* question, never `effectiveType === 'quantitative'`
|
||||||
|
* directly — a binned axis is quantitative by type but a dimension by role; conflating the two
|
||||||
|
* makes a histogram (binned X + count Y) trip the two-measures→scatter nudge (arch 10 §5).
|
||||||
|
*/
|
||||||
function isMeasureMapping(mapping: ChannelMapping): boolean {
|
function isMeasureMapping(mapping: ChannelMapping): boolean {
|
||||||
// A constant value encodes no data, so it is never a measure.
|
|
||||||
if (isValueMapping(mapping)) return false;
|
if (isValueMapping(mapping)) return false;
|
||||||
|
if (mapping.bin) return false; // a binned field is a discretized dimension, not a measure
|
||||||
return isContinuous(effectiveType(mapping));
|
return isContinuous(effectiveType(mapping));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,26 +782,46 @@ export function isBuilderConfigValid(config: BuilderConfig): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The categorical positional channel to sort, when exactly one of X/Y is a discrete
|
* Whether a mapping is a **reorderable** category axis — one whose order is arbitrary,
|
||||||
* category and the other is a measure (spec §06 → Ranking). Returns the channel to
|
* so ranking it by the measure is meaningful. Nominal/ordinal only, and explicitly
|
||||||
* carry `sort`, or undefined when sorting doesn't apply (no clear category axis).
|
* **not** a binned or temporal axis: those carry an inherent order (you don't reorder
|
||||||
|
* histogram bins or a timeline by frequency). This is a *different* question from
|
||||||
|
* `isMeasureMapping` — a binned field is neither a measure nor a reorderable category —
|
||||||
|
* which is why sort and the scatter nudge can't share one predicate.
|
||||||
|
*/
|
||||||
|
function isReorderableCategory(mapping: ChannelMapping): boolean {
|
||||||
|
if (isValueMapping(mapping) || mapping.bin) return false;
|
||||||
|
const t = effectiveType(mapping);
|
||||||
|
return t === 'nominal' || t === 'ordinal';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The categorical positional channel to sort, when exactly one of X/Y is a **reorderable
|
||||||
|
* 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, reorderable category
|
||||||
|
* axis — so a histogram's binned axis is never offered a Sort).
|
||||||
*/
|
*/
|
||||||
export function sortableCategoryChannel(config: BuilderConfig): 'x' | 'y' | undefined {
|
export function sortableCategoryChannel(config: BuilderConfig): 'x' | 'y' | undefined {
|
||||||
const x = config.encodings.x ?? null;
|
const x = config.encodings.x ?? null;
|
||||||
const y = config.encodings.y ?? null;
|
const y = config.encodings.y ?? null;
|
||||||
if (!x || !y) return undefined;
|
if (!x || !y) return undefined;
|
||||||
const xMeasure = isMeasureMapping(x);
|
if (isReorderableCategory(x) && isMeasureMapping(y)) return 'x';
|
||||||
const yMeasure = isMeasureMapping(y);
|
if (isReorderableCategory(y) && isMeasureMapping(x)) return 'y';
|
||||||
if (xMeasure && !yMeasure) return 'y';
|
|
||||||
if (yMeasure && !xMeasure) return 'x';
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The quantitative positional channel (x or y) that stacking applies to, if any. */
|
/** The quantitative positional channel (x or y) that stacking applies to, if any. A
|
||||||
|
* binned axis is a dimension, never the stack measure (so a binned bar with a colour
|
||||||
|
* series stacks the count axis, not the bins). */
|
||||||
function stackMeasureChannel(config: BuilderConfig): 'x' | 'y' | undefined {
|
function stackMeasureChannel(config: BuilderConfig): 'x' | 'y' | undefined {
|
||||||
for (const channel of ['x', 'y'] as const) {
|
for (const channel of ['x', 'y'] as const) {
|
||||||
const mapping = config.encodings[channel];
|
const mapping = config.encodings[channel];
|
||||||
if (mapping && !isValueMapping(mapping) && effectiveType(mapping) === 'quantitative')
|
if (
|
||||||
|
mapping &&
|
||||||
|
!isValueMapping(mapping) &&
|
||||||
|
!mapping.bin &&
|
||||||
|
effectiveType(mapping) === 'quantitative'
|
||||||
|
)
|
||||||
return channel;
|
return channel;
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
@@ -707,14 +972,51 @@ export function builderWarnings(
|
|||||||
const y = config.encodings.y ?? null;
|
const y = config.encodings.y ?? null;
|
||||||
const { mark } = config;
|
const { mark } = config;
|
||||||
|
|
||||||
// Line/area are two-axis marks: a single mapped axis can't draw a meaningful line
|
// Measure/dimension is asked over the post-transform ROLE (`isMeasureMapping`), never
|
||||||
// or band (Draco hard.lp:91 line_area requires both x and y).
|
// raw `effectiveType`, so a binned axis (a discretized dimension) doesn't masquerade as
|
||||||
if ((mark === 'line' || mark === 'area') && (x === null || y === null)) {
|
// a measure here (arch 10 §5).
|
||||||
|
//
|
||||||
|
// A config that matches an applicable intent is known-good, so the "taste" heuristics
|
||||||
|
// (two-measures→scatter, area-split) could stand down for it via
|
||||||
|
// `&& activeIntent(config, columns) === null`. That gate is omitted because no current
|
||||||
|
// intent layout (see `intentLayout`) produces a config that trips a taste rule, so it
|
||||||
|
// would be a dead branch; add it when a future intent or taste rule would otherwise
|
||||||
|
// conflict.
|
||||||
|
|
||||||
|
// Line/area/rect are two-axis marks: a single mapped axis can't draw a meaningful
|
||||||
|
// line or band (Draco hard.lp:91 line_area requires both x and y), and a heatmap is
|
||||||
|
// an X×Y cell grid by definition.
|
||||||
|
if ((mark === 'line' || mark === 'area' || mark === 'rect') && (x === null || y === null)) {
|
||||||
|
const noun = mark === 'rect' ? 'Heatmaps' : mark === 'line' ? 'Line charts' : 'Area charts';
|
||||||
warnings.push({
|
warnings.push({
|
||||||
message: `${mark === 'line' ? 'Line' : 'Area'} charts need both an X and a Y axis.`,
|
message: `${noun} need both an X and a Y axis.`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A heatmap (rect) shades each X×Y cell by a value: without a measure on Colour the
|
||||||
|
// cells are uniform (or, with a categorical Colour, overlapping blocks) — not a
|
||||||
|
// heatmap. Nudge toward a Colour measure and offer Count as the always-available
|
||||||
|
// one (a cross-tab / 2-D-histogram count is the canonical heatmap). Gated on both
|
||||||
|
// axes present so it doesn't pile onto the both-axes hint above.
|
||||||
|
if (mark === 'rect' && x !== null && y !== null) {
|
||||||
|
const heatColor = config.encodings.color ?? null;
|
||||||
|
if (!heatColor || !isMeasureMapping(heatColor)) {
|
||||||
|
warnings.push({
|
||||||
|
channel: 'color',
|
||||||
|
message: 'A heatmap shades its cells by a value — map a measure (or Count) to Colour.',
|
||||||
|
fixes: [
|
||||||
|
{
|
||||||
|
label: 'Colour by count',
|
||||||
|
apply: (c) => ({
|
||||||
|
...c,
|
||||||
|
encodings: { ...c.encodings, color: { type: 'quantitative', aggregate: 'count' } },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Bar/line/area need a measure on one axis; two categories give nothing to compare
|
// Bar/line/area need a measure on one axis; two categories give nothing to compare
|
||||||
// (Draco soft.lp:47 only_discrete — the loudest nudge; hard.lp:97/:100 for bar).
|
// (Draco soft.lp:47 only_discrete — the loudest nudge; hard.lp:97/:100 for bar).
|
||||||
if (
|
if (
|
||||||
@@ -729,15 +1031,20 @@ export function builderWarnings(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two measures on a non-scatter mark: a line/bar/area over two quantitative axes
|
// Two *quantitative measures* on a bar/line/area: a scatter is the conventional choice
|
||||||
// misleads; a scatter is the conventional choice (Draco soft.lp c_c weights).
|
// (Draco soft.lp c_c weights; Datawrapper). Asked over the post-transform **role**
|
||||||
|
// (`isMeasureMapping`), not raw type, so a binned axis — a discretized dimension — never
|
||||||
|
// counts: that's what excludes a histogram (binned X + count) and a 2-D-histogram rect.
|
||||||
|
// The mark is a positive list (bar/line/area) — point/circle already are scatters, and a
|
||||||
|
// rect's right nudge is "bin + colour by count", handled by the heatmap hint above.
|
||||||
if (
|
if (
|
||||||
|
(mark === 'bar' || mark === 'line' || mark === 'area') &&
|
||||||
x !== null &&
|
x !== null &&
|
||||||
y !== null &&
|
y !== null &&
|
||||||
|
isMeasureMapping(x) &&
|
||||||
effectiveType(x) === 'quantitative' &&
|
effectiveType(x) === 'quantitative' &&
|
||||||
effectiveType(y) === 'quantitative' &&
|
isMeasureMapping(y) &&
|
||||||
mark !== 'point' &&
|
effectiveType(y) === 'quantitative'
|
||||||
mark !== 'circle'
|
|
||||||
) {
|
) {
|
||||||
warnings.push({
|
warnings.push({
|
||||||
message: 'Two measures usually read best as a scatter.',
|
message: 'Two measures usually read best as a scatter.',
|
||||||
@@ -1156,6 +1463,12 @@ function markLabel(mark: MarkType): string {
|
|||||||
return mark.charAt(0).toUpperCase() + mark.slice(1);
|
return mark.charAt(0).toUpperCase() + mark.slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The chart-type noun for a generated name: "Heatmap" for `rect` (its `markLabel`
|
||||||
|
* "Rect" is jargon), "<Mark> chart" for the rest. */
|
||||||
|
function markNoun(mark: MarkType): string {
|
||||||
|
return mark === 'rect' ? 'Heatmap' : `${markLabel(mark)} chart`;
|
||||||
|
}
|
||||||
|
|
||||||
/** A human phrase for what a channel encodes, e.g. "sum of revenue", "count". */
|
/** A human phrase for what a channel encodes, e.g. "sum of revenue", "count". */
|
||||||
function describeMapping(mapping: ChannelMapping): string {
|
function describeMapping(mapping: ChannelMapping): string {
|
||||||
if (mapping.value !== undefined) return 'a constant';
|
if (mapping.value !== undefined) return 'a constant';
|
||||||
@@ -1177,11 +1490,11 @@ export function generateChartName(config: BuilderConfig): string {
|
|||||||
// A user-written chart title is the best possible name — prefer it verbatim.
|
// A user-written chart title is the best possible name — prefer it verbatim.
|
||||||
const title = config.title?.trim();
|
const title = config.title?.trim();
|
||||||
if (title) return title;
|
if (title) return title;
|
||||||
const mark = markLabel(config.mark);
|
const noun = markNoun(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 ${describeMapping(y)} by ${describeMapping(x)}`;
|
if (x && y) return `${noun} of ${describeMapping(y)} by ${describeMapping(x)}`;
|
||||||
const only = mappedChannels(config)[0];
|
const only = mappedChannels(config)[0];
|
||||||
if (only) return `${mark} chart of ${describeMapping(only[1])}`;
|
if (only) return `${noun} of ${describeMapping(only[1])}`;
|
||||||
return `${mark} chart of ${config.datasetName}`;
|
return `${noun} of ${config.datasetName}`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user