17 KiB
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/chart-builder-research.md(the convergence of Draco, Voyager, the FT Visual Vocabulary, and Datawrapper). The richer "intent-first" front door (ask what do you want to show? and recommend a chart) is explicitly out of scope for now and noted there as a future tier.
Opening
- Launched from a selected dataset in the Datasets manager via that dataset's "build chart" action.
- Opens as a modal dialog over the application; the URL reflects the dataset's "build" action so the open builder is shareable/restorable (see Application Shell & Navigation).
- On open, the builder loads the selected dataset, displays its name, and pre-populates sensible defaults (see below). If no dataset is available, it shows a "No dataset loaded" message and offers no controls.
Layout
A two-pane modal:
- Left — configuration: dataset name, 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), optional width/height inputs, and a "Create Snippet" action.
- 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 what the right-hand chart renders. A transform-aware "data inspector" that shows the resolved rows is a planned future direction — see
docs/data-inspector-exploration.md.)
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 offersis/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 five mark types: Bar, Line, Point, Area, Circle.
- On open, the mark defaults to the type that best fits the pre-populated X/Y field-type shape (Tier B smart default): a temporal axis against a measure → Line; two measures → Point; a category against a measure → Bar; two categories → Point; and Bar as the fallback when only one axis (or none) is mapped. The user can switch to any of the five afterward.
- 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 places it on the armed channel, else the first empty channel that accepts it (X→Y→Colour→Size). A channel slot is armed by clicking it (then a clicked field fills it); the armed slot shows a "pick a field" prompt. A field that no channel can take is a no-op.
- 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 cycles the field's type within the set 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 an "or constant" affordance; 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 (a measure / Quantitative field): one of
Sum,Mean,Median,Min,Max, orNone. (The field-lessCountmeasure 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 — orNone(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, orNone— the standard way to rank a bar chart. - Stacking (a Bar or Area mark with a Color series):
Stacked(absolute) or100%(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 §5 (polite announcement, focus moved off the removed button). These follow the chart-choice research (docs/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 Bar/Line/Area whose X and Y are both categories (nothing to measure).
- Two measures on a non-scatter mark (a scatter — Point/Circle — usually reads better).
- An Area chart split into multiple colour series (per-series change is hard to see).
- A Bar/Line/Area that pairs a category axis with a raw (un-aggregated) measure over a many-row dataset — it draws one mark, and one axis label, per row, so the category axis becomes an unreadable picket fence. The hint suggests aggregating the measure (one mark per category) or, for a bar, flipping to a horizontal bar (Swap X/Y) where long labels stay readable (FT Visual Vocabulary / Datawrapper). Only the un-aggregated case (mark-count = row-count) is detected; flagging an aggregated axis that still has many distinct categories needs per-column distinct counts the profiler does not yet compute (a known gap).
A clean configuration shows no hints.
Dimensions (optional)
- Optional numeric Width and Height inputs in pixels.
- When left empty, the chart uses default/responsive sizing (consistent with Live Preview); when provided, the values are written into the spec.
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.
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 /timeUnittransform; a constant encoding is a{ value }), chart-level sort and stacking where set, and any explicit width/height. - Channels left unmapped are omitted; if no encodings exist the spec omits the encoding block entirely (prevented by validation here).
- Creates a new snippet from that spec with an auto-generated descriptive name, adds it to the snippet library, and records that it was built from the dataset.
- Links the snippet to the dataset by recording the dataset reference, so the bidirectional snippet↔dataset relationship is established (see Datasets).
- 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.