/** * Chart Builder — the modal body (spec §06). * * A two-pane composer. Left: the Data section (filters, calculated fields, row * preview), the mark selector, the field shelf (columns as type-glyphed chips), the * Marks card (Colour/Size — field or constant), chart-level sort/stacking, guidance, * and Create. Right: the on-chart Columns/Rows shelves, the live preview, and the * chart-properties strip (title/subtitle/size). All spec logic and defaults/guards * come from `@core/chart-builder` via `ChartBuilderStore`; this component is the view. * * Assignment is field-first: a chip click opens an explicit channel chooser, or * assigns directly when a channel is armed (the slot's "Pick a field…" state, made * visible at the shelf). A bound channel renders as a pill — a field-type chip that * opens a direct type pick, the field name, a remove ✕ — with its per-type * transforms (aggregate / bin / granularity) inline beside it. Pickers use * `SelectControl`, not native selects. The preview is builder-local (its own * debounced render over the shared `chart-renderer` service) rather than a reuse of * `LivePreview`, which is bound to the snippet editor's stores. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useShallow } from 'zustand/react/shallow'; import type { VisualizationSpec } from 'vega-embed'; import { CHANNELS, CHART_INTENTS, MARK_TYPES, TIME_UNITS, activeIntent, builderWarnings, channelAcceptsValue, defaultChannelValue, defaultFieldType, effectiveColumns, filterOpArity, intentApplicable, isBuilderConfigValid, isChannelTypeAllowed, isColumnAllowedOnChannel, isValueMapping, supportsAggregate, supportsBin, supportsSort, supportsStack, supportsTimeUnit, validAggregateOps, validFieldTypes, validFilterOps, type BuilderCalculate, type AggregateOp, type BuilderColumns, type BuilderFilter, type BuilderWarningFix, type ChannelMapping, type ChannelName, type ChartIntent, type FieldType, type FilterOp, type MarkType, type TimeUnit, } from '@core/chart-builder'; import { referencedFields, validateExpression } from '@core/expr-validate'; import { tabularRows } from '@core/dataset'; import type { ColumnType } from '@core/type-inference'; import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering'; import { chartConfigFor } from '@core/vega-themes'; import { ChartTooLargeError, renderSpec, type RenderHandle } from '../services/chart-renderer'; import { closeModal, openModal } from '../modals/ModalCoordinator'; import { useAppStore } from '../stores/AppStore'; import { useDatasetStore } from '../stores/DatasetStore'; import { COUNT_FIELD, selectBuilderSpecText, selectBuilderValid, useChartBuilderStore, } from '../stores/ChartBuilderStore'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; import { Button } from './Button'; import { ColorField } from './ColorField'; import { DataInspectorPanel } from './DataInspector'; import { DataTable } from './DataTable'; import { IconButton } from './IconButton'; import { SelectControl } from './SelectControl'; import { Icon } from './Icon'; import styles from './ChartBuilderModal.module.css'; 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`). */ function titleCase(s: string): string { return s.charAt(0).toUpperCase() + s.slice(1); } /** User-facing mark labels. All but `rect` are their geometry name title-cased; `rect` * is shown as **Heatmap** — the chart it makes, since "Rect" is opaque to the no-JSON * audience the builder serves (the spec's mark-set table notes the rect↔heatmap pairing). */ const MARK_LABELS: Record = { bar: 'Bar', line: 'Line', point: 'Point', area: 'Area', circle: 'Circle', rect: 'Heatmap', }; const MARK_OPTIONS: ReadonlyArray> = MARK_TYPES.map((m) => ({ value: m, label: MARK_LABELS[m], title: m === 'rect' ? 'Heatmap (rect mark)' : undefined, })); /** Intent front-door copy (spec §06 → Intent). Label = the chip; needs = what the * dataset must have for the intent to apply (shown when the chip is disabled). */ const INTENT_LABELS: Record = { compare: 'Compare', ranking: 'Ranking', time: 'Change over time', correlation: 'Correlation', distribution: 'Distribution', partToWhole: 'Part-to-whole', heatmap: 'Heatmap', }; const INTENT_NEEDS: Record = { compare: 'a category column', ranking: 'a category column', time: 'a date column', correlation: 'two number columns', distribution: 'a number column', partToWhole: 'two category columns', heatmap: 'two category columns', }; const CHANNEL_LABELS: Record = { x: 'X', y: 'Y', color: 'Color', size: 'Size', }; /** Terse N | O | Q | T abbreviations for a field type (with full-name tooltips). */ const TYPE_ABBR: Record = { nominal: 'N', ordinal: 'O', quantitative: 'Q', temporal: 'T', }; /** Readable labels for the non-count aggregate operators (`validAggregateOps` * supplies the per-type menu — e.g. a Nominal field offers only Count distinct). */ const AGGREGATE_LABELS: Record, string> = { sum: 'Sum', mean: 'Mean', median: 'Median', min: 'Min', max: 'Max', distinct: 'Count distinct', }; /** Friendly labels for each temporal granularity. */ const TIME_UNIT_LABELS: Record = { year: 'Year', yearquarter: 'Year-Quarter', yearmonth: 'Year-Month', yearmonthdate: 'Year-Month-Day', quarter: 'Quarter', month: 'Month', week: 'Week', date: 'Day of month', day: 'Day of week', hours: 'Hour', }; /** Readable labels for the filter operators, phrased to read as " ". */ const FILTER_OP_LABELS: Record = { equal: 'is', notEqual: 'is not', lt: '<', lte: '≤', gt: '>', gte: '≥', range: 'is between', oneOf: 'is one of', }; /** Rows shown in the builder's data-preview table before truncating (1D, spec §06). */ const PREVIEW_ROW_LIMIT = 50; /** * The Vega expression-language reference — both expression inputs (a filter's * expression mode, a calculated field) compile to a raw Vega expression, so this is * the precise vocabulary. Surfaced contextually (only when an expression is in play), * external so it falls outside offline scope, opened in a new tab. */ const VEGA_EXPRESSION_DOCS_URL = 'https://vega.github.io/vega/docs/expressions/'; /** A safe `datum` accessor for a column name (dot for identifiers, bracket otherwise). */ function datumRef(name: string): string { return /^[A-Za-z_$][\w$]*$/.test(name) ? `datum.${name}` : `datum[${JSON.stringify(name)}]`; } /** * An example expression seeded as the input placeholder, drawn from the dataset's own * columns — discovery for writing Vega expressions without an autocomplete popup. A * filter example reads as a predicate; a calculate example as a derived value. */ function exprPlaceholder(columns: BuilderColumns, kind: 'filter' | 'calc'): string { const numeric = columns.columnTypes.find((c) => c.type === 'number')?.name; const anyField = columns.columns[0]; if (kind === 'filter') { if (numeric) return `${datumRef(numeric)} > 0`; return anyField ? `${datumRef(anyField)} != null` : 'datum.value > 0'; } if (numeric) return `${datumRef(numeric)} * 2`; return anyField ? datumRef(anyField) : 'datum.a + datum.b'; } /** A compact type indicator for a column option (# / date / bool / text). */ function typeBadge(type: ColumnType): string { switch (type) { case 'number': return '#'; case 'date': return 'date'; case 'boolean': return 'bool'; default: return 'text'; } } /** True when the mapping is the field-less Count-of-records measure. */ function isCount(mapping: ChannelMapping | null): boolean { return !!mapping && mapping.aggregate === 'count' && mapping.field === undefined; } /** Past this many columns the field shelf splits into Dimensions / Measures groups; * below it (and unless both groups are non-empty) it stays a single flat list. */ const FIELD_SHELF_SPLIT_MIN = 7; /** The field types a mapping may cycle through on its channel (its column's valid * types, narrowed by the channel — e.g. Size keeps only the measure types). */ function channelTypeOptions( channel: ChannelName, mapping: ChannelMapping, columns: BuilderColumns, ): FieldType[] { if (mapping.field === undefined) return []; const colType = columns.columnTypes.find((c) => c.name === mapping.field)?.type ?? 'string'; return validFieldTypes(colType).filter((t) => isChannelTypeAllowed(channel, t)); } /** * A bound channel rendered as a Tableau-style **pill**: a leading type chip, the field * (or "Count") label, and a remove (×). The type chip *is the control* — clicking it * cycles the field's type within the set valid for this channel (disabled when only one * type applies, e.g. a date). Any applicable transforms (aggregate / bin / granularity) * sit in a compact row beneath the pill. A **constant** binding (Colour/Size only) * shows the value editor instead — a colour picker or a number — emitting `{ value }`. */ function ChannelPill({ channel, mapping, columns, }: { channel: ChannelName; mapping: ChannelMapping; columns: BuilderColumns; }) { const setChannelColumn = useChartBuilderStore((s) => s.setChannelColumn); const setChannelType = useChartBuilderStore((s) => s.setChannelType); const setChannelAggregate = useChartBuilderStore((s) => s.setChannelAggregate); const setChannelBin = useChartBuilderStore((s) => s.setChannelBin); const setChannelTimeUnit = useChartBuilderStore((s) => s.setChannelTimeUnit); const setChannelConstant = useChartBuilderStore((s) => s.setChannelConstant); const clearLabel = `Remove ${CHANNEL_LABELS[channel]}`; const clear = () => setChannelColumn(channel, null); // A constant value (the Property model) — Colour or Size only. if (isValueMapping(mapping)) { const isColor = channel === 'color'; return (
value {isColor ? ( setChannelConstant(channel, v)} /> ) : ( setChannelConstant(channel, e.target.value)} /> )}
); } const count = isCount(mapping); const typeOptions = count ? [] : channelTypeOptions(channel, mapping, columns); const canPick = typeOptions.length > 1; const currentType: FieldType = count ? 'quantitative' : mapping.type; const label = count ? 'Count' : (mapping.field ?? ''); const hasTransforms = !count && (supportsAggregate(mapping.type) || supportsBin(mapping.type) || supportsTimeUnit(mapping.type)); return (
{/* The type chip opens a direct pick of the valid types (council 2026-06-12: a cycling button gave keyboard/SR users no way to jump to a type). */} ({ value: t, label: titleCase(t) }))} value={currentType} onSelect={(t) => setChannelType(channel, t)} triggerClassName={styles.pillType} triggerContent={TYPE_ABBR[currentType]} triggerTitle={titleCase(currentType)} disabled={!canPick} /> {label}
{hasTransforms && (
{supportsAggregate(mapping.type) && (
({ value: op, label: AGGREGATE_LABELS[op], })), ]} value={mapping.aggregate && mapping.aggregate !== 'count' ? mapping.aggregate : ''} onSelect={(v) => setChannelAggregate(channel, v || undefined)} />
)} {supportsBin(mapping.type) && ( )} {supportsTimeUnit(mapping.type) && (
({ value: u, label: TIME_UNIT_LABELS[u] })), ]} value={mapping.timeUnit ?? ''} onSelect={(v) => setChannelTimeUnit(channel, v || undefined)} />
)}
)}
); } /** * One encoding target. When the channel is bound it shows its `ChannelPill`; when empty * it is an **assign target** — a button that arms the channel (field-first: click it, * then click a field in the shelf to fill it), plus an "or constant" affordance on the * channels that take a fixed value (Colour/Size). `hint` is the empty-state prompt. */ function ChannelSlot({ channel, hint }: { channel: ChannelName; hint?: string }) { const baseColumns = useChartBuilderStore((s) => s.columns); const calculates = useChartBuilderStore((s) => s.config.calculates); const columns = useMemo( () => effectiveColumns(baseColumns, calculates), [baseColumns, calculates], ); const mapping = useChartBuilderStore((s) => s.config.encodings[channel] ?? null); const active = useChartBuilderStore((s) => s.activeChannel === channel); const focusChannel = useChartBuilderStore((s) => s.focusChannel); const setChannelConstant = useChartBuilderStore((s) => s.setChannelConstant); if (mapping) { return ; } return (
{channelAcceptsValue(channel) && ( // A ghost button, not a link-styled affordance: it acts (binds a constant), // and Carbon draws the line at links navigate / buttons act (council // 2026-06-12). Verb-first label per the content rules. )}
); } /** What a channel currently shows, for the assignment chooser's "replaces …" hint. */ function occupantLabel(mapping: ChannelMapping | null | undefined): string | undefined { if (!mapping) return undefined; if (mapping.value !== undefined) return 'replaces the constant'; if (mapping.aggregate === 'count' && !mapping.field) return 'replaces Count'; return mapping.field ? `replaces ${mapping.field}` : undefined; } /** Channel names as the assignment chooser shows them (the on-chart shelf words). */ const ASSIGN_LABELS: Record = { x: 'Columns (X)', y: 'Rows (Y)', color: 'Color', size: 'Size', }; /** * The field shelf (spec §06 → Encoding, field-first): the dataset's columns (plus any * calculated fields and a field-less "Count of records") as clickable chips with a type * glyph. Clicking a chip opens an explicit channel chooser (the channels that accept the * column; an occupied one says what it would replace); with a channel armed, the click * assigns straight there instead and Esc disarms. Past `FIELD_SHELF_SPLIT_MIN` columns it * groups into Dimensions (categories/dates) and Measures (numerics); a small dataset stays * flat. Already-mapped fields are dimmed (a field may still be placed on several channels). */ function FieldShelf() { const baseColumns = useChartBuilderStore((s) => s.columns); const calculates = useChartBuilderStore((s) => s.config.calculates); const encodings = useChartBuilderStore((s) => s.config.encodings); const assignField = useChartBuilderStore((s) => s.assignField); const activeChannel = useChartBuilderStore((s) => s.activeChannel); const focusChannel = useChartBuilderStore((s) => s.focusChannel); const columns = useMemo( () => effectiveColumns(baseColumns, calculates), [baseColumns, calculates], ); // Esc disarms the armed channel (captured so the modal itself doesn't close). // TODO: with a channel armed AND a SelectControl popover open, one Esc both closes // the popover and disarms — both are capture-phase document listeners, so // stopPropagation can't serialize them. Rare combination; needs a shared // escape-layer stack if it ever matters. useEffect(() => { if (!activeChannel) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.stopPropagation(); focusChannel(null); } }; document.addEventListener('keydown', onKey, true); return () => document.removeEventListener('keydown', onKey, true); }, [activeChannel, focusChannel]); const assigned = useMemo(() => { const set = new Set(); for (const ch of CHANNELS) { const m = encodings[ch]; if (m?.field) set.add(m.field); } return set; }, [encodings]); const colTypeOf = (name: string): ColumnType => columns.columnTypes.find((c) => c.name === name)?.type ?? 'string'; // A field chip: with a channel armed, clicking assigns straight there (the fast // path); unarmed, it opens an explicit channel chooser instead of silently // grabbing the first empty seat (council 2026-06-12 — NN/g #3, user choice). const fieldChip = ( name: string, display: string, domId: string, colType: ColumnType, glyph: string, ) => { const choices = CHANNELS.filter((ch) => isColumnAllowedOnChannel(ch, colType)); return ( ({ value: ch, label: ASSIGN_LABELS[ch], detail: occupantLabel(encodings[ch]), }))} onSelect={(ch) => assignField(name, ch)} beforeOpen={() => { if (activeChannel) { assignField(name); return false; } return true; }} triggerClassName={`${styles.shelfField} ${assigned.has(name) ? styles.shelfFieldUsed : ''}`} triggerContent={ <> {display} } /> ); }; const fieldButton = (name: string) => fieldChip( name, name, `f${columns.columns.indexOf(name)}`, colTypeOf(name), TYPE_ABBR[defaultFieldType(colTypeOf(name))], ); const countButton = fieldChip(COUNT_FIELD, 'Count of records', 'count', 'number', '∑'); const dimensions = columns.columns.filter((n) => colTypeOf(n) !== 'number'); const measures = columns.columns.filter((n) => colTypeOf(n) === 'number'); const split = columns.columns.length >= FIELD_SHELF_SPLIT_MIN && dimensions.length > 0 && measures.length > 0; return (
Fields {/* Arming a channel must be visible at the place the next click happens (NN/g #1): the shelf gains an accent ring and a status line naming the target. The hint is a polite status so AT hears the mode change too. */} {activeChannel && (

Assigning to {CHANNEL_LABELS[activeChannel]} — choose a field below. Esc cancels.

)}
{split ? ( <> {/* The visible count says "there's more below the fold" — the grouped shelf only appears for wide datasets, where the list scrolls. */} Dimensions · {dimensions.length}
{dimensions.map(fieldButton)}
Measures · {measures.length + 1}
{measures.map(fieldButton)} {countButton}
) : (
{columns.columns.map(fieldButton)} {countButton}
)}
); } /** The non-positional encodings (Colour, Size) — Tableau's "Marks" card. Each is a * `ChannelSlot`, so it takes a field or a constant. */ function MarksCard() { return (
Marks
{CHANNEL_LABELS.color}
{CHANNEL_LABELS.size}
); } /** * The on-chart **Columns** (X) and **Rows** (Y) shelves stacked above the preview, * Tableau-style — position is a property of the chart, so its controls sit on the * chart. A Swap X/Y action flips the two axes. The slot rows are shelf-shaped so * Phase 4 faceting can add a second slot per shelf without a layout change, but * no placeholder ships before the feature does (an affordance promising unbuilt * functionality is a promise, not a feature — NN/g #2/#8). */ function OnChartShelves() { const swapXY = useChartBuilderStore((s) => s.swapXY); return (
Axes
Columns
Rows
); } /** * Inline feedback for an expression input (filter expression / calculated field): * a parse error takes priority, else a soft warning for `datum.` references * that don't match a known column — a typo guard before the chart renders empty (1E). * Nothing renders for a valid, fully-resolved expression. `messageId` lets the owning * input point at this node via `aria-describedby`. * * Both severities are a **polite** live region carrying a **status glyph** (round * error / triangle warning), not an assertive alert and never colour alone: the * expression validates on every keystroke, so an assertive role would interrupt on * each character (APG Alert / WCAG 2.2.4), and severity must read without colour * (arch 10 §3; the input also carries `aria-invalid`). */ function ExprFeedback({ expr, columns, messageId, }: { expr: string; columns: BuilderColumns; messageId?: string; }) { const feedback = useMemo(() => { const validation = validateExpression(expr); if (!validation.valid) { return { kind: 'error' as const, text: validation.error ?? 'Invalid expression.' }; } const unknown = referencedFields(expr).filter((f) => !columns.columns.includes(f)); if (unknown.length > 0) { const plural = unknown.length > 1 ? 's' : ''; return { kind: 'warn' as const, text: `Unknown field${plural}: ${unknown.join(', ')} — not a column in this dataset.`, }; } return null; }, [expr, columns]); if (!feedback) return null; const isError = feedback.kind === 'error'; return (

{feedback.text}

); } /** * A contextual pointer to the Vega expression vocabulary, shown only when an * expression input is in play (a calculated field, or a filter in expression mode) — * the place the user needs to know what functions/operators exist. */ function ExprHelp() { return (

Filter expressions and calculated fields use the{' '} Vega expression language (opens in a new tab) .

); } /** One filter row — a guarded `field op value` predicate, or a raw expression. */ function FilterRow({ filter, columns }: { filter: BuilderFilter; columns: BuilderColumns }) { const setFilterField = useChartBuilderStore((s) => s.setFilterField); const updateFilter = useChartBuilderStore((s) => s.updateFilter); const setFilterMode = useChartBuilderStore((s) => s.setFilterMode); const removeFilter = useChartBuilderStore((s) => s.removeFilter); const expressionMode = filter.mode === 'expression'; const fieldType = filter.fieldType ?? 'nominal'; const op = filter.op ?? 'equal'; const arity = filterOpArity(op); const hasColumns = columns.columns.length > 0; // Links the expression input to its feedback line; harmless when no message renders // (aria-describedby to an absent id is ignored — GOV.UK error-message association). const exprMsgId = `filter-${filter.id}-expr-msg`; return (
{expressionMode ? ( updateFilter(filter.id, { expr: e.target.value })} /> ) : ( ({ value: name, label: name }))} value={filter.field} onSelect={(name) => setFilterField(filter.id, name)} triggerContent={filter.field ? undefined : 'Choose a field…'} /> )} removeFilter(filter.id)}>
{!expressionMode && (
({ value: o, label: FILTER_OP_LABELS[o], }))} value={op} onSelect={(o) => updateFilter(filter.id, { op: o })} /> {arity === 'range' ? ( <> updateFilter(filter.id, { value: e.target.value })} /> updateFilter(filter.id, { value2: e.target.value })} /> ) : ( updateFilter(filter.id, { value: e.target.value })} /> )}
)} {expressionMode && ( )} {hasColumns && ( )}
); } /** One calculated-field row — a name and a Vega expression producing a new column. */ function CalculateRow({ calc, columns }: { calc: BuilderCalculate; columns: BuilderColumns }) { const updateCalculate = useChartBuilderStore((s) => s.updateCalculate); const removeCalculate = useChartBuilderStore((s) => s.removeCalculate); const exprMsgId = `calc-${calc.id}-expr-msg`; return (
updateCalculate(calc.id, { as: e.target.value })} /> updateCalculate(calc.id, { expr: e.target.value })} /> removeCalculate(calc.id)} >
); } /** * A collapsible, read-only sample of the dataset's actual rows with a per-column type * chip in each header (1D) — lets the user sanity-check inferred types before building, * exactly when inference is most likely to surprise. Shows the base dataset columns * (calculated fields don't exist in the raw rows). Non-tabular payloads have no rows. */ function DataPreview() { const datasetId = useChartBuilderStore((s) => s.datasetId); const baseColumns = useChartBuilderStore((s) => s.columns); const dataset = useDatasetStore((s) => s.datasets.find((d) => d.id === datasetId) ?? null); const [open, setOpen] = useState(false); const rows = useMemo( () => (dataset ? tabularRows(dataset.data, dataset.format, PREVIEW_ROW_LIMIT) : null), [dataset], ); if (!dataset) return null; const typeOf = (name: string): ColumnType => baseColumns.columnTypes.find((c) => c.name === name)?.type ?? 'string'; return (
{open && (rows ? ( ( <> {col}{' '} {typeBadge(typeOf(col))} )} /> ) : (

This dataset has no tabular rows to preview.

))}
); } /** * The "Data" section at the top of the config pane: row filters and calculated fields * (the top-level transforms, 1C) plus a collapsible row preview (1D) — "here are your * rows, shape them, then encode them". Calculated fields appear in the channel * dropdowns below via the effective-columns derivation. */ function DataSection() { const baseColumns = useChartBuilderStore((s) => s.columns); const calculates = useChartBuilderStore((s) => s.config.calculates); const filters = useChartBuilderStore((s) => s.config.filters); const addFilter = useChartBuilderStore((s) => s.addFilter); const addCalculate = useChartBuilderStore((s) => s.addCalculate); const columns = useMemo( () => effectiveColumns(baseColumns, calculates), [baseColumns, calculates], ); // The expression reference is shown only when an expression input exists — a // calculated field, or a filter switched to expression mode. const hasExpression = (filters ?? []).some((f) => f.mode === 'expression') || (calculates ?? []).length > 0; return (
Data {/* The source rows come first, so they read as the input — distinct from the filters/calculated fields below, which shape what the chart actually draws. */}
Filters
{(filters ?? []).map((f) => ( ))}
Calculated fields
{(calculates ?? []).map((c) => ( ))}
{hasExpression && }
); } /** Sort control values: 'none' maps to an unsorted config. */ const SORT_OPTIONS: ReadonlyArray> = [ { value: 'none', label: 'None' }, { value: 'ascending', label: 'Asc' }, { value: 'descending', label: 'Desc' }, ]; const STACK_OPTIONS: ReadonlyArray> = [ { value: 'zero', label: 'Stacked' }, { value: 'normalize', label: '100%' }, ]; function BuilderPreview() { const hostRef = useRef(null); const handleRef = useRef(null); const generationRef = useRef(0); // The error contract (docs/architecture/10): a plain headline that names the // problem and points at the next step; any raw Vega diagnostic goes in `detail`, // shown behind a disclosure rather than in the headline. const [error, setError] = useState<{ message: string; detail?: 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); // Resolved-data disclosure: open state (modal-local, not persisted) + an epoch // bumped on each settled render so the open table re-reads the post-transform rows // the builder's filters/calculated fields produce (the output, beside the source // rows in the config pane's preview — see DataInspector). const [dataOpen, setDataOpen] = useState(false); const [renderEpoch, setRenderEpoch] = useState(0); const specText = useChartBuilderStore(selectBuilderSpecText); const valid = useChartBuilderStore(selectBuilderValid); // An explicit Chart size must show up in the preview — the 'width' fit mode // overwrites width AND drops height, so it only applies while sizing is auto. const explicitSize = useChartBuilderStore( (s) => s.config.width !== undefined || s.config.height !== undefined, ); const uiTheme = useAppStore((s) => s.uiTheme); const datasets = useDatasetStore(useShallow((s) => s.datasets)); useEffect(() => { const node = hostRef.current; const timer = setTimeout(() => { void (async () => { const mine = ++generationRef.current; if (!valid) { handleRef.current?.destroy(); handleRef.current = null; setError(null); setTooLarge(null); setRenderEpoch((e) => e + 1); return; } if (!node) return; try { const t0 = performance.now(); const parsed: unknown = JSON.parse(specText); const t1 = performance.now(); const prepared = prepareSpecForRender(parsed, { fitMode: explicitSize ? 'default' : 'width', datasets, }); const t2 = performance.now(); handleRef.current?.destroy(); // finalizing a huge prior SVG is itself a cost handleRef.current = null; const t3 = performance.now(); const handle = await renderSpec( node, prepared as VisualizationSpec, 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) { handle.destroy(); return; } handleRef.current = handle; setError(null); setTooLarge(null); setRenderEpoch((e) => e + 1); 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) { if (mine !== generationRef.current) return; setRenderEpoch((epoch) => epoch + 1); 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) { // Near-unreachable (the builder opens from an existing dataset), but if the // backing dataset is deleted mid-session the contract still wants the next step. setError({ message: `Dataset "${e.datasetName}" not found — recreate it from Datasets, then reopen the builder.`, }); setTooLarge(null); } else { // Plain headline; the raw Vega message folds into the disclosure below. setError({ message: "Couldn't render this chart.", detail: (e as Error).message, }); setTooLarge(null); } } })(); }, RENDER_DEBOUNCE_MS); return () => clearTimeout(timer); }, [specText, valid, explicitSize, uiTheme, datasets]); useEffect( () => () => { handleRef.current?.destroy(); handleRef.current = null; }, [], ); // The input + resolved rows the chart drew (the latter after the builder's // filters/calculated fields). Reads the live view through the handle; null when // no chart is up. const getInspectData = useCallback(() => handleRef.current?.inspectData() ?? null, []); return (
{!valid && (

Map at least one channel to a column to see a chart.

)} {valid && tooLarge && (

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.

)} ); } /** A positive integer from a dimension input; blank/garbage reads as "auto". */ function parseDim(raw: string): number | undefined { if (raw.trim() === '') return undefined; const n = Number(raw); return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined; } /** * Chart properties — title/subtitle and explicit width/height, as a slim strip * pinned under the preview: these describe *the chart*, so they live on the chart * side, not in the encoding pane (council 2026-06-12 — NN/g #4, the Tableau/Lyra * convention). The subtitle is disabled until a title exists because Vega-Lite has * no standalone subtitle (it nests under `title`). */ function ChartProps() { const title = useChartBuilderStore((s) => s.config.title ?? ''); const subtitle = useChartBuilderStore((s) => s.config.subtitle ?? ''); const width = useChartBuilderStore((s) => s.config.width); const height = useChartBuilderStore((s) => s.config.height); const setTitle = useChartBuilderStore((s) => s.setTitle); const setSubtitle = useChartBuilderStore((s) => s.setSubtitle); const setWidth = useChartBuilderStore((s) => s.setWidth); const setHeight = useChartBuilderStore((s) => s.setHeight); const hasTitle = title.trim() !== ''; return (
); } /** * The dataset picker at the top of the config pane (spec §06 → Dataset picker): * which data the chart builds from is itself a builder choice, so the builder can * open without a preselected dataset and the data can be switched without leaving. * Switching re-derives smart defaults while the config is untouched, and rebases * (keeps chart-level intent, sheds bindings to missing columns) once it isn't. */ function DatasetPicker({ datasetId }: { datasetId: number | null }) { const datasets = useDatasetStore(useShallow((s) => s.datasets)); const switchDataset = useChartBuilderStore((s) => s.switchDataset); return (
Dataset ({ value: String(d.id), label: d.name }))} value={datasetId !== null ? String(datasetId) : undefined} onSelect={(v) => switchDataset(Number(v))} triggerContent={datasetId === null ? 'Choose a dataset…' : undefined} />
); } /** * The intent-first front door (spec §06 → Intent): a persistent "what do you want to * show?" strip pinned under the dataset picker. Each chip applies a recommended mark + * channel layout — the "do it for me" (Tableau "Show Me"). Intents the dataset can't * satisfy are disabled (with the reason in their name), and the chip whose layout the * live chart matches stays highlighted; once the user edits away from any, none is — a * "Custom" chart. It seeds the builder; it never gates it (the user can ignore it and * drive the channels directly). The active intent is derived from the config, so no * intent state is stored. */ function IntentStrip() { const columns = useChartBuilderStore((s) => s.columns); const config = useChartBuilderStore((s) => s.config); const setIntent = useChartBuilderStore((s) => s.setIntent); const active = useMemo(() => activeIntent(config, columns), [config, columns]); const applicable = useMemo( () => new Set(CHART_INTENTS.filter((i) => intentApplicable(i, columns))), [columns], ); // APG toolbar (arch 10 §5): the chips are a single tab stop with a roving tabindex, // NOT seven independently-tabbable buttons (the pane-toggle precedent). Arrows MOVE // focus only — Enter/Space activates — because applying an // intent reshapes the whole chart; a radiogroup's select-on-arrow would do that on // every keypress. Disabled chips stay arrow-reachable so their "needs …" reason is // discoverable (APG: focusable disabled controls where discoverability is crucial). const btnRefs = useRef>([]); const tabStop = useMemo(() => { if (active) return CHART_INTENTS.indexOf(active); // an active intent is always enabled const firstEnabled = CHART_INTENTS.findIndex((i) => applicable.has(i)); return firstEnabled >= 0 ? firstEnabled : 0; }, [active, applicable]); const onKeyDown = (e: React.KeyboardEvent, index: number) => { const n = CHART_INTENTS.length; let next: number; switch (e.key) { case 'ArrowRight': case 'ArrowDown': next = (index + 1) % n; break; case 'ArrowLeft': case 'ArrowUp': next = (index - 1 + n) % n; break; case 'Home': next = 0; break; case 'End': next = n - 1; break; default: return; // not ours — let it bubble (Enter/Space activate the native button) } e.preventDefault(); btnRefs.current[next]?.focus(); }; return (
What do you want to show?

{active ? ( <> Starting point: {INTENT_LABELS[active]}. Pick another, or adjust the controls below. ) : ( <>Pick a starting point and we will set the chart up — or build it yourself below. )}

{CHART_INTENTS.map((intent, i) => { const enabled = applicable.has(intent); return ( ); })}
); } /** * The no-datasets state (spec §06 → Opening; Carbon no-data empty state): says * what the builder does and offers the one next step — never a dead end. The * action swaps this modal for the Datasets manager opened on its create form. */ function NoDatasets() { return (

No datasets yet

The Chart Builder turns a saved dataset into a chart — pick columns, watch the chart take shape, and save it as a snippet. Add a dataset to start building.

); } export function ChartBuilderModal() { const datasetId = useChartBuilderStore((s) => s.datasetId); const datasetCount = useDatasetStore((s) => s.datasets.length); const mark = useChartBuilderStore((s) => s.config.mark); const sort = useChartBuilderStore((s) => s.config.sort); const stack = useChartBuilderStore((s) => s.config.stack); const setMark = useChartBuilderStore((s) => s.setMark); const setSort = useChartBuilderStore((s) => s.setSort); const setStack = useChartBuilderStore((s) => s.setStack); const applyWarningFix = useChartBuilderStore((s) => s.applyWarningFix); const runCreate = useChartBuilderStore((s) => s.createSnippet); const runSave = useChartBuilderStore((s) => s.saveEdits); // Edit-in-place (spec §06 → Open in builder): when a snippet was opened in the // builder, the primary action *saves back* to it instead of creating a new snippet. const editingSnippetId = useChartBuilderStore((s) => s.editingSnippetId); const editingSnippetName = useChartBuilderStore((s) => s.editingSnippetName); const editing = editingSnippetId !== null; // Validity + guidance + which chart-level controls apply are derived from the // stable `config` reference via useMemo, NOT a store selector that would build a // fresh array each render (which loops useSyncExternalStore — see SnippetStore note). const config = useChartBuilderStore((s) => s.config); const rowCount = useChartBuilderStore((s) => s.rowCount); // `columns` is a stable reference set once at init (not rebuilt per render), so // subscribing to it won't loop useSyncExternalStore. const columns = useChartBuilderStore((s) => s.columns); const valid = useMemo(() => isBuilderConfigValid(config), [config]); const warnings = useMemo( () => builderWarnings(config, rowCount, columns), [config, rowCount, columns], ); const canSort = useMemo(() => supportsSort(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 . 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(null); const warningsRef = useRef(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 (datasetCount === 0) return ; // Datasets exist but none is loaded (init auto-picks, so this is a fallback // for a builder restored into an odd state): offer the choice directly. return (

Choose a dataset

Pick the dataset to build a chart from.

); } return (
{fixAnnouncement}
{editing && (

Editing {editingSnippetName}

)}
Mark
{(canSort || canStack) && (
{canSort && (
Sort setSort(v === 'none' ? undefined : v)} />
)} {canStack && (
Stacking
)}
)} {warnings.length > 0 && (
    {warnings.map((w) => (
  • {w.message} {w.fixes && w.fixes.length > 0 && (
    {w.fixes.map((fix) => ( ))}
    )}
  • ))}
)} {!valid && (

Map at least one channel to a column to {editing ? 'save changes' : 'create a snippet'}.

)}
); }