/** * Chart Builder — the modal body (spec §06). * * A two-pane composer: left is the configuration (dataset name, mark selector, one * block per channel, chart-level sort/stacking, optional dimensions, guidance, * Create), right is a live preview of the spec the configuration produces. All spec * logic and Tier-B defaults/guards come from `@core/chart-builder` via * `ChartBuilderStore`; this component is the view. Each channel is a small block: * a column dropdown (with a field-less "Count of records" option), a fixed * `N | O | Q | T` field-type segmented control (the column's invalid types are * disabled), and the transforms that apply to its type (aggregate + bin for a * measure, granularity for a temporal field). 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 { useEffect, useMemo, useRef, useState } from 'react'; import { useShallow } from 'zustand/react/shallow'; import type { VisualizationSpec } from 'vega-embed'; import { CHANNELS, MARK_TYPES, TIME_UNITS, builderWarnings, channelAcceptsValue, defaultChannelValue, defaultFieldType, effectiveColumns, filterOpArity, isBuilderConfigValid, isChannelTypeAllowed, isValueMapping, supportsAggregate, supportsBin, supportsSort, supportsStack, supportsTimeUnit, validFieldTypes, validFilterOps, type BuilderCalculate, type AggregateOp, type BuilderColumns, type BuilderFilter, type BuilderWarningFix, type ChannelMapping, type ChannelName, 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 } 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 { 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); } const MARK_OPTIONS: ReadonlyArray> = MARK_TYPES.map((m) => ({ value: m, label: titleCase(m), })); 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', }; /** Non-count aggregate operators offered for a quantitative field. */ const FIELD_AGGREGATES: readonly AggregateOp[] = ['sum', 'mean', 'median', 'min', 'max']; /** 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/'; /** One preview cell's text: blank for empty, the string as-is, else JSON. */ function cellText(value: unknown): string { if (value == null) return ''; if (typeof value === 'string') return value; return JSON.stringify(value); } /** 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, e.target.value)} /> ) : ( setChannelConstant(channel, e.target.value)} /> )}
); } const count = isCount(mapping); const typeOptions = count ? [] : channelTypeOptions(channel, mapping, columns); const canCycle = typeOptions.length > 1; const currentType: FieldType = count ? 'quantitative' : mapping.type; const label = count ? 'Count' : (mapping.field ?? ''); const cycleType = () => { if (!canCycle) return; const i = typeOptions.indexOf(mapping.type); setChannelType(channel, typeOptions[(i + 1) % typeOptions.length]); }; const hasTransforms = !count && (supportsAggregate(mapping.type) || supportsBin(mapping.type) || supportsTimeUnit(mapping.type)); return (
{/* TODO(ux-second-pass): the type chip cycles N→O→Q→T — no direct pick for keyboard/SR users. Cycle vs. explicit radio is parked for a batched council review (docs/ux-second-pass.md). */} {label}
{hasTransforms && (
{supportsAggregate(mapping.type) && ( )} {supportsBin(mapping.type) && ( )} {supportsTimeUnit(mapping.type) && ( )}
)}
); } /** * 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) && ( )}
); } /** * 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 field assigns it to the armed channel, else the first empty channel * that accepts it (`assignField`). 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 columns = useMemo( () => effectiveColumns(baseColumns, calculates), [baseColumns, calculates], ); 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'; const fieldButton = (name: string) => ( ); const countButton = ( ); 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 {split ? ( <> Dimensions
{dimensions.map(fieldButton)}
Measures
{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}
); } /** A reserved, non-interactive shelf slot for faceting (small multiples) — Phase 4. * Shown so the layout telegraphs where row/column faceting will live. */ function FacetSlot({ kind }: { kind: 'column' | 'row' }) { return (
+ {kind} facet later
); } /** * 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. Each shelf holds the axis slot plus a reserved faceting placeholder. A Swap * X/Y action flips the two axes. */ 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 })} /> ) : ( )}
{!expressionMode && (
{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 })} />
); } /** * 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 ? (
{baseColumns.columns.map((col) => ( ))} {rows.map((row, ri) => ( {baseColumns.columns.map((col) => ( ))} ))}
{col}{' '} {typeBadge(typeOf(col))}
{cellText(row[col])}
) : (

This dataset has no tabular rows to preview.

))} {open && rows && dataset.rowCount != null && dataset.rowCount > rows.length && (

Showing the first {rows.length} of {dataset.rowCount.toLocaleString()} rows.

)}
); } /** * 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); const specText = useChartBuilderStore(selectBuilderSpecText); const valid = useChartBuilderStore(selectBuilderValid); 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); return; } if (!node) return; try { const t0 = performance.now(); const parsed: unknown = JSON.parse(specText); const t1 = performance.now(); const prepared = prepareSpecForRender(parsed, { fitMode: '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); 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; 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, uiTheme, datasets]); useEffect( () => () => { handleRef.current?.destroy(); handleRef.current = 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.

)} ); } export function ChartBuilderModal() { const datasetId = useChartBuilderStore((s) => s.datasetId); const datasetName = useChartBuilderStore((s) => s.config.datasetName); const mark = useChartBuilderStore((s) => s.config.mark); const width = useChartBuilderStore((s) => s.config.width); const height = useChartBuilderStore((s) => s.config.height); 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 setWidth = useChartBuilderStore((s) => s.setWidth); const setHeight = useChartBuilderStore((s) => s.setHeight); const applyWarningFix = useChartBuilderStore((s) => s.applyWarningFix); const runCreate = useChartBuilderStore((s) => s.createSnippet); // 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) { return

No dataset loaded. Open this from a dataset in Datasets.

; } const parseDim = (raw: string): number | undefined => { if (raw.trim() === '') return undefined; const n = Number(raw); return Number.isFinite(n) && n > 0 ? Math.round(n) : undefined; }; return (
{fixAnnouncement}

Building from {datasetName}

Mark
{(canSort || canStack) && (
{canSort && (
Sort setSort(v === 'none' ? undefined : v)} />
)} {canStack && (
Stacking
)}
)}
{/* "Chart size", not just "Size" — the Marks card now has a Size *encoding* channel; this is the rendered chart's width/height. */} Chart size (optional)
{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 create a snippet.

)}
); }