/** * 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, FIELD_TYPES, MARK_TYPES, TIME_UNITS, builderWarnings, defaultFieldType, effectiveColumns, filterOpArity, isBuilderConfigValid, isChannelTypeAllowed, 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', }; /** The fixed N | O | Q | T field-type segments (terse, with full-name tooltips). */ const TYPE_ORDER: readonly FieldType[] = ['nominal', 'ordinal', 'quantitative', 'temporal']; 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'; } } /** Whether a column may be placed on a channel at all (Size discipline, §06). */ function columnAllowedOnChannel(channel: ChannelName, colType: ColumnType): boolean { return isChannelTypeAllowed(channel, defaultFieldType(colType)); } /** 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; } function ChannelBlock({ channel }: { channel: ChannelName }) { const baseColumns = useChartBuilderStore((s) => s.columns); const calculates = useChartBuilderStore((s) => s.config.calculates); // Effective columns = the dataset's columns plus any calculated fields, so a derived // field is selectable on a channel like any real column. const columns = useMemo( () => effectiveColumns(baseColumns, calculates), [baseColumns, calculates], ); const mapping = useChartBuilderStore((s) => s.config.encodings[channel] ?? null); 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 colTypeOf = (name: string): ColumnType => columns.columnTypes.find((c) => c.name === name)?.type ?? 'string'; // The fixed N|O|Q|T control: a column's invalid types and types disallowed on this // channel (e.g. a category on Size) are disabled, never hidden, so the control keeps // one shape on every channel (APG radio with disabled options). const typeSegments: ReadonlyArray> = useMemo(() => { const valid = mapping?.field !== undefined ? validFieldTypes(colTypeOf(mapping.field)) : []; return TYPE_ORDER.filter((t) => FIELD_TYPES.includes(t)).map((t) => ({ value: t, label: TYPE_ABBR[t], title: titleCase(t), disabled: !(valid.includes(t) && isChannelTypeAllowed(channel, t)), })); // eslint-disable-next-line react-hooks/exhaustive-deps }, [channel, mapping?.field, columns]); const selectValue = mapping === null ? '' : isCount(mapping) ? COUNT_FIELD : (mapping.field ?? ''); return (
{CHANNEL_LABELS[channel]}
{mapping && !isCount(mapping) && (
setChannelType(channel, t)} className={styles.typeSeg} /> {supportsAggregate(mapping.type) && ( )} {supportsBin(mapping.type) && ( )} {supportsTimeUnit(mapping.type) && ( )}
)}
); } /** * 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); const [error, setError] = useState(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) { // TODO: this drops the next-step the error contract wants (arch 10); LivePreview // gives "Create it from Datasets…". Near-unreachable here (the builder opens from // an existing dataset), so it's terse — restore the next-step if it can be reached. setError(`Dataset "${e.datasetName}" not found.`); setTooLarge(null); } else { // TODO: arch 10 routes a raw diagnostic into a disclosure, not the headline. The // editor surfaces the Vega message inline by design; the builder could fold it // behind a details disclosure and keep the headline plain. setError(`Couldn't render this chart: ${(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 swapXY = useChartBuilderStore((s) => s.swapXY); 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
Encoding
{CHANNELS.map((channel) => ( ))}
{(canSort || canStack) && (
{canSort && (
Sort setSort(v === 'none' ? undefined : v)} />
)} {canStack && (
Stacking
)}
)}
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.

)}
); }