mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
efb5a9bbe0
A collapsible Data panel under the Live Preview and Chart Builder charts shows the rows the chart actually uses, switching between Input (parsed source) and Resolved (post-transform) views read from the live Vega view. Collapsed by default; the open-state and a draggable height divider persist. Rows come through a new RenderHandle.inspectData() accessor, so no component touches the view: core/result-data picks the most-upstream source and most-downstream result from the compiled dataflow, read lazily. The divider reuses the window-splitter pattern (horizontal variant). Consolidations: a shared DataTable primitive replaces the inspector's and the builder's duplicate read-only tables; useResizeDrag merges the col/row drag-gesture twins. Docs: spec 04/06 and arch 05/10 updated; the now-shipped exploration memo removed.
171 lines
24 KiB
Markdown
171 lines
24 KiB
Markdown
# 06 · Chart Builder
|
||
|
||
The Chart Builder is a visual, no-JSON way to compose a Vega-Lite chart from a selected dataset. The user picks a mark type and maps the dataset's columns to encoding channels; the builder produces a complete Vega-Lite spec and saves it as a new snippet that references the dataset. It is intended for users who want to start a chart quickly without hand-writing JSON in the _Spec Editor & Draft/Published Workflow_.
|
||
|
||
> **Design level — "smart + guarded" (Tier B).** The builder is field-first — the user works from a shelf of the dataset's columns and drops them onto encoding channels — and stays within the inputs below, but it is not a dumb composer: it picks a sensible default mark for the data shape, offers only field types valid for each column, keeps unsuitable channel mappings out of reach, and surfaces non-blocking guidance for encodings that render poorly. These behaviors are derived from cross-source chart-choice research recorded in [`docs/exploration/chart-builder-research.md`](../exploration/chart-builder-research.md) (the convergence of Draco, Voyager, the FT Visual Vocabulary, and Datawrapper). On top of this "smart + guarded" base sits the **intent-first front door** (Tier C — _what do you want to show?_, see _Intent_ below): an on-ramp that recommends a whole chart from the user's stated intent, without replacing the mark-first builder beneath it.
|
||
|
||
## Opening
|
||
|
||
The builder has several doors; the guided creation path must be visible where the intent to make a chart forms, not buried behind the data model.
|
||
|
||
- **The library's creation surface** — the primary **Build Chart** action (see _Snippet Library → The List_). Opens the builder with no preselected dataset; the builder picks the **most recently modified dataset** itself.
|
||
- **The onboarding canvas** — the data-first "Build a chart from your data" door (see _Snippet Library → First-Run & Empty Workspace_).
|
||
- **A dataset's "Build Chart" action** in the _Datasets_ manager — the contextual shortcut; opens the builder on that dataset.
|
||
- Opens as a modal dialog over the application; the URL reflects the loaded dataset's "build" form (or `#build` while no dataset is loaded) so the open builder is shareable/restorable (see _Application Shell & Navigation_).
|
||
- On open, the builder loads its dataset and pre-populates sensible defaults (see below).
|
||
|
||
### Dataset picker
|
||
|
||
Which data the chart builds from is itself a builder choice. A **Dataset** picker sits at the top of the configuration pane, showing the loaded dataset and letting the user switch to any other without leaving the builder.
|
||
|
||
- Switching while the configuration is still the untouched opening default **re-derives fresh smart defaults** for the new dataset.
|
||
- Switching after the user has built on the configuration **rebases** it instead: chart-level intent survives (mark, title/subtitle, explicit size, sort/stack, calculated fields, expression filters), while bindings to columns the new dataset lacks are shed (encodings cleared, predicate filters dropped). A same-schema dataset — the common switch — keeps everything.
|
||
|
||
### No datasets
|
||
|
||
With an empty dataset library the builder shows a **no-datasets state** instead of controls: it says what the builder does and offers one primary next step — **Add a dataset**, which opens the _Datasets_ manager on its create form. The guided path never dead-ends.
|
||
|
||
### Intent (the front door)
|
||
|
||
A persistent **"What do you want to show?"** strip sits at the top of the configuration pane, under the dataset picker — the builder's guided on-ramp (Tier C). It offers a small set of analytic **intents**, each of which, when chosen, **sets the whole chart up for you** ("do it for me", modelled on Tableau's _Show Me_):
|
||
|
||
- The intents map the FT Visual Vocabulary / Datawrapper taxonomy onto the builder's marks and channels: **Compare** (magnitude across categories → a bar of counts), **Ranking** (the same, sorted by value), **Change over time** (a line of the first measure over a date), **Correlation** (a scatter of two measures), **Distribution** (a histogram — a binned measure vs count), **Part-to-whole** (a stacked bar split by a second category), and **Heatmap** (a two-category grid shaded by count).
|
||
- Picking an intent reshapes the chart — its **mark, encodings, and sort/stack** — to that intent's recommended layout, derived from the dataset's column roles. It **keeps** the dataset, the data transforms (filters / calculated fields), and the chart properties (title/subtitle/size): those are orthogonal to _what kind of chart_.
|
||
- The strip is an **on-ramp, not a gate**: it seeds the mark-first builder, which the user can then adjust freely or ignore entirely (and can always drop to Monaco). The chosen intent is builder-local steering — it **never enters the produced spec** (the JSON stays the document).
|
||
- On open, the strip **pre-highlights the intent matching the data-aware default** (so a category-vs-count default opens on _Compare_). The highlight is **derived from the configuration, not stored**: the chip whose recommended layout the live chart currently matches stays highlighted; once the user edits away from any recommended layout, none is highlighted — a **Custom** chart.
|
||
- Intents the dataset **cannot satisfy** are **disabled** (Tableau _Show Me_): a _Correlation_ needs two number columns, a _Heatmap_ or _Part-to-whole_ needs two category columns, _Change over time_ needs a date, and so on. A disabled chip stays perceivable and carries the reason in its accessible name; it is never hidden.
|
||
- Keyboard/focus follow [`architecture/10`](../architecture/10-interaction-and-feedback.md) §5 (an APG **toolbar**: one tab stop, a roving tabindex, arrow keys move focus, Enter/Space applies the intent — so navigation never reshapes the chart by accident).
|
||
|
||
## Layout
|
||
|
||
A two-pane modal:
|
||
|
||
- **Left — configuration:** the dataset picker, a **Data** section (row filters, calculated fields, and a collapsible row preview — see _Data_ below), mark type selector, a **field shelf** (the dataset's columns, field-first), a **Marks** card (the Colour and Size encodings), and a "Create Snippet" action. Chart-level properties (title/subtitle, width/height) sit on the **preview side**, under the chart (see _Chart properties_).
|
||
- **Right — live preview:** the **Columns** (X) and **Rows** (Y) shelves stacked above a rendered chart that updates as the configuration changes, with a placeholder/error area. Position is a property of the chart, so its controls sit on the chart (Tableau's Columns/Rows metaphor).
|
||
|
||
## Data (preview, filters, calculated fields)
|
||
|
||
A **Data** section sits at the top of the configuration pane — "here are your rows; shape them, then encode them". It previews the source rows first, then offers controls to shape them before encoding. Everything here is optional; a chart can be built with none of it. The shaping controls emit the spec's top-level `transform` array (see _Output_).
|
||
|
||
The section is ordered **input → shaping** so the distinction reads at a glance: the row preview (the **input** data) comes first, the filters and calculated fields (which shape what the chart actually draws) come below it.
|
||
|
||
### Data preview
|
||
|
||
- The first item in the section: a collapsible, **read-only** sample of the dataset's first rows (capped), with a per-column **type chip** in each header. It lets the user sanity-check inferred types against the actual values _before_ building — exactly when type inference is most likely to surprise. Editing the data is out of scope. A non-tabular payload (a single JSON object, TopoJSON, an unfetched URL) has no rows to show.
|
||
- The preview shows the dataset's **raw source rows** — _before_ the filters and calculated fields below are applied; it does not show derived columns. Its position above those controls makes that explicit: it is the input, not the result. The transformed result is shown by the **data inspector** under the chart on the preview side (below), which switches between input and resolved rows read from the rendered view.
|
||
|
||
### Filters
|
||
|
||
- A list of row filters, each added via **Add filter** and removable. All filters combine (logical **AND**) and apply to the raw rows **before** any encoding aggregation, so "filter rows, then aggregate" is the natural reading.
|
||
- A filter is, by default, a **guarded predicate** — a **field**, an **operator**, and a **value** — needing no expression for the common case:
|
||
- The **field** dropdown offers the dataset's columns plus any calculated fields.
|
||
- The **operators** offered depend on the field's type: a measure or temporal field offers `is` / `is not` / `<` / `≤` / `>` / `≥` / `is between` (two bounds); a category offers `is` / `is not` / `is one of` (a comma-separated membership list). A quantitative value compares as a number; other types compare as text (ISO dates sort correctly as text).
|
||
- A filter can be switched to an **expression** power-mode — a raw Vega predicate expression (e.g. `datum.value > 0`) — for what the guarded shelf can't say. The toggle is reversible.
|
||
- An **incomplete** filter (no value yet, a blank range bound, an empty or syntactically-invalid expression) is ignored, so the live preview keeps rendering while the user types.
|
||
|
||
### Calculated fields
|
||
|
||
- A list of derived fields, each added via **Add field** and removable: a **name** and a **Vega expression** that produces a new column (e.g. `profit` = `datum.revenue - datum.cost`).
|
||
- A named calculated field appears in the encoding **channel dropdowns** like any real column (it defaults to **Quantitative**, the common arithmetic case, and its type can be overridden on the channel within the valid set). Calculated fields are applied **before** filters, so a filter may reference a derived field.
|
||
- Removing or renaming a calculated field that a channel referenced **clears that channel** (the produced spec never encodes a field that no longer exists).
|
||
|
||
### Expression validation
|
||
|
||
- Both expression inputs (a filter's expression mode, a calculated field) are validated with **Vega's own expression parser** — the same one the chart uses — so a syntax error is reported **inline** the moment it appears, matching exactly what the chart would accept.
|
||
- A `datum.<field>` reference that does not match a known column raises a soft **"unknown field"** warning (a typo guard) without blocking — the value is genuinely valid Vega, it just won't resolve.
|
||
- An expression that does not parse is **left out of the produced spec** (like an incomplete filter or an unnamed calculated field): the inline error is the only feedback, and a half-typed expression never reaches the renderer — the preview keeps showing the last valid chart.
|
||
|
||
## Inputs and Controls
|
||
|
||
### Mark type
|
||
|
||
- Single selection from an exact set of six mark types: **Bar, Line, Point, Area, Circle, Heatmap**. **Heatmap** is the Vega-Lite `rect` mark — an X×Y grid of cells shaded by a Colour measure; it is labelled by the chart it makes rather than its geometry, since "Rect" is opaque to the no-JSON audience.
|
||
- On open, the mark **defaults to the type that best fits the pre-populated X/Y field-type shape** (Tier B smart default): a temporal axis against a measure → **Line**; two measures → **Point**; a category against a measure → **Bar**; two categories → **Point**; and **Bar** as the fallback when only one axis (or none) is mapped. **Heatmap is never the auto-default** — it reads only with a Colour measure, which the X/Y shape alone can't determine, so it stays a deliberate pick (guidance nudges the missing Colour). The user can switch to any of the six afterward.
|
||
- Exactly one mark type is active at any time; selecting one updates the preview.
|
||
|
||
### Encoding channels
|
||
|
||
Exactly four channels are offered: **X, Y** (the positional axes, on the on-chart Columns/Rows shelves) and **Colour, Size** (the Marks card). Assignment is **field-first**.
|
||
|
||
- **The field shelf** lists the dataset's detected columns (see _Datasets_), each with a small type glyph, plus any calculated fields and a field-less **"Count of records"** measure (Vega-Lite `count`). Past a threshold of columns the shelf groups into **Dimensions** (categories/dates) and **Measures** (numerics); a small dataset stays a single flat list. A column already mapped somewhere is dimmed (it may still be placed on more than one channel).
|
||
- **Assigning a field:** clicking a shelf field opens an explicit **channel chooser** listing the channels that accept it (an occupied channel is labelled with what it would replace); picking one places the field there. With a channel **armed**, the chooser is skipped — the clicked field fills the armed channel directly. A channel slot is armed by clicking it; arming is visible at the shelf (an accent ring plus a status line naming the target — "Assigning to X — choose a field below. Esc cancels"), and **Esc** disarms without closing the builder. A field that no channel can take offers no choices.
|
||
- **A mapped channel is a pill:** a leading type chip, the field (or "Count") label, and a remove (×). The type chip **is the field-type control** — activating it opens a **direct pick** of the types **valid for that column** and channel (Tier B valid-type locking): number → {Quantitative (default), Ordinal, Nominal}; date → {Temporal}; text → {Nominal (default), Ordinal}; boolean → {Nominal}. When only one type applies (e.g. a date), the chip is inert. A fresh mapping defaults its type from the inferred column type (numeric → Quantitative, date → Temporal, otherwise Nominal).
|
||
- **Constant values (the Property model):** the **Colour** and **Size** channels may instead hold a **fixed constant** — a literal colour or size applied to every mark, emitted as Vega-Lite `{ value }` rather than a field binding. An empty Colour/Size slot offers a **"Use a constant"** ghost button; the bound constant shows a colour picker (Colour) or a number (Size). X and Y stay field-only (a constant position is not useful). Switching a channel between a field and a constant is reversible, and the prior field type is preserved across the toggle.
|
||
- **Size discipline:** the **Size** channel accepts only columns whose natural type is a magnitude (numeric) — size implies an ordered magnitude, so categories and dates are not placed on Size by assignment (they remain available on X/Y/Colour). A constant size is always allowed.
|
||
- **Clearing** a channel (the pill's ×) leaves it out of the produced spec.
|
||
- A **Swap X/Y** control, by the on-chart shelves, exchanges the X and Y bindings (field/constant and type) in one click — for quickly flipping the axes of the pre-populated default.
|
||
|
||
### Faceting placeholder (reserved)
|
||
|
||
Each on-chart shelf shows, beside its axis slot, a **non-interactive placeholder** for **faceting → small multiples** (a future capability). It only signals where row/column faceting will live; it does nothing yet.
|
||
|
||
### Transforms (per channel)
|
||
|
||
Once a column is mapped, the channel offers the transforms that apply to its field type — and only those:
|
||
|
||
- **Aggregate** (any field — the menu narrows by field type): a Quantitative field offers `Sum`, `Mean`, `Median`, `Min`, `Max`, `Count distinct`, or `None`; a Temporal or Ordinal field offers `Min`, `Max`, `Count distinct` (an ordering but no arithmetic); a Nominal field offers `Count distinct` alone. `Count distinct` counts a field's unique values, so the channel reads as a **quantitative measure** whatever the field's own type (e.g. "unique customers per region" on a Color or Y channel); the field's asserted type is preserved and restored when the aggregate is removed. (The field-less `Count` measure is chosen via the "Count of records" column option above.)
|
||
- **Bin** (a Quantitative field): bins the values into ranges — e.g. a Quantitative X binned with a Count Y is a histogram. Binning and aggregating the same field are mutually exclusive (setting one clears the other).
|
||
- **Granularity** (a Temporal field): a Vega-Lite `timeUnit` — Year, Year-Quarter, Year-Month, Year-Month-Day, Quarter, Month, Week, Day of month, Day of week, Hour — or `None` (raw timestamps). Defaults to **None** (no silent change to what the raw data shows).
|
||
|
||
### Sort and stacking (chart-level)
|
||
|
||
These controls appear only when they apply:
|
||
|
||
- **Sort** (when X and Y form a category-vs-measure pair): sorts the categorical axis by the measure — `Ascending`, `Descending`, or `None` — the standard way to rank a bar chart.
|
||
- **Stacking** (a Bar or Area mark with a Color series): `Stacked` (absolute) or `100%` (normalized, part-to-whole). Bars/areas without a Color series, or other marks, show no stacking control.
|
||
|
||
### Default pre-population
|
||
|
||
- On open, the 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)
|
||
|
||
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/exploration/chart-builder-research.md`](../exploration/chart-builder-research.md)) and include, for example:
|
||
|
||
- A **Line**, **Area**, or **Heatmap** mark with only one axis mapped (both axes are needed to draw it).
|
||
- A **Bar/Line/Area** whose X and Y are both categories (nothing to measure).
|
||
- A **Heatmap** with both axes mapped but **no measure on Colour** (its cells have nothing to shade) — offers a one-click _Colour by count_, the canonical cross-tab heatmap. _Two measures on a heatmap are exempt from the scatter nudge below: a binned 2-D histogram is two quantitative axes shaded by count._
|
||
- **Two measures** on a non-scatter mark (a scatter — Point/Circle — usually reads better; Heatmap excepted).
|
||
- An **Area** chart split into multiple colour series (per-series change is hard to see).
|
||
- A **Bar/Line/Area** that pairs a category axis with a **raw (un-aggregated) measure** over a many-row dataset — it draws one mark, and one axis label, per row, so the category axis becomes an unreadable picket fence. The hint suggests aggregating the measure (one mark per category) or, for a bar, flipping to a horizontal bar (Swap X/Y) where long labels stay readable (FT Visual Vocabulary / Datawrapper). Only the un-aggregated case (mark-count = row-count) is detected; flagging an _aggregated_ axis that still has many distinct categories needs per-column distinct counts the profiler does not yet compute (a known gap).
|
||
|
||
A clean configuration shows no hints.
|
||
|
||
### Chart properties (optional)
|
||
|
||
A slim strip pinned **under the live preview** — these describe the chart itself, so they live on the chart side rather than in the configuration pane:
|
||
|
||
- **Title** and **Subtitle** text inputs, written into the spec's top-level `title` (a bare string for a lone title; the `{ text, subtitle }` object form when both are set). A subtitle is emitted only alongside a title — Vega-Lite has no standalone subtitle — so the Subtitle input is disabled until a title exists. A non-empty title is also preferred verbatim as the created snippet's name.
|
||
- Optional numeric **Width** and **Height** inputs in pixels.
|
||
- When left empty, sizing is default/responsive (consistent with _Live Preview_); when provided, the values are written into the spec **and the builder preview renders at that explicit size** (the preview's fit-to-pane sizing applies only while sizing is auto).
|
||
|
||
## Live Preview
|
||
|
||
- The right pane renders the chart described by the current mark, encodings, and dimensions, resolving the dataset reference to its actual data (same rendering behavior as _Live Preview_).
|
||
- Updates are debounced: changes to mark, encodings, or dimensions trigger a re-render after a short pause rather than on every keystroke.
|
||
- While no encoding is mapped, the pane shows a placeholder instructing the user to configure at least one encoding.
|
||
- If the spec fails to render, the pane shows an inline error message describing the problem instead of a chart.
|
||
- Under the chart, the same **data inspector** as _Live Preview_ (Input | Resolved rows, collapsed by default) lets the user compare the source rows against what the builder's filters and calculated fields produced. It appears only with a live chart, so it never doubles the placeholder/error.
|
||
|
||
## Validation
|
||
|
||
- A chart requires **at least one** channel bound — a field, a count, or a constant value.
|
||
- While nothing is bound, the "Create Snippet" action is disabled and the preview shows the configuration prompt.
|
||
|
||
## Output / Create
|
||
|
||
Selecting "Create Snippet" produces the final artifact:
|
||
|
||
- Builds a complete Vega-Lite spec containing: the schema reference, a named data reference to the dataset, any top-level `transform` (calculated fields first, then row filters — see _Data_), the chosen mark (with tooltips enabled), the bound encodings (a field encoding carries its field and field type plus any aggregate / bin / `timeUnit` transform; a constant encoding is a `{ value }`), chart-level sort and stacking where set, any title/subtitle, and any explicit width/height.
|
||
- Channels left unmapped are omitted; if no encodings exist the spec omits the encoding block entirely (prevented by validation here).
|
||
- Creates a new snippet from that spec with an auto-generated descriptive name, adds it to the snippet library, and records that it was built from the dataset.
|
||
- Links the snippet to the dataset by recording the dataset reference, so the bidirectional snippet↔dataset relationship is established (see _Datasets_).
|
||
- Closes the builder; the newly created snippet becomes the active snippet in the library/editor. **No success toast** — the result is immediately visible (the new snippet opens in the editor), so a toast would be noise (architecture 10 §1, "toast only what the user can't already see"). This refines the earlier blanket "every action toasts" rule, consistent with the Extract-to-dataset / publish reconciliation.
|
||
|
||
## Closing
|
||
|
||
- The builder can be dismissed without creating anything (close control / modal dismissal).
|
||
- Closing resets all builder state (dataset, mark type, encodings, dimensions, preview) so a later open starts fresh, and any pending preview render is cancelled.
|