mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart builder: data-aware defaults, one-click hint fixes, canvas preview, fullscreen modal
This commit is contained in:
@@ -289,10 +289,14 @@ 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,
|
> **Enhancement push (post-M4).** The forward plan now lives in
|
||||||
> temporal granularity, sort/orientation, cardinality-based warnings, Tier C
|
> [`docs/chart-builder-enhancement-scope.md`](chart-builder-enhancement-scope.md) — it merges
|
||||||
> intent-first) lives in [`docs/chart-builder-research.md`](chart-builder-research.md) §8
|
> the Tier-B backlog ([`chart-builder-research.md`](chart-builder-research.md) §8) with the
|
||||||
> — its single home, so these stop living in chat.
|
> Lyra interaction review ([`lyra-review.md`](lyra-review.md)) and sets a **Tier-C** target.
|
||||||
|
> Shipped beyond the Tier-B floor so far: per-channel aggregate/bin/`timeUnit`, sort/stack;
|
||||||
|
> **actionable hints** (one-click warning fixes); and a builder UX/perf batch (near-fullscreen
|
||||||
|
> modal, canvas preview + canvas max-dimension guard, data-aware default pre-population). See
|
||||||
|
> the scope doc §4 for the sequenced plan and current status.
|
||||||
|
|
||||||
**Core**
|
**Core**
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,9 @@ authoritative architecture for adding, opening, closing, and rendering modals.
|
|||||||
- **At most one modal open at a time** (mandated by the product spec). Opening a
|
- **At most one modal open at a time** (mandated by the product spec). Opening a
|
||||||
modal closes any other; the two never overlap.
|
modal closes any other; the two never overlap.
|
||||||
- **Uniform dismissal**: close button, `Escape`, or backdrop click — never a
|
- **Uniform dismissal**: close button, `Escape`, or backdrop click — never a
|
||||||
click inside the body.
|
click inside the body. A modal holding in-progress work can opt out of the
|
||||||
|
**backdrop** click (`dismissOnBackdrop: false`) so a stray click can't discard it
|
||||||
|
(the Chart Builder does); close button and `Escape` still dismiss.
|
||||||
- **Accessible by default**: focus moves into the modal on open and returns to
|
- **Accessible by default**: focus moves into the modal on open and returns to
|
||||||
the trigger on close.
|
the trigger on close.
|
||||||
- **Unsaved-change safety** for editing modals, with an explicit opt-out for
|
- **Unsaved-change safety** for editing modals, with an explicit opt-out for
|
||||||
@@ -492,6 +494,13 @@ export function useFocusTrap<T extends HTMLElement = HTMLDivElement>(
|
|||||||
- Don't dismiss on clicks inside the body, and don't let Escape fire when no
|
- Don't dismiss on clicks inside the body, and don't let Escape fire when no
|
||||||
modal is open (the handler only exists while a modal renders).
|
modal is open (the handler only exists while a modal renders).
|
||||||
|
|
||||||
|
**Sizing & backdrop opt-out.** The shell picks a **size tier** by modal: a small form
|
||||||
|
(Extract), a large two-pane manager (Datasets), or a near-fullscreen **work surface**
|
||||||
|
(Chart Builder — a config pane plus a chart that wants room). The two larger tiers have a
|
||||||
|
definite height so their inner panes scroll **internally** rather than the modal growing
|
||||||
|
past the viewport. A modal opts a backdrop click out of dismissal with the registry's
|
||||||
|
`dismissOnBackdrop: false` (above).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Confirmation & alert dialogs
|
## Confirmation & alert dialogs
|
||||||
|
|||||||
@@ -101,6 +101,23 @@ async function rerender(node: HTMLElement, spec: TopLevelSpec, config: Config) {
|
|||||||
|
|
||||||
- **Do** pass `actions: false`. Astrolabe owns its own export/copy affordances;
|
- **Do** pass `actions: false`. Astrolabe owns its own export/copy affordances;
|
||||||
the library's overlay menu does not belong on the preview.
|
the library's overlay menu does not belong on the preview.
|
||||||
|
- **SVG is the default renderer, canvas is an opt-in for many-mark previews.** SVG is
|
||||||
|
crisp/inspectable/copyable and stays the default for the editor's LivePreview. But an
|
||||||
|
SVG chart renders one DOM node per mark, so a many-mark chart (e.g. the Chart Builder's
|
||||||
|
default one-bar-per-row on a 10k-row dataset) costs **seconds** of main-thread
|
||||||
|
layout/paint per render (measured ~6.5s on 9994 rows; the chart paints _after_ it first
|
||||||
|
appears, freezing the tab). The **Chart Builder preview** therefore passes
|
||||||
|
`renderSpec(…, { renderer: 'canvas' })` — canvas is a single node and paints in
|
||||||
|
milliseconds. The raster trade-off is invisible for an ephemeral preview, and image
|
||||||
|
export (`view.toImageURL`) is renderer-agnostic.
|
||||||
|
- **Canvas has a hard max dimension; SVG doesn't.** A canvas larger than the browser's
|
||||||
|
limit (~32k px/side, less on Safari) fails to allocate and draws _nothing_ — silently.
|
||||||
|
So for canvas, `renderSpec` first runs a headless (`'none'`) layout probe, reads the
|
||||||
|
resolved height, and throws `ChartTooLargeError(heightPx, limitPx)` when it exceeds
|
||||||
|
`MAX_CANVAS_PX ÷ devicePixelRatio`, so the caller can show the real cause. This is a
|
||||||
|
**render-size** limit (the chart is physically too big), distinct from the readability
|
||||||
|
cardinality warnings — don't conflate them. Only an _unbounded_ axis overflows: a
|
||||||
|
`width: 'container'` axis is bounded, so it's the deleted (natural-height) axis to watch.
|
||||||
- **Do** call `view.finalize()` on every previous view before rendering a new
|
- **Do** call `view.finalize()` on every previous view before rendering a new
|
||||||
one, and on component unmount.
|
one, and on component unmount.
|
||||||
- **Do** keep exactly one live view per preview node.
|
- **Do** keep exactly one live view per preview node.
|
||||||
|
|||||||
@@ -301,8 +301,10 @@ trivially testable. The caller passes `null` for URL and non-tabular datasets.
|
|||||||
### 3.3 Column stats: cardinality + numeric extent
|
### 3.3 Column stats: cardinality + numeric extent
|
||||||
|
|
||||||
Alongside the display type, each column carries the two data-shape signals the
|
Alongside the display type, each column carries the two data-shape signals the
|
||||||
**Chart Builder** needs for its data-aware Tier-B hints (spec §06; see
|
**Chart Builder** needs for its data-aware Tier-B hints (`chart-builder.ts`
|
||||||
`chart-builder.ts` `builderWarnings`):
|
`builderWarnings`) **and** for its default pre-population (`smartDefaultEncodings`
|
||||||
|
prefers a low-cardinality category over a high-cardinality key, so the builder never
|
||||||
|
opens on a degenerate chart; spec §06):
|
||||||
|
|
||||||
- **`distinct`** — the count of distinct non-empty values **in the sample**,
|
- **`distinct`** — the count of distinct non-empty values **in the sample**,
|
||||||
counted only up to `DISTINCT_CAP` (50). Past the cap the exact number stops
|
counted only up to `DISTINCT_CAP` (50). Past the cap the exact number stops
|
||||||
|
|||||||
@@ -167,6 +167,18 @@ lives in [04 · Routing & Global Events](04-routing-and-events.md).
|
|||||||
uses `alert`/`status` roles by severity. Don't invent keyboard models; adopt the
|
uses `alert`/`status` roles by severity. Don't invent keyboard models; adopt the
|
||||||
documented one.
|
documented one.
|
||||||
|
|
||||||
|
**Resolved — a control that removes its own container.** When activating a control deletes
|
||||||
|
the element it lives in (e.g. a Chart Builder guidance hint's one-click **fix** button —
|
||||||
|
the hint re-derives away once applied), focus must not fall to `<body>`. The rule (council:
|
||||||
|
Carbon _Actionable notification_ + APG _Alert_): **announce the change politely and move
|
||||||
|
focus to a stable neighbour.** Concretely, the builder writes "Applied: `<label>`." to a
|
||||||
|
visually-hidden `role="status" aria-live="polite"` node and moves focus to the guidance
|
||||||
|
region if any hints remain, else the surrounding pane (`tabIndex={-1}` anchors, focused only
|
||||||
|
programmatically — no visible ring). Advisory hints themselves don't _grab_ focus (APG: an
|
||||||
|
alert "must not affect keyboard focus"); the fix's remedy lives in a **low-emphasis ghost
|
||||||
|
button** beside the advice (Carbon: inline actionable → ghost button, wraps under the body
|
||||||
|
on narrow widths), an _offer_, never a forced change.
|
||||||
|
|
||||||
**Resolved — pane resize handle (window splitter).** A `ResizeHandle` is a focusable
|
**Resolved — pane resize handle (window splitter).** A `ResizeHandle` is a focusable
|
||||||
`role="separator"` that **reports the controlled pane's size**, per APG → Window Splitter:
|
`role="separator"` that **reports the controlled pane's size**, per APG → Window Splitter:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
# Chart Builder — Enhancement Scope
|
||||||
|
|
||||||
|
> **Status:** scope consolidated 2026-06-10. This is the **single forward-looking home**
|
||||||
|
> for chart-builder enhancement work — it merges the research backlog from
|
||||||
|
> [`chart-builder-research.md`](./chart-builder-research.md) §8 (the M4 decision and its
|
||||||
|
> deferred items) with the interaction ideas from [`lyra-review.md`](./lyra-review.md) §5,
|
||||||
|
> read against the current spec ([`spec/06-chart-builder.md`](./spec/06-chart-builder.md))
|
||||||
|
> and the shipped code (`src/core/chart-builder.ts`).
|
||||||
|
>
|
||||||
|
> **Goal (the brief):** a **rapid, intuitive GUI for building Vega-Lite specs**, with
|
||||||
|
> **guidance and recommendations on the fly**, **moderately capable** — not a full
|
||||||
|
> visual-design IDE.
|
||||||
|
>
|
||||||
|
> **Decision (2026-06-10):** push the builder from its shipped **Tier B** ("smart +
|
||||||
|
> guarded") up to **Tier C** ("intent-first aid"). "Moderately capable" is the ceiling:
|
||||||
|
> we add the controls that are _both common and awkward in JSON_ and stop there; the long
|
||||||
|
> tail of styling/scale/axis breadth stays in Monaco. The two source docs remain the
|
||||||
|
> research record (the _why_ and the citations); this doc is the _plan_ (the _what next_
|
||||||
|
> and the _order_).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Status log
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
- **2026-06-10** — **Up next: 1B (per-chart export).**
|
||||||
|
- **1A · Actionable hints** shipped: one-click fixes on guidance warnings
|
||||||
|
(`BuilderWarning.fixes` + `applyWarningFix`), council-reviewed, with focus/announce a11y.
|
||||||
|
- **Builder UX/perf batch** (from dogfooding the Superstore dataset) shipped: near-fullscreen
|
||||||
|
`xlarge` modal tier; panes scroll internally (preview in its own viewport); backdrop-dismiss
|
||||||
|
guard (`dismissOnBackdrop: false`); render-timing diagnostics; **canvas** preview renderer
|
||||||
|
(SVG stays default elsewhere) + **canvas max-dimension guard** (`ChartTooLargeError` via a
|
||||||
|
headless probe); **data-aware default pre-population** (`smartDefaultEncodings`).
|
||||||
|
- Scope consolidated and **Tier-C target** set (this doc created); the research/Lyra forward
|
||||||
|
sequences are superseded by §4 here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Where the builder is today (the floor — don't rebuild)
|
||||||
|
|
||||||
|
Tier B is shipped and tested (M4 done). The intelligence that decides _which chart and
|
||||||
|
why_ already exists in `src/core/chart-builder.ts`:
|
||||||
|
|
||||||
|
- **Smart default mark** from the (X, Y) field-type shape — not unconditionally Bar
|
||||||
|
(`defaultMark`).
|
||||||
|
- **Valid-type-only** field-type menus per column + **Size discipline** (Nominal /
|
||||||
|
Temporal / negative-extent columns are _blocked_ on Size, not merely warned)
|
||||||
|
(`validFieldTypes`, `isChannelTypeAllowed`).
|
||||||
|
- **Non-blocking guidance** — `builderWarnings` (6 rules: line/area needs both axes,
|
||||||
|
all-categorical, two-measures-want-scatter, area-split-many-series, crowded category
|
||||||
|
axis, negative-size guard).
|
||||||
|
- **Per-channel transforms** — Aggregate / Bin / `timeUnit`, chart-level Sort / Stack,
|
||||||
|
and a field-less "Count of records" measure.
|
||||||
|
- **Swap X/Y**, debounced live preview, validation gate (≥1 channel mapped).
|
||||||
|
|
||||||
|
Everything in §3 below is **backlog** — verified not yet built: `BuilderWarning` has no
|
||||||
|
`fix` field, there is no per-chart export, no top-level `transform`, no data-table
|
||||||
|
preview, no channels beyond X/Y/Color/Size, no styling/scale controls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. The target experience (Tier C, concretely)
|
||||||
|
|
||||||
|
Reading the brief's four words against the research:
|
||||||
|
|
||||||
|
| Brief word | What it means here | The levers (from §3) |
|
||||||
|
| ----------------------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| **Rapid** | shortest path from "a dataset" to "a chart I'd keep" | smart defaults (have), intent front door, starter examples, field shelf |
|
||||||
|
| **Intuitive** | matches how people think ("I have fields; what shows my point?") | intent front door, field shelf, data preview, value-or-field channels |
|
||||||
|
| **Guidance & recommendations on the fly** | the app proposes and corrects, not just validates | **actionable hints**, **intent front door** (the defining Tier-C feature) |
|
||||||
|
| **Moderately capable** | covers the common data-shaping + a few high-value encodings; _not_ every knob | filter / calculate, a _small_ set of promoted controls — and a hard stop short of Lyra's everything-inspector |
|
||||||
|
|
||||||
|
The defining shift from B→C is the **intent-first front door**: a _"what do you want to
|
||||||
|
show?"_ entry (FT / Datawrapper intent categories) that recommends a mark + channel layout
|
||||||
|
from `intent × column-types`, instead of starting the user at a blank mark picker. It is
|
||||||
|
the feature the brief most directly asks for, and it is the one piece the M4 research
|
||||||
|
_deferred_. Choosing Tier C is choosing to pull it forward.
|
||||||
|
|
||||||
|
Tier C **extends** §06 — it does not replace the mark-first builder. The front door is an
|
||||||
|
on-ramp; the user can still ignore it and drive the channels directly, and can always drop
|
||||||
|
to Monaco. This preserves Astrolabe's core invariant: **the JSON spec is the source of
|
||||||
|
truth; the builder is a view that emits it** (the Lyra anti-lesson — never let the GUI
|
||||||
|
become the document).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The consolidated enhancement set
|
||||||
|
|
||||||
|
Organized into build phases by dependency and value. Each item carries its **source**,
|
||||||
|
**value/effort**, **code home**, and **spec impact**. Phases 1–3 are the committed Tier-C
|
||||||
|
scope; Phase 4 is explicitly _beyond_ "moderately capable" and gated on a later decision.
|
||||||
|
|
||||||
|
### Phase 1 — Guidance + I/O (no new interaction model) — _do first_
|
||||||
|
|
||||||
|
High value-to-effort, mostly pure-core + thin UI, no architectural change. These make the
|
||||||
|
_current_ builder dramatically better and de-risk the bigger phases.
|
||||||
|
|
||||||
|
**1A · Actionable hints** — _done (2026-06-10)_
|
||||||
|
Turn advisory warnings into one-click fixes. Source: Lyra §3.1 (`Hints` carries an
|
||||||
|
`action`). Several existing warnings have an obvious remedy:
|
||||||
|
|
||||||
|
- _"draws one mark per row → "_ **[Aggregate as Sum]** (set the measure's `aggregate`)
|
||||||
|
- _"long labels → "_ **[Swap X/Y]** (the action already exists — just wire it)
|
||||||
|
- _"two measures usually read as a scatter → "_ **[Switch to Point]**
|
||||||
|
- _"area split into many series → "_ **[Stack]** or **[Remove colour]**
|
||||||
|
|
||||||
|
Implementation: extended `BuilderWarning` with `fixes?: BuilderWarningFix[]` (`{ label, apply }`, pure,
|
||||||
|
unit-tested in `chart-builder.test.ts`); the modal renders each as a ghost button wired to a
|
||||||
|
new `applyWarningFix` store action. Wired fixes: **[Aggregate as Sum]** + **[Swap X/Y]**
|
||||||
|
(one-mark-per-row), **[Swap X/Y]** (high-cardinality axis), **[Switch to Point]** (two
|
||||||
|
measures), **[Stack]** + **[Remove colour]** (area split — and a stacked area is no longer
|
||||||
|
flagged, so [Stack] resolves it). Council run (Carbon Actionable notification + APG Alert):
|
||||||
|
ghost buttons, remedy-in-button, polite "Applied: …" announcement, focus moved off the
|
||||||
|
removed button — resolution recorded in `architecture/10` §5. §06 "Guidance" amended to
|
||||||
|
document the one-click fixes.
|
||||||
|
|
||||||
|
**1B · Per-chart export** — _highest value-to-effort overall_
|
||||||
|
Today export is workspace-backup only (§08); there is **no way to get one chart out**.
|
||||||
|
Source: Lyra §3.8. The renderer already holds the live Vega `view`
|
||||||
|
(`services/chart-renderer.ts`), so this is small:
|
||||||
|
|
||||||
|
- **Copy spec** (clipboard) + **Download `.vl.json`** for the active snippet (trivial — the
|
||||||
|
snippet _is_ the spec)
|
||||||
|
- **Download PNG / SVG** via `view.toImageURL('png' | 'svg')`
|
||||||
|
- _(optional)_ standalone HTML (ties to the BYO-cloud "private-move" direction)
|
||||||
|
|
||||||
|
Home: a **snippet-level** "Export / Share" affordance (library row action or editor
|
||||||
|
toolbar), distinct from the workspace Export. _Spec impact: new export surface in §08;
|
||||||
|
arguably §02/§03 (where the affordance lives)._ Note: not strictly a _builder_ feature, but
|
||||||
|
the biggest single miss adjacent to it — sequence it here.
|
||||||
|
|
||||||
|
**1C · Filter (+ Calculate) dataset transforms** — _closes the loop the builder's own warnings open_
|
||||||
|
The transform layer the builder doesn't touch: top-level `transform: []`. Source: Lyra
|
||||||
|
§3.12. The builder _already tells users to filter_ in three warnings
|
||||||
|
(`chart-builder.ts:475,476,511`) while offering no way to do it.
|
||||||
|
|
||||||
|
- **Filter** _(highest)_ — a guarded **field + operator + value** predicate shelf
|
||||||
|
(Voyager-style, no expression needed for the common case); power form is a raw `datum.…`
|
||||||
|
expression validated with Vega's `parseExpr` (see 1E). VL applies top-level transforms
|
||||||
|
_before_ encoding aggregation, so "filter raw rows, then aggregate" is the natural
|
||||||
|
default; filtering on an aggregated value (HAVING) is the advanced case — defer.
|
||||||
|
- **Calculate / derived field** _(second)_ — `transform: [{calculate, as}]`; the new field
|
||||||
|
then appears in column dropdowns like any other.
|
||||||
|
- **Lookup / join a second dataset** — larger data-model change (one dataset per snippet
|
||||||
|
today) → **defer to Phase 4**.
|
||||||
|
|
||||||
|
Home: a new **"Data" section in the builder's left pane, above the channels** (`here are
|
||||||
|
your rows [+ Filter] [+ Calculate] → now encode them`), paired with 1D. _Spec impact: new
|
||||||
|
§06 "Data / transforms" subsection — this is genuinely new behaviour, write it._
|
||||||
|
|
||||||
|
**1D · Data-table preview** — _closes a confirmed gap; pairs with 1C_
|
||||||
|
Neither the builder nor the Datasets manager ever shows the **actual rows**. Source: Lyra
|
||||||
|
§3.2. A compact, **read-only** first-N-rows grid with a **per-column type chip** in each
|
||||||
|
header lets users sanity-check inferred types _before_ building — exactly when inference is
|
||||||
|
most likely to surprise. Type + cardinality + extent already come from `profile.ts`; we
|
||||||
|
only need the row sample. Homes: a collapsible "Data" strip in the builder's left pane
|
||||||
|
(under the dataset name) and/or the Datasets manager. Keep it read-only (editing data is
|
||||||
|
out of scope). _Spec impact: §06 + §05 (Datasets) additions._
|
||||||
|
|
||||||
|
**1E · Inline expression validation + field autocomplete** — _build with 1C, not standalone_
|
||||||
|
When 1C's expression mode lands, validate the Vega/VL expression string with the library's
|
||||||
|
own `parseExpr` (Lyra §3.6) and surface errors inline; autocomplete the dataset's own
|
||||||
|
column names (we have the schema from `profile.ts`) (Lyra §3.10). Record the `parseExpr`
|
||||||
|
technique in `architecture/08`. _Spec impact: folded into 1C._
|
||||||
|
|
||||||
|
### Phase 2 — Interaction substrate (enables Tier C)
|
||||||
|
|
||||||
|
Two changes to _how the user touches fields_. They have standalone value but their main job
|
||||||
|
is to be the substrate Phase 3 (and any future added channels) stands on — sequence them
|
||||||
|
here so Tier C lands cleanly.
|
||||||
|
|
||||||
|
**2A · Value-or-field channels (the Property model)** — _capability gain; medium_
|
||||||
|
Source: Lyra §3.3 (`Property.tsx` — one droppable control that is _either_ a literal value
|
||||||
|
_or_ a bound field). Today a channel is field-only. Let a channel also hold a **constant
|
||||||
|
`value`** (fixed colour / size) with one consistent control and a chip showing the binding
|
||||||
|
kind. VL encodes exactly this (`field` vs `value` vs `datum`). Generalizes cleanly to any
|
||||||
|
channel we add later. _Spec impact: §06 "Encoding channels" — a channel may carry a
|
||||||
|
constant._
|
||||||
|
|
||||||
|
**2B · Field shelf + in-place type cycling** — _the largest interaction shift; the Tier-C/facet substrate_
|
||||||
|
Source: Lyra §3.4 (drop-zones) + Voyager (field list with type chips) + Lyra §3.5
|
||||||
|
(`FieldType` — the type icon _is_ the control, click cycles N→O→Q→T within the valid set).
|
||||||
|
Flip from **channel-first** ("pick a channel, then its column") to **field-first** ("here
|
||||||
|
are your columns — drag/click onto channels"), matching how people think. This is the
|
||||||
|
natural way to assign many fields across many channels, so it is the **interaction
|
||||||
|
substrate for Tier C and faceting**, not a standalone task. _Spec impact: §06 layout
|
||||||
|
revision (field shelf alongside the channel rows)._
|
||||||
|
|
||||||
|
### Phase 3 — Tier C (the intent-first front door) — _the defining feature of this push_
|
||||||
|
|
||||||
|
**3A · Intent-first front door** — _the B→C step_
|
||||||
|
Source: research §5/§8 Tier C (FT Visual Vocabulary + Datawrapper intent taxonomy). A
|
||||||
|
_"what do you want to show?"_ entry mapping **intent × column types → recommended mark +
|
||||||
|
channel layout**:
|
||||||
|
|
||||||
|
| Intent (FT / Datawrapper) | Our expression (within 5 marks / our channels) |
|
||||||
|
| -------------------------- | ------------------------------------------------------------------- |
|
||||||
|
| **Magnitude / Comparison** | Bar (x=N, y=Q; horizontal for long labels) |
|
||||||
|
| **Ranking** | Bar, sorted by value |
|
||||||
|
| **Change over time** | Line (x=T, y=Q; color=N for series) |
|
||||||
|
| **Correlation** | Point (x=Q, y=Q); Circle + size=Q for a 3rd measure |
|
||||||
|
| **Distribution** | Bar of binned counts (histogram) — uses Bin |
|
||||||
|
| **Part-to-whole** | Stacked / 100% bar (uses Stack); _true pie needs `theta` → Phase 4_ |
|
||||||
|
| **Deviation** | diverging signed Bar |
|
||||||
|
|
||||||
|
Honest coverage gaps stay honest (Spatial / Flow excluded; Part-to-whole partial until
|
||||||
|
`theta`). The front door is an **on-ramp, not a gate** — it pre-populates the mark-first
|
||||||
|
builder, which the user can then adjust or ignore. Munzner's typology and Wilke's directory
|
||||||
|
become seatable council sources at this point (research §2). Built **on the 2B field
|
||||||
|
shelf**. **Run the front-door copy + flow through `/council`.** _Spec impact: substantial
|
||||||
|
§06 amendment — a new "Intent" front-door subsection; the M4 spec note explicitly flagged
|
||||||
|
this as the deferred tier, so this is the planned amendment, not drift._
|
||||||
|
|
||||||
|
**3B · Starter examples gallery** — _cheap; pairs with 3A_
|
||||||
|
Source: Lyra §3.7. A small set of **curated starter snippets**, one per covered FT intent
|
||||||
|
(Magnitude/Bar, Change-over-time/Line, Correlation/Point, Distribution/histogram,
|
||||||
|
Part-to-whole/stacked). Improves first-run, doubles as living documentation of what the app
|
||||||
|
does well. Natural home: the snippet library. _Spec impact: §02 (library seed content)._
|
||||||
|
|
||||||
|
### Builder UX & perf — in-flight fixes (2026-06-10, from dogfooding the Superstore dataset)
|
||||||
|
|
||||||
|
Pre-existing builder rough edges surfaced while testing on a 10k-row / ~24-col dataset.
|
||||||
|
Fixed in this batch (not part of 1A–3B, but the same surface):
|
||||||
|
|
||||||
|
- **Modal is a near-fullscreen work surface** — new `xlarge` shell tier (`ModalShell`,
|
||||||
|
`min(1800px, 96vw) × min(1100px, 92vh)`); the Chart Builder no longer wastes screen.
|
||||||
|
- **Panes scroll internally, modal keeps its shape** — the `.builder` grid fills the body
|
||||||
|
(`grid-template-rows: minmax(0,1fr)`), the config pane and the **preview** each scroll in
|
||||||
|
their own viewport, so a tall one-mark-per-row chart scrolls inside the preview instead of
|
||||||
|
pushing Create/Cancel below the fold.
|
||||||
|
- **Backdrop click no longer discards in-progress work** — `dismissOnBackdrop: false` on the
|
||||||
|
builder (registry flag); Escape and × still close.
|
||||||
|
- **Data-aware default pre-population** — the builder no longer blindly takes the first two
|
||||||
|
columns (which opened Superstore on a 9994-bar degenerate chart). When the dataset is
|
||||||
|
profiled, `defaultBuilderConfig` picks a "safest bet": a low-cardinality category vs a
|
||||||
|
count of records (tidy bar), else a time series of the first measure, else a scatter — each
|
||||||
|
guaranteed to render. Falls back to positional when there are no stats. Pure + tested. This
|
||||||
|
composes with (isn't replaced by) the future intent-first front door — the builder always
|
||||||
|
needs a sane opening state.
|
||||||
|
- **Render-timing diagnostics** — `BuilderPreview` logs `parse · prepare · destroy · embed ·
|
||||||
|
paint · total` (+ mark, row count) to the console (dev always; prod only when slow). The
|
||||||
|
**paint** phase (a double-rAF after `embed()`) captures the real freeze.
|
||||||
|
|
||||||
|
**Perf finding — confirmed and fixed.** Diagnostics on the Superstore dataset:
|
||||||
|
`embed 237ms · paint 6458ms` for the default one-bar-per-row chart (9994 rows), vs
|
||||||
|
`paint 6ms` once grouped to a few categories. The freeze was entirely **SVG layout/paint**
|
||||||
|
(one DOM node per mark), not chart compilation. **Fix shipped:** the builder preview now
|
||||||
|
renders with **canvas** (`renderSpec(…, { renderer: 'canvas' })`); SVG stays the default for
|
||||||
|
the editor's LivePreview and for image export. Contract divergence recorded in
|
||||||
|
`architecture/05` §2.
|
||||||
|
|
||||||
|
**Canvas max-dimension guard (measured, not guessed).** Canvas (unlike SVG) has a hard
|
||||||
|
max side (~32k px), so a chart that resolves taller than that fails to allocate (the
|
||||||
|
broken-image icon). The cause is **physical render size, not cardinality** — a vertical bar
|
||||||
|
with thousands of _X_ bands renders fine (width is container-bounded); only an unbounded
|
||||||
|
band axis (e.g. a horizontal bar's _Y_) overflows. So `renderSpec` now runs a **headless
|
||||||
|
(`'none'`) layout probe** for canvas charts, reads the chart's resolved **height**, and
|
||||||
|
throws `ChartTooLargeError(heightPx, limitPx)` when it exceeds `MAX_CANVAS_PX ÷ dpr`. The
|
||||||
|
builder catches it and shows the real numbers ("would be ~200,000px tall — larger than the
|
||||||
|
browser can draw on a canvas (~16,383px max here); aggregate or filter"). The earlier
|
||||||
|
band-count proxy (`previewBandCount`/`MAX_PREVIEW_BANDS`) was removed — readability
|
||||||
|
(cardinality) stays a `builderWarnings` concern; the render-size limit is now measured at
|
||||||
|
its true cause.
|
||||||
|
|
||||||
|
### Phase 4 — Beyond "moderately capable" (gated — decide later)
|
||||||
|
|
||||||
|
These exceed the stated ceiling. List them so they have a home, but **do not commit them in
|
||||||
|
this push** — revisit once Phases 1–3 land and we see real usage. Each must clear the
|
||||||
|
guardrail: _promote a control only when it is **both common AND awkward in JSON**._
|
||||||
|
|
||||||
|
- **More channels** — `theta` (unlocks pie/donut → _true_ part-to-whole), `opacity`,
|
||||||
|
`shape`. (`theta` is the most defensible — it closes a real coverage gap.) Ride on 2A/2B.
|
||||||
|
- **Faceting (Row / Column → small multiples)** — research §8 B8. The clean way to compare
|
||||||
|
many categories. Design crux: VL facets default to **shared scales** (keep that default);
|
||||||
|
expose an "independent axes" toggle (`resolve.scale`) only as advanced. **Verify against
|
||||||
|
the preview's `"container"` fit modes** (per-cell sizing on facets is finicky).
|
||||||
|
- **Light styling / scale controls** — colour-scheme picker (categorical / sequential /
|
||||||
|
diverging), measure-axis `zero` / `log` toggle, custom axis title, legend title / hide.
|
||||||
|
Implement as **auto-derived override panels** that start empty (inheriting VL defaults)
|
||||||
|
and emit JSON **only when touched**; clearing a channel **drops its overrides** (Lyra
|
||||||
|
§3.9 `cleanupUnused` — no orphaned `scale`/`axis` in the output spec).
|
||||||
|
- **Builder undo/redo / "reset to smart defaults"** — Lyra §3.11. Low priority (the modal
|
||||||
|
is short-lived; the smart default already gives a sane start).
|
||||||
|
- **Lookup / join a second dataset** — Lyra §3.12; data-model change (multi-dataset
|
||||||
|
snippets). Larger, separate effort.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Recommended build order
|
||||||
|
|
||||||
|
```
|
||||||
|
Phase 1 1A actionable hints ✓ done
|
||||||
|
1B per-chart export ← next: highest value-to-effort
|
||||||
|
1C filter (+ calculate) ← closes the loop on warnings the builder already emits
|
||||||
|
1D data preview ← pairs with 1C
|
||||||
|
1E expr-validate + autocomplete (with 1C)
|
||||||
|
Phase 2 2A value-or-field channels (Property model)
|
||||||
|
2B field shelf + in-place type cycling ← Tier-C substrate
|
||||||
|
Phase 3 3A intent-first front door (Tier C) ← built on 2B; the defining feature
|
||||||
|
3B starter examples
|
||||||
|
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
|
||||||
|
panes, backdrop-dismiss guard, render diagnostics, canvas preview + canvas-size guard,
|
||||||
|
data-aware default pre-population. See "Builder UX & perf" above.
|
||||||
|
```
|
||||||
|
|
||||||
|
Rationale for the order: Phase 1 is the cheapest large quality jump and needs no new
|
||||||
|
interaction model, so it ships value while the bigger design settles. Phase 2 is pure
|
||||||
|
substrate — low _user-visible_ payoff alone, but Phase 3 is much cleaner on top of it than
|
||||||
|
bolted onto the channel-first UI. Phase 3 delivers the brief's headline ("recommendations
|
||||||
|
on the fly"). Phase 4 is deliberately deferred to protect the "moderately capable" ceiling.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Constraints & anti-scope (the ceiling)
|
||||||
|
|
||||||
|
What "moderately capable" rules **out** — load-bearing, from `lyra-review.md` §2.1/§4:
|
||||||
|
|
||||||
|
- **No everything-inspector.** Lyra surfaces ~50 direct controls per primitive because the
|
||||||
|
GUI _was_ its document. We split the work on purpose: a small **guarded** builder + a
|
||||||
|
first-class **Monaco** editor for the long tail. Styling/scale/axis breadth for its own
|
||||||
|
sake belongs in Monaco, not the builder.
|
||||||
|
- **The promotion test:** a control enters the builder only when it is **both common AND
|
||||||
|
awkward in JSON**. Otherwise it stays in Monaco.
|
||||||
|
- **JSON stays the source of truth.** The builder _emits_ spec; it is never the document
|
||||||
|
(the Lyra one-way-export trap that forbids round-trips).
|
||||||
|
- **No interaction-by-demonstration, no direct-manipulation canvas, no general
|
||||||
|
data-pipeline editor.** If we ever add interactivity, expose VL `params`/selections as a
|
||||||
|
small guarded action — never port Lyra's signal generator.
|
||||||
|
- **Take ideas from the reference clones, read no code into the repo** (`AGENTS.md`: no
|
||||||
|
shared lib; patterns adapted, not imported).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Cross-cutting notes
|
||||||
|
|
||||||
|
- **Spec deltas:** 1A (minor §06), 1B (§08 + §02/§03), 1C (new §06 transforms subsection),
|
||||||
|
1D (§06 + §05), 2A/2B (§06 layout), 3A (substantial §06 intent front-door amendment), 3B
|
||||||
|
(§02). Per _"spec follows code now"_: build the decision, then amend the spec to match —
|
||||||
|
don't let code and §06 drift.
|
||||||
|
- **Council:** auto-fires on guidance copy and new interactive-widget keyboard/focus work —
|
||||||
|
so 1A (hint affordance + copy) and 3A (front-door flow + copy) both go through `/council`
|
||||||
|
before committing. It advises; architecture 09/10 decide.
|
||||||
|
- **Verification:** pure rules get `chart-builder.test.ts` cases; every UI/affordance change
|
||||||
|
gets a manual pass against the _live_ builder — a green build proves nothing about what
|
||||||
|
the user sees (`AGENTS.md`; `docs/manual-verification.md`). Items 1A and 1C are mostly
|
||||||
|
`src/core/`, squarely the "core-first, tested hardest" rule.
|
||||||
|
- **Source of truth going forward:** this doc. `chart-builder-research.md` §8 and
|
||||||
|
`lyra-review.md` §5 remain the _research record_; their forward sequences are superseded
|
||||||
|
by §4 here.
|
||||||
@@ -201,8 +201,13 @@ The highest-value guardrails — encodings a naive UI emits that the canon rejec
|
|||||||
## 8. Future enhancements (backlog)
|
## 8. Future enhancements (backlog)
|
||||||
|
|
||||||
The Tier-B build is the floor, not the ceiling. The enhancements below were surfaced by
|
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
|
the research. Status as of 2026-06-06.
|
||||||
as of 2026-06-06.
|
|
||||||
|
> **Forward plan moved (2026-06-10):** the _prioritized, sequenced_ enhancement plan now
|
||||||
|
> lives in [`chart-builder-enhancement-scope.md`](./chart-builder-enhancement-scope.md),
|
||||||
|
> which merges this backlog with the [`lyra-review.md`](./lyra-review.md) §5 ideas and sets
|
||||||
|
> the **Tier-C** target. This section remains the research _record_ (the citations behind
|
||||||
|
> each item); consult the scope doc for _what to build next and in what order_.
|
||||||
|
|
||||||
**A · Cheap wins inside the current 5-mark / 4-channel scope**
|
**A · Cheap wins inside the current 5-mark / 4-channel scope**
|
||||||
|
|
||||||
|
|||||||
@@ -350,6 +350,12 @@ defining features are traps for us.
|
|||||||
|
|
||||||
## 5. Recommended sequence (mapped to the existing backlog)
|
## 5. Recommended sequence (mapped to the existing backlog)
|
||||||
|
|
||||||
|
> **Consolidated (2026-06-10):** this sequence is now merged with
|
||||||
|
> `chart-builder-research.md` §8 into the forward plan at
|
||||||
|
> [`chart-builder-enhancement-scope.md`](./chart-builder-enhancement-scope.md) (Tier-C
|
||||||
|
> target). The list below is the original Lyra-side reasoning; the scope doc §4 is the
|
||||||
|
> authoritative build order.
|
||||||
|
|
||||||
Slot these into `chart-builder-research.md` §8 rather than inventing a new track:
|
Slot these into `chart-builder-research.md` §8 rather than inventing a new track:
|
||||||
|
|
||||||
1. **Actionable hints** (§3.1) — extend `BuilderWarning` with an optional pure `fix`; wire the
|
1. **Actionable hints** (§3.1) — extend `BuilderWarning` with an optional pure `fix`; wire the
|
||||||
|
|||||||
@@ -56,11 +56,13 @@ These controls appear only when they apply:
|
|||||||
|
|
||||||
### 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 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.
|
- On open, the builder chooses a **data-aware "safest bet"** so it never opens on a degenerate, unrenderable chart (e.g. a 10k-row dataset whose first two columns are an id and a high-cardinality key would otherwise draw one bar per row). When the dataset is profiled (per-column cardinality available), it prefers, in order: a **low-cardinality category vs a count of records** (a tidy bar); else a **time series** of the first measure over a date; else a **scatter** of two measures. Each is guaranteed to render and read cleanly. The measure for the category case is the field-less **count** deliberately — it is always meaningful and avoids summing an id-like numeric (e.g. a Row ID) into nonsense.
|
||||||
|
- When the dataset carries no cardinality stats (older or URL-backed datasets), it falls back to the original positional rule: the first detected column on **X** and the second (if any) on **Y**, each with its derived field type.
|
||||||
|
- Either way, remaining channels start unmapped with no transforms, and the mark starts at the smart default for the resulting X/Y shape (see _Mark type_), not unconditionally Bar. The intent-first front door (future) layers richer recommendations on top of this default; it does not replace the need for a sane opening state.
|
||||||
|
|
||||||
### Guidance (non-blocking)
|
### Guidance (non-blocking)
|
||||||
|
|
||||||
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). 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** or **Area** 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).
|
||||||
|
|||||||
@@ -3,7 +3,13 @@
|
|||||||
.builder {
|
.builder {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(320px, 360px) 1fr;
|
grid-template-columns: minmax(320px, 360px) 1fr;
|
||||||
min-height: 480px;
|
/* Fill the modal body and never exceed it, so each pane scrolls internally rather
|
||||||
|
than the whole modal growing past the viewport (otherwise a tall chart pushes the
|
||||||
|
Create/Cancel actions below the fold). The `minmax(0, 1fr)` row lets the panes
|
||||||
|
shrink below their content height so their own overflow kicks in. */
|
||||||
|
grid-template-rows: minmax(0, 1fr);
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,6 +30,7 @@
|
|||||||
border-right: var(--border-width) solid var(--border);
|
border-right: var(--border-width) solid var(--border);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.datasetName {
|
.datasetName {
|
||||||
@@ -213,6 +220,13 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* These take focus only programmatically (tabIndex -1) after a hint fix is applied,
|
||||||
|
to keep focus off <body>; no visible ring for that script-driven move. */
|
||||||
|
.warnings:focus,
|
||||||
|
.configPane:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.warning {
|
.warning {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -230,6 +244,42 @@
|
|||||||
color: var(--support-warning-fg);
|
color: var(--support-warning-fg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The hint text and its one-click remedies stacked, growing beside the icon. */
|
||||||
|
.warningBody {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actionable-hint remedies: each fix is an offer, never forced, so they're
|
||||||
|
low-emphasis ghost buttons — suggestions beside the advice, not commands. */
|
||||||
|
.warningFixes {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warningFix {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
border: var(--border-width) solid var(--border-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warningFix:hover {
|
||||||
|
background: var(--layer-01);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warningFix:focus-visible {
|
||||||
|
outline: 2px solid var(--focus);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Explains the disabled Create action (contract 10: a disabled control must say
|
/* 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. */
|
why). `margin-top: auto` pins it just above the actions so the two read as one. */
|
||||||
.createHint {
|
.createHint {
|
||||||
@@ -292,6 +342,8 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: var(--space-5);
|
padding: var(--space-5);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,9 +357,14 @@
|
|||||||
.previewFrame {
|
.previewFrame {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
/* A chart taller/wider than the pane scrolls *here*, inside a fixed viewport, so
|
||||||
justify-content: center;
|
the modal keeps its shape. `safe` centring aligns to the start instead of
|
||||||
|
clipping the top/left when the chart overflows. */
|
||||||
|
overflow: auto;
|
||||||
|
align-items: safe center;
|
||||||
|
justify-content: safe center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.previewHost {
|
.previewHost {
|
||||||
|
|||||||
@@ -8,11 +8,26 @@ import { useSnippetStore } from '../stores/SnippetStore';
|
|||||||
import { ChartBuilderModal } from './ChartBuilderModal';
|
import { ChartBuilderModal } from './ChartBuilderModal';
|
||||||
|
|
||||||
// The builder preview embeds a real Vega chart in an effect; stub the renderer so
|
// The builder preview embeds a real Vega chart in an effect; stub the renderer so
|
||||||
// this render test stays a pure React/DOM check (the loop we guard against happens
|
// these tests stay pure React/DOM checks. `renderSpec` is a vi.fn so a test can make
|
||||||
// during commit, long before any chart is drawn).
|
// it reject (e.g. the canvas-too-large path); the mocked `ChartTooLargeError` is the
|
||||||
vi.mock('../services/chart-renderer', () => ({
|
// same class the component imports, so its `instanceof` check matches. The class is
|
||||||
renderSpec: () => Promise.resolve({ destroy() {}, resize() {} }),
|
// declared inside the factory because vi.mock is hoisted above module-scope code.
|
||||||
}));
|
vi.mock('../services/chart-renderer', () => {
|
||||||
|
class ChartTooLargeError extends Error {
|
||||||
|
heightPx: number;
|
||||||
|
limitPx: number;
|
||||||
|
constructor(heightPx: number, limitPx: number) {
|
||||||
|
super('too large');
|
||||||
|
this.name = 'ChartTooLargeError';
|
||||||
|
this.heightPx = heightPx;
|
||||||
|
this.limitPx = limitPx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
renderSpec: vi.fn(() => Promise.resolve({ destroy() {}, resize() {} })),
|
||||||
|
ChartTooLargeError,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// React 19 wants this flag set for act() to drive effects without warnings.
|
// React 19 wants this flag set for act() to drive effects without warnings.
|
||||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||||
@@ -69,6 +84,77 @@ describe('ChartBuilderModal', () => {
|
|||||||
expect(container.textContent).toContain('scatter'); // the guidance hint rendered
|
expect(container.textContent).toContain('scatter'); // the guidance hint rendered
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a guidance hint offers a one-click fix that resolves it (actionable hints, §06)', async () => {
|
||||||
|
const ds = createDataset({
|
||||||
|
name: 'Nums',
|
||||||
|
data: [
|
||||||
|
{ a: 1, b: 2 },
|
||||||
|
{ a: 3, b: 4 },
|
||||||
|
],
|
||||||
|
format: 'json',
|
||||||
|
source: 'inline',
|
||||||
|
now: T,
|
||||||
|
});
|
||||||
|
useDatasetStore.getState().add(ds);
|
||||||
|
const id = useDatasetStore.getState().datasets[0].id;
|
||||||
|
useChartBuilderStore.getState().init(id);
|
||||||
|
useChartBuilderStore.getState().setMark('bar'); // two measures on a bar → scatter hint
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<ChartBuilderModal />);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The hint renders a [Switch to Point] button (not just prose).
|
||||||
|
const fixButton = Array.from(container.querySelectorAll('button')).find(
|
||||||
|
(b) => b.textContent === 'Switch to Point',
|
||||||
|
);
|
||||||
|
expect(fixButton).toBeDefined();
|
||||||
|
expect(container.textContent).toContain('scatter');
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fixButton!.click();
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Applying it switches the mark and the hint re-derives away.
|
||||||
|
expect(useChartBuilderStore.getState().config.mark).toBe('point');
|
||||||
|
expect(container.textContent).not.toContain('scatter');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows the canvas-limit message when the chart resolves too large to render', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { renderSpec, ChartTooLargeError } = await import('../services/chart-renderer');
|
||||||
|
vi.mocked(renderSpec).mockRejectedValueOnce(new ChartTooLargeError(200_000, 16_383));
|
||||||
|
|
||||||
|
const ds = createDataset({
|
||||||
|
name: 'Big',
|
||||||
|
data: [
|
||||||
|
{ a: 1, b: 'x' },
|
||||||
|
{ a: 2, b: 'y' },
|
||||||
|
],
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
// Drive the debounced render so renderSpec runs and rejects with the limit error.
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(container.textContent).toContain('larger than the browser can draw on a canvas');
|
||||||
|
expect(container.textContent).toContain('200,000'); // the measured height
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
test('shows the empty state when no dataset is loaded', async () => {
|
test('shows the empty state when no dataset is loaded', async () => {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.render(<ChartBuilderModal />);
|
root.render(<ChartBuilderModal />);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
supportsTimeUnit,
|
supportsTimeUnit,
|
||||||
validFieldTypes,
|
validFieldTypes,
|
||||||
type AggregateOp,
|
type AggregateOp,
|
||||||
|
type BuilderWarningFix,
|
||||||
type ChannelMapping,
|
type ChannelMapping,
|
||||||
type ChannelName,
|
type ChannelName,
|
||||||
type FieldType,
|
type FieldType,
|
||||||
@@ -42,7 +43,7 @@ import {
|
|||||||
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';
|
||||||
import { chartConfigFor } from '@core/vega-themes';
|
import { chartConfigFor } from '@core/vega-themes';
|
||||||
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
|
import { ChartTooLargeError, renderSpec, type RenderHandle } from '../services/chart-renderer';
|
||||||
import { closeModal } from '../modals/ModalCoordinator';
|
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';
|
||||||
@@ -58,6 +59,35 @@ import styles from './ChartBuilderModal.module.css';
|
|||||||
|
|
||||||
const RENDER_DEBOUNCE_MS = 300;
|
const RENDER_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render-timing diagnostics for the builder preview. A many-mark chart (e.g. the
|
||||||
|
* default one-bar-per-row on a 10k-row dataset) is cheap to compile but expensive
|
||||||
|
* for the browser to lay out as **SVG**, and that cost lands *after* `embed()`
|
||||||
|
* resolves, in the next paint — the chart appears, then the tab freezes for a moment.
|
||||||
|
* Each phase is timed, including that post-embed paint (a double rAF lands just after
|
||||||
|
* it), so the numbers attribute the cost to layout rather than chart compilation.
|
||||||
|
* Logged in dev always; in prod only when a render is slow.
|
||||||
|
*/
|
||||||
|
const SLOW_RENDER_MS = 250;
|
||||||
|
function logBuilderRenderTiming(t: {
|
||||||
|
parse: number;
|
||||||
|
prepare: number;
|
||||||
|
destroy: number;
|
||||||
|
embed: number;
|
||||||
|
paint: number;
|
||||||
|
total: number;
|
||||||
|
}): void {
|
||||||
|
const total = Math.round(t.total);
|
||||||
|
if (!import.meta.env.DEV && total < SLOW_RENDER_MS) return;
|
||||||
|
const ms = (n: number) => Math.round(n);
|
||||||
|
const { rowCount, config } = useChartBuilderStore.getState();
|
||||||
|
console.info(
|
||||||
|
`[chart-builder] render ${total}ms — parse ${ms(t.parse)} · prepare ${ms(t.prepare)} · ` +
|
||||||
|
`destroy ${ms(t.destroy)} · embed ${ms(t.embed)} · paint ${ms(t.paint)} ` +
|
||||||
|
`(mark=${config.mark}, rows=${rowCount ?? 'n/a'})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Title-case a token for display (e.g. `bar` → `Bar`, `sum` → `Sum`). */
|
/** 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);
|
||||||
@@ -264,6 +294,9 @@ function BuilderPreview() {
|
|||||||
const handleRef = useRef<RenderHandle | null>(null);
|
const handleRef = useRef<RenderHandle | null>(null);
|
||||||
const generationRef = useRef(0);
|
const generationRef = useRef(0);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// Set when the chart resolves larger than the canvas backend can draw — a
|
||||||
|
// physical render-size limit, distinct from the readability cardinality warnings.
|
||||||
|
const [tooLarge, setTooLarge] = useState<{ heightPx: number; limitPx: number } | null>(null);
|
||||||
|
|
||||||
const specText = useChartBuilderStore(selectBuilderSpecText);
|
const specText = useChartBuilderStore(selectBuilderSpecText);
|
||||||
const valid = useChartBuilderStore(selectBuilderValid);
|
const valid = useChartBuilderStore(selectBuilderValid);
|
||||||
@@ -279,18 +312,26 @@ function BuilderPreview() {
|
|||||||
handleRef.current?.destroy();
|
handleRef.current?.destroy();
|
||||||
handleRef.current = null;
|
handleRef.current = null;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setTooLarge(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!node) return;
|
if (!node) return;
|
||||||
try {
|
try {
|
||||||
|
const t0 = performance.now();
|
||||||
const parsed: unknown = JSON.parse(specText);
|
const parsed: unknown = JSON.parse(specText);
|
||||||
|
const t1 = performance.now();
|
||||||
const prepared = prepareSpecForRender(parsed, { fitMode: 'width', datasets });
|
const prepared = prepareSpecForRender(parsed, { fitMode: 'width', datasets });
|
||||||
handleRef.current?.destroy();
|
const t2 = performance.now();
|
||||||
|
handleRef.current?.destroy(); // finalizing a huge prior SVG is itself a cost
|
||||||
handleRef.current = null;
|
handleRef.current = null;
|
||||||
|
const t3 = performance.now();
|
||||||
const handle = await renderSpec(
|
const handle = await renderSpec(
|
||||||
node,
|
node,
|
||||||
prepared as VisualizationSpec,
|
prepared as VisualizationSpec,
|
||||||
chartConfigFor(uiTheme),
|
chartConfigFor(uiTheme),
|
||||||
|
// Canvas, not SVG: a many-mark preview (one bar per row of a big dataset)
|
||||||
|
// costs seconds of SVG layout/paint; canvas paints in ms (see renderer).
|
||||||
|
{ renderer: 'canvas' },
|
||||||
);
|
);
|
||||||
if (mine !== generationRef.current) {
|
if (mine !== generationRef.current) {
|
||||||
handle.destroy();
|
handle.destroy();
|
||||||
@@ -298,12 +339,37 @@ function BuilderPreview() {
|
|||||||
}
|
}
|
||||||
handleRef.current = handle;
|
handleRef.current = handle;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setTooLarge(null);
|
||||||
|
const t4 = performance.now();
|
||||||
|
// The browser lays out/paints the (possibly huge) SVG after embed resolves;
|
||||||
|
// a double rAF lands just after that paint, capturing the freeze the user
|
||||||
|
// feels. Skipped if a newer render has already superseded this one.
|
||||||
|
requestAnimationFrame(() =>
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (mine !== generationRef.current) return;
|
||||||
|
const t5 = performance.now();
|
||||||
|
logBuilderRenderTiming({
|
||||||
|
parse: t1 - t0,
|
||||||
|
prepare: t2 - t1,
|
||||||
|
destroy: t3 - t2,
|
||||||
|
embed: t4 - t3,
|
||||||
|
paint: t5 - t4,
|
||||||
|
total: t5 - t0,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mine !== generationRef.current) return;
|
if (mine !== generationRef.current) return;
|
||||||
if (e instanceof DatasetNotFoundError) {
|
if (e instanceof ChartTooLargeError) {
|
||||||
|
// A physical render-size limit (canvas max dimension), not a data error.
|
||||||
|
setTooLarge({ heightPx: e.heightPx, limitPx: e.limitPx });
|
||||||
|
setError(null);
|
||||||
|
} else if (e instanceof DatasetNotFoundError) {
|
||||||
setError(`Dataset "${e.datasetName}" not found.`);
|
setError(`Dataset "${e.datasetName}" not found.`);
|
||||||
|
setTooLarge(null);
|
||||||
} else {
|
} else {
|
||||||
setError(`Couldn't render this chart: ${(e as Error).message}`);
|
setError(`Couldn't render this chart: ${(e as Error).message}`);
|
||||||
|
setTooLarge(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
@@ -325,10 +391,18 @@ function BuilderPreview() {
|
|||||||
{!valid && (
|
{!valid && (
|
||||||
<p className={styles.previewHint}>Map at least one channel to a column to see a chart.</p>
|
<p className={styles.previewHint}>Map at least one channel to a column to see a chart.</p>
|
||||||
)}
|
)}
|
||||||
<div className={styles.previewFrame} hidden={!valid || error !== null}>
|
{valid && tooLarge && (
|
||||||
|
<p className={styles.previewHint} role="status">
|
||||||
|
This chart would be about {Math.round(tooLarge.heightPx).toLocaleString()} px tall —
|
||||||
|
larger than the browser can draw on a canvas (
|
||||||
|
{Math.round(tooLarge.limitPx).toLocaleString()} px max here). Aggregate the measure or
|
||||||
|
filter to fewer rows so it fits.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className={styles.previewFrame} hidden={!valid || tooLarge !== null || error !== null}>
|
||||||
<div className={styles.previewHost} ref={hostRef} />
|
<div className={styles.previewHost} ref={hostRef} />
|
||||||
</div>
|
</div>
|
||||||
{valid && error !== null && (
|
{valid && tooLarge === null && error !== null && (
|
||||||
<pre className={styles.previewError} role="alert">
|
<pre className={styles.previewError} role="alert">
|
||||||
{error}
|
{error}
|
||||||
</pre>
|
</pre>
|
||||||
@@ -351,6 +425,7 @@ export function ChartBuilderModal() {
|
|||||||
const setStack = useChartBuilderStore((s) => s.setStack);
|
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 applyWarningFix = useChartBuilderStore((s) => s.applyWarningFix);
|
||||||
const runCreate = useChartBuilderStore((s) => s.createSnippet);
|
const runCreate = useChartBuilderStore((s) => s.createSnippet);
|
||||||
|
|
||||||
// Validity + guidance + which chart-level controls apply are derived from the
|
// Validity + guidance + which chart-level controls apply are derived from the
|
||||||
@@ -369,6 +444,30 @@ export function ChartBuilderModal() {
|
|||||||
const canSort = useMemo(() => supportsSort(config), [config]);
|
const canSort = useMemo(() => supportsSort(config), [config]);
|
||||||
const canStack = useMemo(() => supportsStack(config), [config]);
|
const canStack = useMemo(() => supportsStack(config), [config]);
|
||||||
|
|
||||||
|
// Applying a hint's fix removes that hint's list item, so focus would otherwise fall
|
||||||
|
// to <body>. The change is announced politely (the chart updates silently for sighted
|
||||||
|
// users) and focus moves to the guidance region, or the config pane if the last hint
|
||||||
|
// just cleared — the pattern for a control that removes its own container (arch 10 §5).
|
||||||
|
const configPaneRef = useRef<HTMLDivElement>(null);
|
||||||
|
const warningsRef = useRef<HTMLUListElement>(null);
|
||||||
|
const pendingFixFocus = useRef(false);
|
||||||
|
const [fixAnnouncement, setFixAnnouncement] = useState('');
|
||||||
|
|
||||||
|
const handleFix = (fix: BuilderWarningFix) => {
|
||||||
|
applyWarningFix(fix); // re-derives `warnings`, firing the focus effect below
|
||||||
|
setFixAnnouncement(`Applied: ${fix.label}.`);
|
||||||
|
pendingFixFocus.current = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// After a fix re-derives the warnings, move focus off the (now-removed) button:
|
||||||
|
// to the guidance region if hints remain, else the config pane. Ref-flag, not
|
||||||
|
// state, so we never setState inside the effect (react-hooks/set-state-in-effect).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingFixFocus.current) return;
|
||||||
|
pendingFixFocus.current = false;
|
||||||
|
(warningsRef.current ?? configPaneRef.current)?.focus();
|
||||||
|
}, [warnings]);
|
||||||
|
|
||||||
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>;
|
||||||
}
|
}
|
||||||
@@ -381,7 +480,10 @@ export function ChartBuilderModal() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.builder}>
|
<div className={styles.builder}>
|
||||||
<div className={styles.configPane}>
|
<div className={styles.configPane} ref={configPaneRef} tabIndex={-1}>
|
||||||
|
<div className="visually-hidden" role="status" aria-live="polite">
|
||||||
|
{fixAnnouncement}
|
||||||
|
</div>
|
||||||
<p className={styles.datasetName}>
|
<p className={styles.datasetName}>
|
||||||
Building from <strong>{datasetName}</strong>
|
Building from <strong>{datasetName}</strong>
|
||||||
</p>
|
</p>
|
||||||
@@ -464,11 +566,32 @@ export function ChartBuilderModal() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{warnings.length > 0 && (
|
{warnings.length > 0 && (
|
||||||
<ul className={styles.warnings}>
|
<ul
|
||||||
|
className={styles.warnings}
|
||||||
|
ref={warningsRef}
|
||||||
|
tabIndex={-1}
|
||||||
|
aria-label="Chart guidance"
|
||||||
|
>
|
||||||
{warnings.map((w) => (
|
{warnings.map((w) => (
|
||||||
<li key={w.message} className={styles.warning}>
|
<li key={w.message} className={styles.warning}>
|
||||||
<Icon name="status-warning" className={styles.warningIcon} />
|
<Icon name="status-warning" className={styles.warningIcon} />
|
||||||
<span>{w.message}</span>
|
<div className={styles.warningBody}>
|
||||||
|
<span>{w.message}</span>
|
||||||
|
{w.fixes && w.fixes.length > 0 && (
|
||||||
|
<div className={styles.warningFixes}>
|
||||||
|
{w.fixes.map((fix) => (
|
||||||
|
<button
|
||||||
|
key={fix.label}
|
||||||
|
type="button"
|
||||||
|
className={styles.warningFix}
|
||||||
|
onClick={() => handleFix(fix)}
|
||||||
|
>
|
||||||
|
{fix.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -28,6 +28,14 @@
|
|||||||
height: min(700px, 88vh);
|
height: min(700px, 88vh);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Extra-large: the Chart Builder — a work surface (config + a chart that wants room),
|
||||||
|
with nothing useful behind it. Near-fullscreen, capped so it doesn't stretch absurdly
|
||||||
|
on ultra-wide displays. Definite height so its panes scroll internally (not the modal). */
|
||||||
|
.xlarge {
|
||||||
|
width: min(1800px, 96vw);
|
||||||
|
height: min(1100px, 92vh);
|
||||||
|
}
|
||||||
|
|
||||||
/* Small: single-form modals (Extract). Grows with content up to a cap. */
|
/* Small: single-form modals (Extract). Grows with content up to a cap. */
|
||||||
.small {
|
.small {
|
||||||
width: min(560px, 92vw);
|
width: min(560px, 92vw);
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ export function ModalShell() {
|
|||||||
const name = useAppStore((s) => s.activeModal);
|
const name = useAppStore((s) => s.activeModal);
|
||||||
const config = getModalConfig(name);
|
const config = getModalConfig(name);
|
||||||
|
|
||||||
const isLarge = name === 'datasets' || name === 'chartBuilder';
|
// The Chart Builder is a near-fullscreen work surface; the Datasets manager is the
|
||||||
|
// standard large two-pane modal; everything else is a small form. Both large kinds
|
||||||
|
// get the static-title initial focus (APG dialog-modal) so content isn't skipped.
|
||||||
|
const isXLarge = name === 'chartBuilder';
|
||||||
|
const isLarge = name === 'datasets' || isXLarge;
|
||||||
|
|
||||||
// Move focus into the modal on open, return it to the trigger on close. For a
|
// Move focus into the modal on open, return it to the trigger on close. For a
|
||||||
// large manager (list + detail), APG dialog-modal advises focusing a static
|
// large manager (list + detail), APG dialog-modal advises focusing a static
|
||||||
@@ -38,10 +42,16 @@ export function ModalShell() {
|
|||||||
if (!config) return null;
|
if (!config) return null;
|
||||||
const Body = config.component;
|
const Body = config.component;
|
||||||
|
|
||||||
|
// Modals with in-progress work (the Chart Builder's config) opt out of
|
||||||
|
// click-outside-to-close so an accidental backdrop click can't discard it; Escape
|
||||||
|
// and the close button still dismiss. Other modals keep backdrop dismissal.
|
||||||
|
const dismissOnBackdrop = config.dismissOnBackdrop !== false;
|
||||||
|
const sizeClass = isXLarge ? styles.xlarge : isLarge ? styles.large : styles.small;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.backdrop}
|
className={styles.backdrop}
|
||||||
onClick={() => void closeModal()}
|
onClick={dismissOnBackdrop ? () => void closeModal() : undefined}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -51,7 +61,7 @@ export function ModalShell() {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={modalRef}
|
ref={modalRef}
|
||||||
className={`${styles.modal} ${isLarge ? styles.large : styles.small}`}
|
className={`${styles.modal} ${sizeClass}`}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-labelledby="modal-title"
|
aria-labelledby="modal-title"
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ export interface ModalConfig {
|
|||||||
getState?: () => Record<string, unknown> | null;
|
getState?: () => Record<string, unknown> | null;
|
||||||
/** Whether the modal is reflected in the URL hash (navigable). */
|
/** Whether the modal is reflected in the URL hash (navigable). */
|
||||||
isUrlNavigable?: boolean;
|
isUrlNavigable?: boolean;
|
||||||
|
/**
|
||||||
|
* Whether a backdrop (click-outside) closes the modal. Defaults to `true`. Set
|
||||||
|
* `false` for modals holding in-progress work an accidental click shouldn't
|
||||||
|
* discard (the Chart Builder) — Escape and the close button still dismiss.
|
||||||
|
*/
|
||||||
|
dismissOnBackdrop?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
|
export const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
|
||||||
@@ -71,11 +77,14 @@ export const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
|
|||||||
// Opened from a selected dataset's "Build Chart" action; `arg` is its id. Loads
|
// Opened from a selected dataset's "Build Chart" action; `arg` is its id. Loads
|
||||||
// the dataset and pre-populates a smart default config (§06). Applies on Create
|
// the dataset and pre-populates a smart default config (§06). Applies on Create
|
||||||
// (a new snippet), so there is nothing transient to lose on close — no getState.
|
// (a new snippet), so there is nothing transient to lose on close — no getState.
|
||||||
|
// Backdrop dismissal is off: the config is real in-progress work, and a stray
|
||||||
|
// click outside this large surface shouldn't throw it away (Escape/× still close).
|
||||||
chartBuilder: {
|
chartBuilder: {
|
||||||
name: 'chartBuilder',
|
name: 'chartBuilder',
|
||||||
title: 'Chart Builder',
|
title: 'Chart Builder',
|
||||||
component: ChartBuilderModal,
|
component: ChartBuilderModal,
|
||||||
isUrlNavigable: true,
|
isUrlNavigable: true,
|
||||||
|
dismissOnBackdrop: false,
|
||||||
init: (datasetId) => useChartBuilderStore.getState().init(datasetId ? Number(datasetId) : null),
|
init: (datasetId) => useChartBuilderStore.getState().init(datasetId ? Number(datasetId) : null),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -27,15 +27,85 @@ export interface RenderHandle {
|
|||||||
resize(): void;
|
resize(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Embed a prepared spec into `node`. Non-negotiable: no actions menu, SVG output. */
|
export interface RenderOptions {
|
||||||
|
/**
|
||||||
|
* Renderer backend. **Default `'svg'`** — crisp at any zoom, themeable, the
|
||||||
|
* contract default for the editor's LivePreview (docs/architecture/05 §2). The
|
||||||
|
* Chart Builder preview passes **`'canvas'`**: an SVG chart with thousands of
|
||||||
|
* marks (e.g. one bar per row of a 10k-row dataset) costs *seconds* of
|
||||||
|
* main-thread layout/paint per render — measured ~6.5s paint on 9994 rows —
|
||||||
|
* because each mark is a DOM node; canvas is a single node and paints in
|
||||||
|
* milliseconds. Canvas is raster (not crisp on zoom) but that's invisible for an
|
||||||
|
* ephemeral preview, and image export (`view.toImageURL`) is renderer-agnostic.
|
||||||
|
*/
|
||||||
|
renderer?: 'svg' | 'canvas';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum canvas side length, in CSS px before the device-pixel-ratio multiplier.
|
||||||
|
* Browsers cap a canvas backing store at ~32767px per side (Chrome/Firefox; Safari
|
||||||
|
* is lower and area-bound); past that the canvas fails to allocate and draws
|
||||||
|
* nothing. SVG has no such cap. `renderSpec` measures a canvas chart's resolved
|
||||||
|
* size against this (÷ dpr, since the backing store is dpr× the CSS size) and
|
||||||
|
* throws `ChartTooLargeError` rather than handing back a blank canvas.
|
||||||
|
*/
|
||||||
|
export const MAX_CANVAS_PX = 32767;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by `renderSpec` when a **canvas**-backed chart resolves to a height larger
|
||||||
|
* than the browser can allocate (see `MAX_CANVAS_PX`). Carries the measured size and
|
||||||
|
* the limit so the caller can explain the *actual* cause — the chart is physically
|
||||||
|
* too large to draw — rather than guessing at "too many categories". This is a
|
||||||
|
* render-backend limit, distinct from the readability cardinality warnings.
|
||||||
|
*/
|
||||||
|
export class ChartTooLargeError extends Error {
|
||||||
|
/** The chart's resolved height, in CSS px. */
|
||||||
|
readonly heightPx: number;
|
||||||
|
/** The per-side limit at the current device-pixel-ratio, in CSS px. */
|
||||||
|
readonly limitPx: number;
|
||||||
|
constructor(heightPx: number, limitPx: number) {
|
||||||
|
super(
|
||||||
|
`Chart is ${Math.round(heightPx)}px tall — over the ~${Math.round(limitPx)}px canvas limit`,
|
||||||
|
);
|
||||||
|
this.name = 'ChartTooLargeError';
|
||||||
|
this.heightPx = heightPx;
|
||||||
|
this.limitPx = limitPx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The canvas side limit in CSS px at the current display's device-pixel-ratio. */
|
||||||
|
function canvasLimitPx(): number {
|
||||||
|
const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
|
||||||
|
return MAX_CANVAS_PX / dpr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Embed a prepared spec into `node`. Always: no actions menu. Renderer per `options`. */
|
||||||
export async function renderSpec(
|
export async function renderSpec(
|
||||||
node: HTMLElement,
|
node: HTMLElement,
|
||||||
spec: VisualizationSpec,
|
spec: VisualizationSpec,
|
||||||
config: Config,
|
config: Config,
|
||||||
|
options: RenderOptions = {},
|
||||||
): Promise<RenderHandle> {
|
): Promise<RenderHandle> {
|
||||||
|
const renderer = options.renderer ?? 'svg';
|
||||||
|
|
||||||
|
// Canvas can't allocate past the browser's max dimension, and an oversized canvas
|
||||||
|
// fails *silently* (a blank/broken surface, sometimes a null 2d context). So for
|
||||||
|
// canvas we first run a headless ('none') layout pass — no canvas allocated — read
|
||||||
|
// the chart's resolved height, and throw with the real numbers if it won't fit.
|
||||||
|
// SVG renders any size (just slowly), so it skips this. The probe uses a detached
|
||||||
|
// node and is finalized immediately; only its computed `height` signal is read.
|
||||||
|
if (renderer === 'canvas') {
|
||||||
|
const probeHost = document.createElement('div');
|
||||||
|
const probe = await vegaEmbed(probeHost, spec, { actions: false, renderer: 'none', config });
|
||||||
|
const height = probe.view.height();
|
||||||
|
probe.view.finalize();
|
||||||
|
const limit = canvasLimitPx();
|
||||||
|
if (typeof height === 'number' && height > limit) throw new ChartTooLargeError(height, limit);
|
||||||
|
}
|
||||||
|
|
||||||
const result: EmbedResult = await vegaEmbed(node, spec, {
|
const result: EmbedResult = await vegaEmbed(node, spec, {
|
||||||
actions: false, // Astrolabe owns its own export/copy affordances
|
actions: false, // Astrolabe owns its own export/copy affordances
|
||||||
renderer: 'svg',
|
renderer,
|
||||||
config,
|
config,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,19 @@ describe('sort / stack', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('applyWarningFix', () => {
|
||||||
|
test('applies a hint fix to the working config (actionable hints, §06)', () => {
|
||||||
|
const id = seedDataset('Nums', [
|
||||||
|
{ a: 1, b: 2 },
|
||||||
|
{ a: 3, b: 4 },
|
||||||
|
]);
|
||||||
|
cb().init(id);
|
||||||
|
cb().setMark('bar'); // two quantitative axes on a bar → "scatter" hint with a fix
|
||||||
|
cb().applyWarningFix({ label: 'Switch to Point', apply: (c) => ({ ...c, mark: 'point' }) });
|
||||||
|
expect(cb().config.mark).toBe('point');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
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.
+138
-13
@@ -108,16 +108,20 @@ describe('builderWarnings (Tier B advisories)', () => {
|
|||||||
expect(w.some((m) => /need both an X and a Y/.test(m.message))).toBe(true);
|
expect(w.some((m) => /need both an X and a Y/.test(m.message))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('warns when two measures are drawn on a non-scatter mark', () => {
|
it('warns when two measures are drawn on a non-scatter mark, offering [Switch to Point]', () => {
|
||||||
const w = builderWarnings({
|
const config: BuilderConfig = {
|
||||||
datasetName: 'D',
|
datasetName: 'D',
|
||||||
mark: 'bar',
|
mark: 'bar',
|
||||||
encodings: {
|
encodings: {
|
||||||
x: { field: 'a', type: 'quantitative' },
|
x: { field: 'a', type: 'quantitative' },
|
||||||
y: { field: 'b', type: 'quantitative' },
|
y: { field: 'b', type: 'quantitative' },
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
expect(w.some((m) => /scatter/.test(m.message))).toBe(true);
|
const w = builderWarnings(config);
|
||||||
|
const hint = w.find((m) => /scatter/.test(m.message));
|
||||||
|
const fix = hint?.fixes?.find((f) => f.label === 'Switch to Point');
|
||||||
|
expect(fix).toBeDefined();
|
||||||
|
expect(fix!.apply(config).mark).toBe('point');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('warns when a bar/line/area has no measure on either axis', () => {
|
it('warns when a bar/line/area has no measure on either axis', () => {
|
||||||
@@ -129,8 +133,8 @@ describe('builderWarnings (Tier B advisories)', () => {
|
|||||||
expect(w.some((m) => /need a measure/.test(m.message))).toBe(true);
|
expect(w.some((m) => /need a measure/.test(m.message))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('warns when an area chart is split into colour series', () => {
|
it('warns when an area chart is split into colour series, offering [Stack] / [Remove colour]', () => {
|
||||||
const w = builderWarnings({
|
const config: BuilderConfig = {
|
||||||
datasetName: 'D',
|
datasetName: 'D',
|
||||||
mark: 'area',
|
mark: 'area',
|
||||||
encodings: {
|
encodings: {
|
||||||
@@ -138,8 +142,20 @@ describe('builderWarnings (Tier B advisories)', () => {
|
|||||||
y: { field: 'v', type: 'quantitative' },
|
y: { field: 'v', type: 'quantitative' },
|
||||||
color: { field: 'g', type: 'nominal' },
|
color: { field: 'g', type: 'nominal' },
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
expect(w.some((m) => m.channel === 'color')).toBe(true);
|
const w = builderWarnings(config);
|
||||||
|
const hint = w.find((m) => m.channel === 'color');
|
||||||
|
expect(hint).toBeDefined();
|
||||||
|
const labels = hint?.fixes?.map((f) => f.label) ?? [];
|
||||||
|
expect(labels).toEqual(['Stack', 'Remove colour']); // most-recommended first
|
||||||
|
// [Remove colour] clears the colour channel, so the hint re-derives away.
|
||||||
|
const cleared = hint!.fixes!.find((f) => f.label === 'Remove colour')!.apply(config);
|
||||||
|
expect(cleared.encodings.color).toBeNull();
|
||||||
|
expect(builderWarnings(cleared).some((m) => m.channel === 'color')).toBe(false);
|
||||||
|
// [Stack] turns it into a part-to-whole stack, which is no longer flagged.
|
||||||
|
const stacked = hint!.fixes!.find((f) => f.label === 'Stack')!.apply(config);
|
||||||
|
expect(stacked.stack).toBe('zero');
|
||||||
|
expect(builderWarnings(stacked).some((m) => m.channel === 'color')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is silent for a clean configuration', () => {
|
it('is silent for a clean configuration', () => {
|
||||||
@@ -170,7 +186,29 @@ describe('builderWarnings (Tier B advisories)', () => {
|
|||||||
const hint = w.find((m) => /one mark per row/.test(m.message));
|
const hint = w.find((m) => /one mark per row/.test(m.message));
|
||||||
expect(hint?.channel).toBe('x'); // the category axis
|
expect(hint?.channel).toBe('x'); // the category axis
|
||||||
expect(hint?.message).toContain('406 in this dataset');
|
expect(hint?.message).toContain('406 in this dataset');
|
||||||
expect(hint?.message).toMatch(/Swap X\/Y/); // bar → horizontal-bar remedy
|
// Remedies are one-click fixes, not prose; most-recommended first.
|
||||||
|
const labels = hint?.fixes?.map((f) => f.label) ?? [];
|
||||||
|
expect(labels).toEqual(['Aggregate as Sum', 'Swap X/Y']); // aggregate before swap
|
||||||
|
});
|
||||||
|
|
||||||
|
it('[Aggregate as Sum] resolves the one-mark-per-row hint', () => {
|
||||||
|
const w = crowded();
|
||||||
|
const hint = w.find((m) => /one mark per row/.test(m.message));
|
||||||
|
const fix = hint?.fixes?.find((f) => f.label === 'Aggregate as Sum');
|
||||||
|
expect(fix).toBeDefined();
|
||||||
|
const fixed = fix!.apply({
|
||||||
|
datasetName: 'D',
|
||||||
|
mark: 'bar',
|
||||||
|
encodings: {
|
||||||
|
x: { field: 'name', type: 'nominal' },
|
||||||
|
y: { field: 'mpg', type: 'quantitative' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(fixed.encodings.y?.aggregate).toBe('sum');
|
||||||
|
// and the hint is gone once applied
|
||||||
|
expect(builderWarnings(fixed, 406).some((m) => /one mark per row/.test(m.message))).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is silent once the measure is aggregated (one bar per category)', () => {
|
it('is silent once the measure is aggregated (one bar per category)', () => {
|
||||||
@@ -219,8 +257,9 @@ describe('builderWarnings (Tier B advisories)', () => {
|
|||||||
);
|
);
|
||||||
const hint = w.find((m) => /one mark per row/.test(m.message));
|
const hint = w.find((m) => /one mark per row/.test(m.message));
|
||||||
expect(hint).toBeDefined();
|
expect(hint).toBeDefined();
|
||||||
expect(hint?.message).not.toMatch(/Swap X\/Y/);
|
const labels = hint?.fixes?.map((f) => f.label) ?? [];
|
||||||
expect(hint?.message).toMatch(/reduce the number of categories/);
|
expect(labels).not.toContain('Swap X/Y'); // a horizontal line makes no sense
|
||||||
|
expect(labels).toContain('Aggregate as Sum'); // aggregate still applies
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -262,7 +301,7 @@ describe('builderWarnings (Tier B advisories)', () => {
|
|||||||
const hint = w.find((m) => /distinct values/.test(m.message));
|
const hint = w.find((m) => /distinct values/.test(m.message));
|
||||||
expect(hint?.channel).toBe('x');
|
expect(hint?.channel).toBe('x');
|
||||||
expect(hint?.message).toMatch(/48 distinct values/);
|
expect(hint?.message).toMatch(/48 distinct values/);
|
||||||
expect(hint?.message).toMatch(/Swap X\/Y/); // bar → horizontal-bar remedy
|
expect(hint?.fixes?.map((f) => f.label)).toContain('Swap X/Y'); // horizontal-bar remedy
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports "more than 50" when the category cardinality hit the profiler cap', () => {
|
it('reports "more than 50" when the category cardinality hit the profiler cap', () => {
|
||||||
@@ -426,7 +465,8 @@ describe('builderWarnings (Tier B advisories)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('defaultBuilderConfig', () => {
|
describe('defaultBuilderConfig', () => {
|
||||||
it('puts the first column on X and the second on Y, each with derived type', () => {
|
it('falls back to first-on-X, second-on-Y when the dataset is unprofiled', () => {
|
||||||
|
// `columns` carries no columnStats → no data-aware pick → positional default.
|
||||||
const config = defaultBuilderConfig('Sales', columns);
|
const config = defaultBuilderConfig('Sales', columns);
|
||||||
expect(config.mark).toBe('bar');
|
expect(config.mark).toBe('bar');
|
||||||
expect(config.datasetName).toBe('Sales');
|
expect(config.datasetName).toBe('Sales');
|
||||||
@@ -467,6 +507,91 @@ describe('defaultBuilderConfig', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('defaultBuilderConfig — data-aware "safest bet" (profiled datasets)', () => {
|
||||||
|
/** Stats for one column. */
|
||||||
|
const stat = (name: string, distinct: number, capped = false) => ({
|
||||||
|
name,
|
||||||
|
distinct,
|
||||||
|
distinctCapped: capped,
|
||||||
|
numericExtent: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens on a low-cardinality category vs a count of records, not the first two columns', () => {
|
||||||
|
// Superstore-shaped: an id-like number first, a high-cardinality id, then tidy
|
||||||
|
// categories — the case a positional first-two-columns default would open as a
|
||||||
|
// 9994-bar degenerate chart.
|
||||||
|
const wide: BuilderColumns = {
|
||||||
|
columns: ['Row ID', 'Order ID', 'Segment', 'Sales'],
|
||||||
|
columnTypes: [
|
||||||
|
{ name: 'Row ID', type: 'number' },
|
||||||
|
{ name: 'Order ID', type: 'string' },
|
||||||
|
{ name: 'Segment', type: 'string' },
|
||||||
|
{ name: 'Sales', type: 'number' },
|
||||||
|
],
|
||||||
|
columnStats: [
|
||||||
|
stat('Row ID', 50, true),
|
||||||
|
stat('Order ID', 50, true), // high cardinality → not a category axis
|
||||||
|
stat('Segment', 3), // tidy category → the pick
|
||||||
|
stat('Sales', 50, true),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const config = defaultBuilderConfig('Superstore', wide);
|
||||||
|
expect(config.mark).toBe('bar');
|
||||||
|
expect(config.encodings.x).toEqual({ field: 'Segment', type: 'nominal' });
|
||||||
|
expect(config.encodings.y).toEqual({ type: 'quantitative', aggregate: 'count' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('picks the lowest-cardinality readable category among several', () => {
|
||||||
|
const cols: BuilderColumns = {
|
||||||
|
columns: ['Region', 'Segment', 'City'],
|
||||||
|
columnTypes: [
|
||||||
|
{ name: 'Region', type: 'string' },
|
||||||
|
{ name: 'Segment', type: 'string' },
|
||||||
|
{ name: 'City', type: 'string' },
|
||||||
|
],
|
||||||
|
columnStats: [stat('Region', 4), stat('Segment', 3), stat('City', 50, true)],
|
||||||
|
};
|
||||||
|
const config = defaultBuilderConfig('D', cols);
|
||||||
|
expect(config.encodings.x).toEqual({ field: 'Segment', type: 'nominal' }); // 3 < 4
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls through to a time series (date vs count) when no tidy category exists', () => {
|
||||||
|
const cols: 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),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const config = defaultBuilderConfig('D', cols);
|
||||||
|
expect(config.mark).toBe('line');
|
||||||
|
expect(config.encodings.x).toEqual({ field: 'Order Date', type: 'temporal' });
|
||||||
|
expect(config.encodings.y).toEqual({ field: 'Sales', type: 'quantitative' }); // the measure, raw
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls through to a scatter of two measures when there is no category or date', () => {
|
||||||
|
const cols: 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)],
|
||||||
|
};
|
||||||
|
const config = defaultBuilderConfig('D', cols);
|
||||||
|
expect(config.mark).toBe('point');
|
||||||
|
expect(config.encodings.x).toEqual({ field: 'Sales', type: 'quantitative' });
|
||||||
|
expect(config.encodings.y).toEqual({ field: 'Profit', type: 'quantitative' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('isBuilderConfigValid', () => {
|
describe('isBuilderConfigValid', () => {
|
||||||
const base: BuilderConfig = { datasetName: 'D', mark: 'bar', encodings: {} };
|
const base: BuilderConfig = { datasetName: 'D', mark: 'bar', encodings: {} };
|
||||||
|
|
||||||
|
|||||||
+177
-28
@@ -230,14 +230,87 @@ function fieldTypeForColumn(name: string, columns: BuilderColumns): FieldType {
|
|||||||
return defaultFieldType(match?.type ?? 'string');
|
return defaultFieldType(match?.type ?? 'string');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Above this many distinct values, a column is too high-cardinality to be a good
|
||||||
|
* default category axis: its labels overlap into an unreadable axis, and an
|
||||||
|
* unaggregated chart that wide can exceed the canvas size limit. Matches the
|
||||||
|
* crowded-axis warning threshold.
|
||||||
|
*/
|
||||||
|
const CATEGORY_DEFAULT_MAX_DISTINCT = 30;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a *sensible, renderable* default X/Y from the data shape, using profiled
|
||||||
|
* cardinality (`columnStats`). This avoids opening the builder on a degenerate
|
||||||
|
* one-mark-per-row chart — the shape a positional first-two-columns rule yields when
|
||||||
|
* the leading columns are an id and a high-cardinality key. Returns `null` when there
|
||||||
|
* are no stats to reason about (older / URL datasets), so the caller falls back to the
|
||||||
|
* positional default.
|
||||||
|
*
|
||||||
|
* Preference order (each guaranteed to render and read cleanly):
|
||||||
|
* 1. a low-cardinality **category** vs a **count of records** → a tidy bar;
|
||||||
|
* 2. else a **temporal** axis vs count → a time series (continuous x, always fits);
|
||||||
|
* 3. else two **measures** → a scatter (continuous axes, always fit).
|
||||||
|
*
|
||||||
|
* The category case pairs with the field-less **count**, not a raw measure: count is
|
||||||
|
* always meaningful and avoids summing an id-like numeric (Row ID, Postal Code) into
|
||||||
|
* nonsense. This is the builder's *opening* state only; a later intent-first entry
|
||||||
|
* point can layer richer recommendations on top.
|
||||||
|
*/
|
||||||
|
function smartDefaultEncodings(
|
||||||
|
columns: BuilderColumns,
|
||||||
|
): { x: ChannelMapping; y: ChannelMapping } | null {
|
||||||
|
const stats = columns.columnStats;
|
||||||
|
if (!stats || stats.length === 0) return null; // no profiling → positional fallback
|
||||||
|
const typeOf = (name: string): ColumnType =>
|
||||||
|
columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
|
||||||
|
const knownDistinct = (name: string): number | undefined => {
|
||||||
|
const s = stats.find((x) => x.name === name);
|
||||||
|
return s && !s.distinctCapped ? s.distinct : undefined;
|
||||||
|
};
|
||||||
|
const count: ChannelMapping = { type: 'quantitative', aggregate: 'count' };
|
||||||
|
const numbers = columns.columns.filter((name) => typeOf(name) === 'number');
|
||||||
|
|
||||||
|
// 1. The lowest-cardinality readable category (string/boolean) → a tidy bar. Pair it
|
||||||
|
// with **count** (not a raw measure): a raw measure would draw one bar per row.
|
||||||
|
const category = columns.columns
|
||||||
|
.filter((name) => {
|
||||||
|
const t = typeOf(name);
|
||||||
|
if (t !== 'string' && t !== 'boolean') return false;
|
||||||
|
const d = knownDistinct(name);
|
||||||
|
return d !== undefined && d >= 2 && d <= CATEGORY_DEFAULT_MAX_DISTINCT;
|
||||||
|
})
|
||||||
|
.sort((a, b) => (knownDistinct(a) ?? 0) - (knownDistinct(b) ?? 0))[0];
|
||||||
|
if (category) return { x: { field: category, type: 'nominal' }, y: count };
|
||||||
|
|
||||||
|
// 2. A date → a time series of the first measure (a temporal axis is continuous, so a
|
||||||
|
// line of raw values always fits); fall back to count if there is no measure.
|
||||||
|
const temporal = columns.columns.find((name) => typeOf(name) === 'date');
|
||||||
|
if (temporal) {
|
||||||
|
const y: ChannelMapping = numbers[0] ? { field: numbers[0], type: 'quantitative' } : count;
|
||||||
|
return { x: { field: temporal, type: 'temporal' }, y };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Two measures → a scatter (continuous axes, always renderable).
|
||||||
|
if (numbers.length >= 2) {
|
||||||
|
return {
|
||||||
|
x: { field: numbers[0], type: 'quantitative' },
|
||||||
|
y: { field: numbers[1], type: 'quantitative' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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). When the dataset is profiled, X/Y are chosen as a
|
||||||
* with its derived field type; Color and Size start unmapped, no transforms. The
|
* **data-aware "safest bet"** (`smartDefaultEncodings`) — a low-cardinality category
|
||||||
* mark is the **smart default** for the resulting X/Y shape (`defaultMark`) rather
|
* vs a count of records (a tidy bar), else a time series, else a scatter — so the
|
||||||
* than always Bar — a date-vs-number dataset opens as a Line, two measures as a
|
* builder never opens on a degenerate one-mark-per-row chart that can't render. When
|
||||||
* Point — so the first preview is already the conventional chart. A dataset with no
|
* there are no stats to reason about, it falls back to the positional rule: first
|
||||||
* detected columns yields an all-unmapped config (the modal then prompts).
|
* column on X, second (if any) on Y, each with its derived field type. Either way the
|
||||||
|
* mark is the **smart default** for the resulting X/Y shape (`defaultMark`), Color
|
||||||
|
* and Size start unmapped with no transforms, and a dataset with no 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>> = {
|
||||||
@@ -246,12 +319,20 @@ export function defaultBuilderConfig(datasetName: string, columns: BuilderColumn
|
|||||||
color: null,
|
color: null,
|
||||||
size: null,
|
size: null,
|
||||||
};
|
};
|
||||||
const [first, second] = columns.columns;
|
// Prefer a data-aware "safest bet" (a renderable, readable chart) when the dataset
|
||||||
if (first !== undefined) {
|
// is profiled; otherwise fall back to the positional first-on-X, second-on-Y rule.
|
||||||
encodings.x = { field: first, type: fieldTypeForColumn(first, columns) };
|
const smart = smartDefaultEncodings(columns);
|
||||||
}
|
if (smart) {
|
||||||
if (second !== undefined) {
|
encodings.x = smart.x;
|
||||||
encodings.y = { field: second, type: fieldTypeForColumn(second, columns) };
|
encodings.y = smart.y;
|
||||||
|
} else {
|
||||||
|
const [first, second] = columns.columns;
|
||||||
|
if (first !== undefined) {
|
||||||
|
encodings.x = { field: first, type: fieldTypeForColumn(first, columns) };
|
||||||
|
}
|
||||||
|
if (second !== undefined) {
|
||||||
|
encodings.y = { field: second, type: fieldTypeForColumn(second, columns) };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const mark = defaultMark(encodings.x?.type ?? null, encodings.y?.type ?? null);
|
const mark = defaultMark(encodings.x?.type ?? null, encodings.y?.type ?? null);
|
||||||
return { datasetName, mark, encodings };
|
return { datasetName, mark, encodings };
|
||||||
@@ -329,12 +410,27 @@ export function supportsStack(config: BuilderConfig): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A one-click remedy a warning can offer. `apply` is a pure config→config transform;
|
||||||
|
* the modal renders `label` as a button that runs it. It is an *offer*, never a forced
|
||||||
|
* change — once applied, the warning re-derives away. Lives in core so the remedies
|
||||||
|
* unit-test alongside the warnings.
|
||||||
|
*/
|
||||||
|
export interface BuilderWarningFix {
|
||||||
|
/** The button label naming the remedy, e.g. "Aggregate as Sum". */
|
||||||
|
label: string;
|
||||||
|
/** Produce the corrected configuration from the current one (pure). */
|
||||||
|
apply: (config: BuilderConfig) => BuilderConfig;
|
||||||
|
}
|
||||||
|
|
||||||
/** 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. */
|
||||||
channel?: ChannelName;
|
channel?: ChannelName;
|
||||||
/** A short, plain-language hint the modal shows inline (not an error). */
|
/** A short, plain-language hint the modal shows inline (not an error). */
|
||||||
message: string;
|
message: string;
|
||||||
|
/** Optional one-click remedies the modal renders as buttons next to the hint. */
|
||||||
|
fixes?: BuilderWarningFix[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -371,6 +467,41 @@ function cardinalityText(stats: ColumnStats): string {
|
|||||||
return stats.distinctCapped ? `more than ${DISTINCT_CAP}` : `${stats.distinct}`;
|
return stats.distinctCapped ? `more than ${DISTINCT_CAP}` : `${stats.distinct}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Pure config transforms backing the actionable-hint fixes. They mirror the
|
||||||
|
// store's setChannelAggregate / swapXY / setStack / setChannelColumn(null) / setMark
|
||||||
|
// actions, so applying a fix and making the equivalent manual edit land on the same
|
||||||
|
// config. Kept here (not the store) so the remedies are pure and unit-testable. ---
|
||||||
|
|
||||||
|
/** Aggregate one channel's field (clearing any bin — the two are mutually exclusive). */
|
||||||
|
function withChannelAggregate(
|
||||||
|
config: BuilderConfig,
|
||||||
|
channel: ChannelName,
|
||||||
|
aggregate: AggregateOp,
|
||||||
|
): BuilderConfig {
|
||||||
|
const current = config.encodings[channel];
|
||||||
|
if (!current) return config;
|
||||||
|
const next: ChannelMapping = { ...current, aggregate };
|
||||||
|
delete next.bin;
|
||||||
|
return { ...config, encodings: { ...config.encodings, [channel]: next } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exchange the X and Y mappings (the manual Swap X/Y, as a pure transform). */
|
||||||
|
function withSwappedXY(config: BuilderConfig): BuilderConfig {
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
encodings: {
|
||||||
|
...config.encodings,
|
||||||
|
x: config.encodings.y ?? null,
|
||||||
|
y: config.encodings.x ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear one channel back to "None" (drops its mapping from the spec). */
|
||||||
|
function withChannelCleared(config: BuilderConfig, channel: ChannelName): BuilderConfig {
|
||||||
|
return { ...config, encodings: { ...config.encodings, [channel]: null } };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
||||||
@@ -427,17 +558,25 @@ export function builderWarnings(
|
|||||||
mark !== 'circle'
|
mark !== 'circle'
|
||||||
) {
|
) {
|
||||||
warnings.push({
|
warnings.push({
|
||||||
message: 'Two measures usually read best as a scatter — try Point or Circle.',
|
message: 'Two measures usually read best as a scatter.',
|
||||||
|
fixes: [{ label: 'Switch to Point', apply: (c) => ({ ...c, mark: 'point' }) }],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Area split into many series hides per-component change (FT Visual Vocabulary:
|
// Area split into many series hides per-component change (FT Visual Vocabulary:
|
||||||
// "seeing change in components can be very difficult").
|
// "seeing change in components can be very difficult").
|
||||||
if (mark === 'area' && config.encodings.color) {
|
// (Stacking turns overlapping series into a cumulative part-to-whole, a valid read,
|
||||||
|
// so a stacked area is not flagged — applying the [Stack] fix below clears this.)
|
||||||
|
if (mark === 'area' && config.encodings.color && !config.stack) {
|
||||||
|
const fixes: BuilderWarningFix[] = [];
|
||||||
|
if (supportsStack(config)) {
|
||||||
|
fixes.push({ label: 'Stack', apply: (c) => ({ ...c, stack: 'zero' }) });
|
||||||
|
}
|
||||||
|
fixes.push({ label: 'Remove colour', apply: (c) => withChannelCleared(c, 'color') });
|
||||||
warnings.push({
|
warnings.push({
|
||||||
channel: 'color',
|
channel: 'color',
|
||||||
message:
|
message: 'Area charts make per-series change hard to read.',
|
||||||
'Area charts make per-series change hard to read; consider Line for multiple series.',
|
fixes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -455,28 +594,38 @@ export function builderWarnings(
|
|||||||
// long category lists belong on a horizontal bar).
|
// long category lists belong on a horizontal bar).
|
||||||
if (mark === 'bar' || mark === 'line' || mark === 'area') {
|
if (mark === 'bar' || mark === 'line' || mark === 'area') {
|
||||||
const category = sortableCategoryChannel(config); // discrete axis of a category-vs-measure pair
|
const category = sortableCategoryChannel(config); // discrete axis of a category-vs-measure pair
|
||||||
const measure = category ? config.encodings[category === 'x' ? 'y' : 'x'] : null;
|
const measureChannel = category ? (category === 'x' ? 'y' : 'x') : null;
|
||||||
if (category && measure) {
|
const measure = measureChannel ? (config.encodings[measureChannel] ?? null) : null;
|
||||||
|
if (category && measureChannel && measure) {
|
||||||
const rawMeasure = !measure.aggregate && !measure.bin;
|
const rawMeasure = !measure.aggregate && !measure.bin;
|
||||||
if (rawMeasure && typeof rowCount === 'number' && rowCount > CROWDED_CATEGORY_ROWS) {
|
if (rawMeasure && typeof rowCount === 'number' && rowCount > CROWDED_CATEGORY_ROWS) {
|
||||||
const fix =
|
// Aggregating the measure collapses one-mark-per-row to one-per-category; a bar
|
||||||
mark === 'bar'
|
// can also flip horizontal (Swap X/Y) where long labels stay readable. Offer
|
||||||
? '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 only for a quantitative measure (Sum is meaningless on a date).
|
||||||
: 'Aggregate the measure (e.g. Sum or Mean) so there is one mark per category, or reduce the number of categories.';
|
const fixes: BuilderWarningFix[] = [];
|
||||||
|
if (effectiveType(measure) === 'quantitative') {
|
||||||
|
fixes.push({
|
||||||
|
label: 'Aggregate as Sum',
|
||||||
|
apply: (c) => withChannelAggregate(c, measureChannel, 'sum'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (mark === 'bar') fixes.push({ label: 'Swap X/Y', apply: withSwappedXY });
|
||||||
warnings.push({
|
warnings.push({
|
||||||
channel: category,
|
channel: category,
|
||||||
message: `This draws one mark per row (${rowCount} in this dataset), so the category-axis labels will overlap. ${fix}`,
|
message: `This draws one mark per row (${rowCount} in this dataset), so the category-axis labels will overlap.`,
|
||||||
|
fixes: fixes.length ? fixes : undefined,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const stats = statsFor(config.encodings[category]?.field, columns);
|
const stats = statsFor(config.encodings[category]?.field, columns);
|
||||||
if (stats && stats.distinct > CROWDED_CATEGORY_DISTINCT) {
|
if (stats && stats.distinct > CROWDED_CATEGORY_DISTINCT) {
|
||||||
const fix =
|
// Aggregated already, so the remedy is fewer categories (filter — not yet a
|
||||||
mark === 'bar'
|
// builder control) or, for a bar, a horizontal flip where long lists fit.
|
||||||
? 'Use Swap X/Y for a horizontal bar where long lists stay readable, or filter to fewer categories.'
|
const fixes: BuilderWarningFix[] =
|
||||||
: 'Filter to fewer categories, or group the long tail into an "Other".';
|
mark === 'bar' ? [{ label: 'Swap X/Y', apply: withSwappedXY }] : [];
|
||||||
warnings.push({
|
warnings.push({
|
||||||
channel: category,
|
channel: category,
|
||||||
message: `This category axis has ${cardinalityText(stats)} distinct values, so its labels will overlap. ${fix}`,
|
message: `This category axis has ${cardinalityText(stats)} distinct values, so its labels will overlap.`,
|
||||||
|
fixes: fixes.length ? fixes : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user