Files
astrolabe/src/app/components/ChartBuilderModal.tsx
T

1654 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<MarkType, string> = {
bar: 'Bar',
line: 'Line',
point: 'Point',
area: 'Area',
circle: 'Circle',
rect: 'Heatmap',
};
const MARK_OPTIONS: ReadonlyArray<SegmentedOption<MarkType>> = 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<ChartIntent, string> = {
compare: 'Compare',
ranking: 'Ranking',
time: 'Change over time',
correlation: 'Correlation',
distribution: 'Distribution',
partToWhole: 'Part-to-whole',
heatmap: 'Heatmap',
};
const INTENT_NEEDS: Record<ChartIntent, string> = {
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<ChannelName, string> = {
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<FieldType, string> = {
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<Exclude<AggregateOp, 'count'>, 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<TimeUnit, string> = {
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 "<field> <op> <value>". */
const FILTER_OP_LABELS: Record<FilterOp, string> = {
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 (
<div className={`${styles.pill} ${styles.pillConst}`}>
<span className={styles.pillTag}>value</span>
{isColor ? (
<ColorField
size="sm"
className={styles.constColor}
label={`${CHANNEL_LABELS[channel]} constant colour`}
value={typeof mapping.value === 'string' ? mapping.value : '#000000'}
onChange={(v) => setChannelConstant(channel, v)}
/>
) : (
<input
type="number"
className={styles.constNumber}
aria-label={`${CHANNEL_LABELS[channel]} constant size`}
value={String(mapping.value ?? '')}
onChange={(e) => setChannelConstant(channel, e.target.value)}
/>
)}
<button type="button" className={styles.pillRemove} aria-label={clearLabel} onClick={clear}>
<Icon name="close" />
</button>
</div>
);
}
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 (
<div className={styles.pillWrap}>
<div className={styles.pill}>
{/* 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). */}
<SelectControl
id={`cb-type-${channel}`}
label={`Field type for ${CHANNEL_LABELS[channel]}`}
heading="Field type"
options={typeOptions.map((t) => ({ value: t, label: titleCase(t) }))}
value={currentType}
onSelect={(t) => setChannelType(channel, t)}
triggerClassName={styles.pillType}
triggerContent={TYPE_ABBR[currentType]}
triggerTitle={titleCase(currentType)}
disabled={!canPick}
/>
<span className={styles.pillName} title={label}>
{label}
</span>
<button type="button" className={styles.pillRemove} aria-label={clearLabel} onClick={clear}>
<Icon name="close" />
</button>
</div>
{hasTransforms && (
<div className={styles.pillControls}>
{supportsAggregate(mapping.type) && (
<div className={styles.transform}>
<span className={styles.miniLabel} aria-hidden="true">
Aggregate
</span>
<SelectControl
id={`cb-agg-${channel}`}
label={`Aggregate for ${CHANNEL_LABELS[channel]}`}
heading="Aggregate"
options={[
{ value: '', label: 'None' },
...validAggregateOps(mapping.type).map((op) => ({
value: op,
label: AGGREGATE_LABELS[op],
})),
]}
value={mapping.aggregate && mapping.aggregate !== 'count' ? mapping.aggregate : ''}
onSelect={(v) => setChannelAggregate(channel, v || undefined)}
/>
</div>
)}
{supportsBin(mapping.type) && (
<label className={styles.toggle}>
<input
type="checkbox"
checked={!!mapping.bin}
onChange={(e) => setChannelBin(channel, e.target.checked)}
/>
Bin
</label>
)}
{supportsTimeUnit(mapping.type) && (
<div className={styles.transform}>
<span className={styles.miniLabel} aria-hidden="true">
Granularity
</span>
<SelectControl
id={`cb-tu-${channel}`}
label={`Granularity for ${CHANNEL_LABELS[channel]}`}
heading="Granularity"
options={[
{ value: '', label: 'None (raw)' },
...TIME_UNITS.map((u) => ({ value: u, label: TIME_UNIT_LABELS[u] })),
]}
value={mapping.timeUnit ?? ''}
onSelect={(v) => setChannelTimeUnit(channel, v || undefined)}
/>
</div>
)}
</div>
)}
</div>
);
}
/**
* 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 <ChannelPill channel={channel} mapping={mapping} columns={columns} />;
}
return (
<div className={`${styles.slot} ${active ? styles.slotActive : ''}`}>
<button
type="button"
className={styles.slotAssign}
aria-pressed={active}
aria-label={`${CHANNEL_LABELS[channel]}: ${
active ? 'pick a field from the list' : 'arm to assign a field'
}`}
onClick={() => focusChannel(active ? null : channel)}
>
{active ? 'Pick a field…' : (hint ?? 'Add a field')}
</button>
{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.
<button
type="button"
className={styles.slotConst}
onClick={() => setChannelConstant(channel, String(defaultChannelValue(channel)))}
>
Use a constant
</button>
)}
</div>
);
}
/** 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<ChannelName, string> = {
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<string>();
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 (
<SelectControl
key={domId}
id={`cb-assign-${domId}`}
label={`Add ${display} to a channel`}
heading="Add to"
options={choices.map((ch) => ({
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={
<>
<span className={styles.shelfGlyph} aria-hidden="true">
{glyph}
</span>
<span className={styles.shelfFieldName}>{display}</span>
</>
}
/>
);
};
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 (
<div className={styles.fieldShelf}>
<span className={styles.fieldLabel}>Fields</span>
{/* 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 && (
<p className={styles.armedHint} role="status">
Assigning to <strong>{CHANNEL_LABELS[activeChannel]}</strong> choose a field below. Esc
cancels.
</p>
)}
<div className={`${styles.shelfScroll} ${activeChannel ? styles.shelfArmed : ''}`}>
{split ? (
<>
{/* The visible count says "there's more below the fold" — the grouped
shelf only appears for wide datasets, where the list scrolls. */}
<span className={styles.shelfGroupHead}>Dimensions · {dimensions.length}</span>
<div className={styles.shelfList}>{dimensions.map(fieldButton)}</div>
<span className={styles.shelfGroupHead}>Measures · {measures.length + 1}</span>
<div className={styles.shelfList}>
{measures.map(fieldButton)}
{countButton}
</div>
</>
) : (
<div className={styles.shelfList}>
{columns.columns.map(fieldButton)}
{countButton}
</div>
)}
</div>
</div>
);
}
/** 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 (
<div className={styles.marksCard}>
<span className={styles.fieldLabel}>Marks</span>
<div className={styles.marksRow}>
<span className={styles.channelLabel}>{CHANNEL_LABELS.color}</span>
<ChannelSlot channel="color" />
</div>
<div className={styles.marksRow}>
<span className={styles.channelLabel}>{CHANNEL_LABELS.size}</span>
<ChannelSlot channel="size" />
</div>
</div>
);
}
/**
* 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 (
<div className={styles.shelves}>
<div className={styles.shelvesHead}>
<span className={styles.fieldLabel}>Axes</span>
<button type="button" className={styles.swap} onClick={swapXY}>
Swap X/Y
</button>
</div>
<div className={styles.shelfStrip}>
<span className={styles.shelfName}>Columns</span>
<div className={styles.shelfSlots}>
<ChannelSlot channel="x" hint="Add a field for the X axis" />
</div>
</div>
<div className={styles.shelfStrip}>
<span className={styles.shelfName}>Rows</span>
<div className={styles.shelfSlots}>
<ChannelSlot channel="y" hint="Add a field for the Y axis" />
</div>
</div>
</div>
);
}
/**
* Inline feedback for an expression input (filter expression / calculated field):
* a parse error takes priority, else a soft warning for `datum.<field>` 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 (
<p id={messageId} className={isError ? styles.exprError : styles.exprWarn} role="status">
<Icon
name={isError ? 'status-error' : 'status-warning'}
className={styles.exprFeedbackIcon}
/>
<span>{feedback.text}</span>
</p>
);
}
/**
* 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 (
<p className={styles.exprHelp}>
Filter expressions and calculated fields use the{' '}
<a
className={styles.exprHelpLink}
href={VEGA_EXPRESSION_DOCS_URL}
target="_blank"
rel="noreferrer"
>
Vega expression language
<span aria-hidden="true"> </span>
<span className="visually-hidden"> (opens in a new tab)</span>
</a>
.
</p>
);
}
/** 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 (
<div className={styles.transformBlock}>
<div className={styles.transformBlockTop}>
{expressionMode ? (
<input
className={styles.exprInput}
aria-label="Filter expression"
placeholder={exprPlaceholder(columns, 'filter')}
value={filter.expr ?? ''}
aria-invalid={!validateExpression(filter.expr ?? '').valid || undefined}
aria-describedby={exprMsgId}
onChange={(e) => updateFilter(filter.id, { expr: e.target.value })}
/>
) : (
<SelectControl
id={`cb-ffield-${filter.id}`}
label="Filter field"
heading="Filter field"
options={columns.columns.map((name) => ({ value: name, label: name }))}
value={filter.field}
onSelect={(name) => setFilterField(filter.id, name)}
triggerContent={filter.field ? undefined : 'Choose a field…'}
/>
)}
<IconButton size="sm" label="Remove filter" onClick={() => removeFilter(filter.id)}>
<Icon name="close" />
</IconButton>
</div>
{!expressionMode && (
<div className={styles.filterPredicate}>
<SelectControl
id={`cb-fop-${filter.id}`}
label="Filter operator"
heading="Operator"
options={validFilterOps(fieldType).map((o) => ({
value: o,
label: FILTER_OP_LABELS[o],
}))}
value={op}
onSelect={(o) => updateFilter(filter.id, { op: o })}
/>
{arity === 'range' ? (
<>
<input
className={styles.valueInput}
aria-label="Lower bound"
placeholder="min"
value={filter.value ?? ''}
onChange={(e) => updateFilter(filter.id, { value: e.target.value })}
/>
<span className={styles.rangeDash} aria-hidden="true">
</span>
<input
className={styles.valueInput}
aria-label="Upper bound"
placeholder="max"
value={filter.value2 ?? ''}
onChange={(e) => updateFilter(filter.id, { value2: e.target.value })}
/>
</>
) : (
<input
className={styles.valueInput}
aria-label={arity === 'list' ? 'Values, comma-separated' : 'Filter value'}
placeholder={arity === 'list' ? 'A, B, C' : 'value'}
value={filter.value ?? ''}
onChange={(e) => updateFilter(filter.id, { value: e.target.value })}
/>
)}
</div>
)}
{expressionMode && (
<ExprFeedback expr={filter.expr ?? ''} columns={columns} messageId={exprMsgId} />
)}
{hasColumns && (
<button
type="button"
className={styles.modeToggle}
onClick={() => setFilterMode(filter.id, expressionMode ? 'predicate' : 'expression')}
>
{expressionMode ? 'Use the field picker' : 'Write an expression'}
</button>
)}
</div>
);
}
/** 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 (
<div className={styles.transformBlock}>
<div className={styles.transformBlockTop}>
<input
className={styles.calcName}
aria-label="New field name"
placeholder="new field"
value={calc.as}
onChange={(e) => updateCalculate(calc.id, { as: e.target.value })}
/>
<span className={styles.calcEquals} aria-hidden="true">
=
</span>
<input
className={styles.exprInput}
aria-label="Calculated field expression"
placeholder={exprPlaceholder(columns, 'calc')}
value={calc.expr}
aria-invalid={!validateExpression(calc.expr).valid || undefined}
aria-describedby={exprMsgId}
onChange={(e) => updateCalculate(calc.id, { expr: e.target.value })}
/>
<IconButton
size="sm"
label="Remove calculated field"
onClick={() => removeCalculate(calc.id)}
>
<Icon name="close" />
</IconButton>
</div>
<ExprFeedback expr={calc.expr} columns={columns} messageId={exprMsgId} />
</div>
);
}
/**
* 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 (
<div className={styles.dataPreview}>
<button
type="button"
className={styles.previewToggle}
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
<span className={styles.previewCaret} aria-hidden="true">
{open ? '▾' : '▸'}
</span>
Preview rows
{dataset.rowCount != null && (
<span className={styles.previewMeta}>
{dataset.rowCount.toLocaleString()} rows · {dataset.columnCount} cols
</span>
)}
</button>
{open &&
(rows ? (
<DataTable
columns={baseColumns.columns}
rows={rows}
total={dataset.rowCount ?? undefined}
ariaLabel="Data preview"
renderHeader={(col) => (
<>
<span className={styles.previewColName}>{col}</span>{' '}
<span className={styles.previewColType}>{typeBadge(typeOf(col))}</span>
</>
)}
/>
) : (
<p className={styles.previewEmptyNote}>This dataset has no tabular rows to preview.</p>
))}
</div>
);
}
/**
* 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 (
<section className={styles.dataSection} aria-label="Data">
<span className={styles.fieldLabel}>Data</span>
{/* 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. */}
<DataPreview />
<div className={styles.transformGroup}>
<div className={styles.transformGroupHead}>
<span className={styles.miniLabel}>Filters</span>
<button type="button" className={styles.addRow} onClick={addFilter}>
<Icon name="add" /> Add filter
</button>
</div>
{(filters ?? []).map((f) => (
<FilterRow key={f.id} filter={f} columns={columns} />
))}
</div>
<div className={styles.transformGroup}>
<div className={styles.transformGroupHead}>
<span className={styles.miniLabel}>Calculated fields</span>
<button type="button" className={styles.addRow} onClick={addCalculate}>
<Icon name="add" /> Add field
</button>
</div>
{(calculates ?? []).map((c) => (
<CalculateRow key={c.id} calc={c} columns={columns} />
))}
</div>
{hasExpression && <ExprHelp />}
</section>
);
}
/** Sort control values: 'none' maps to an unsorted config. */
const SORT_OPTIONS: ReadonlyArray<SegmentedOption<'none' | 'ascending' | 'descending'>> = [
{ value: 'none', label: 'None' },
{ value: 'ascending', label: 'Asc' },
{ value: 'descending', label: 'Desc' },
];
const STACK_OPTIONS: ReadonlyArray<SegmentedOption<'zero' | 'normalize'>> = [
{ value: 'zero', label: 'Stacked' },
{ value: 'normalize', label: '100%' },
];
function BuilderPreview() {
const hostRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<RenderHandle | null>(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 (
<div className={styles.previewPane}>
{!valid && (
<p className={styles.previewHint}>Map at least one channel to a column to see a chart.</p>
)}
{valid && tooLarge && (
<p className={styles.previewHint} role="status">
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.
</p>
)}
<div className={styles.previewFrame} hidden={!valid || tooLarge !== null || error !== null}>
<div className={styles.previewHost} ref={hostRef} />
</div>
{valid && tooLarge === null && error !== null && (
<div className={styles.previewError} role="alert">
<p className={styles.previewErrorHeadline}>{error.message}</p>
{error.detail !== undefined && (
<details className={styles.previewErrorDetails}>
<summary className={styles.previewErrorSummary}>Technical details</summary>
<pre className={styles.previewErrorDetail}>{error.detail}</pre>
</details>
)}
</div>
)}
{/* Data inspector — input vs. resolved rows the chart drew (the latter after
the builder's transforms). Only with a live chart, so it never duplicates
the "map a channel" / error hints above. */}
{valid && tooLarge === null && error === null && (
<DataInspectorPanel
open={dataOpen}
onToggle={setDataOpen}
getData={getInspectData}
renderEpoch={renderEpoch}
/>
)}
</div>
);
}
/** 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 (
<div className={styles.chartProps}>
<label className={styles.propField}>
<span>Title</span>
<input
className={styles.propInput}
value={title}
placeholder="None"
onChange={(e) => setTitle(e.target.value)}
/>
</label>
<label className={styles.propField}>
<span>Subtitle</span>
<input
className={styles.propInput}
value={subtitle}
placeholder={hasTitle ? 'None' : 'Add a title first'}
disabled={!hasTitle}
onChange={(e) => setSubtitle(e.target.value)}
/>
</label>
<label className={styles.propField}>
<span>Width</span>
<input
type="number"
min={1}
className={styles.dimInput}
value={width ?? ''}
placeholder="auto"
onChange={(e) => setWidth(parseDim(e.target.value))}
/>
</label>
<label className={styles.propField}>
<span>Height</span>
<input
type="number"
min={1}
className={styles.dimInput}
value={height ?? ''}
placeholder="auto"
onChange={(e) => setHeight(parseDim(e.target.value))}
/>
</label>
</div>
);
}
/**
* 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 (
<div className={styles.datasetRow}>
<span className={styles.fieldLabel}>Dataset</span>
<SelectControl
id="cb-dataset"
label="Dataset to build from"
heading="Dataset"
options={datasets.map((d) => ({ 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}
/>
</div>
);
}
/**
* 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<Array<HTMLButtonElement | null>>([]);
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 (
<div className={styles.intentStrip}>
<span className={styles.intentQ} id="cb-intent-q">
What do you want to show?
</span>
<p className={styles.intentSub}>
{active ? (
<>
Starting point: <strong>{INTENT_LABELS[active]}</strong>. Pick another, or adjust the
controls below.
</>
) : (
<>Pick a starting point and we will set the chart up or build it yourself below.</>
)}
</p>
<div className={styles.intentChips} role="toolbar" aria-labelledby="cb-intent-q">
{CHART_INTENTS.map((intent, i) => {
const enabled = applicable.has(intent);
return (
<button
key={intent}
ref={(el) => {
btnRefs.current[i] = el;
}}
type="button"
className={styles.intentChip}
aria-pressed={active === intent}
aria-disabled={!enabled || undefined}
aria-label={
enabled ? undefined : `${INTENT_LABELS[intent]} — needs ${INTENT_NEEDS[intent]}`
}
title={enabled ? undefined : `Needs ${INTENT_NEEDS[intent]}`}
tabIndex={i === tabStop ? 0 : -1}
onKeyDown={(e) => onKeyDown(e, i)}
onClick={() => {
if (enabled) setIntent(intent);
}}
>
{INTENT_LABELS[intent]}
</button>
);
})}
</div>
</div>
);
}
/**
* 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 (
<div className={styles.emptyState}>
<h3 className={styles.emptyTitle}>No datasets yet</h3>
<p className={styles.emptyBody}>
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.
</p>
<Button
variant="primary"
size="lg"
onClick={() => {
openModal('datasets');
useDatasetStore.getState().startCreate();
}}
>
Add a dataset
</Button>
</div>
);
}
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 <body>. 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<HTMLDivElement>(null);
const warningsRef = useRef<HTMLUListElement>(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 <NoDatasets />;
// 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 (
<div className={styles.emptyState}>
<h3 className={styles.emptyTitle}>Choose a dataset</h3>
<p className={styles.emptyBody}>Pick the dataset to build a chart from.</p>
<DatasetPicker datasetId={null} />
</div>
);
}
return (
<div className={styles.builder}>
<div className={styles.configPane} ref={configPaneRef} tabIndex={-1}>
<div className="visually-hidden" role="status" aria-live="polite">
{fixAnnouncement}
</div>
{editing && (
<p className={styles.editingBanner}>
Editing <strong>{editingSnippetName}</strong>
</p>
)}
<DatasetPicker datasetId={datasetId} />
<IntentStrip />
<DataSection />
<div className={styles.field}>
<span className={styles.fieldLabel}>Mark</span>
<SegmentedControl
label="Mark type"
options={MARK_OPTIONS}
value={mark}
onChange={setMark}
// Six marks don't fit one 320360px row: wrap to two, each segment kept at
// the control height (the base look is fixed-height, single-row).
className={styles.markPicker}
optionClassName={styles.markPickerOption}
/>
</div>
<FieldShelf />
<MarksCard />
{(canSort || canStack) && (
<div className={styles.chartControls}>
{canSort && (
<div className={styles.field}>
<span className={styles.fieldLabel}>Sort</span>
<SegmentedControl
label="Sort the category axis by its measure"
options={SORT_OPTIONS}
value={sort ?? 'none'}
onChange={(v) => setSort(v === 'none' ? undefined : v)}
/>
</div>
)}
{canStack && (
<div className={styles.field}>
<span className={styles.fieldLabel}>Stacking</span>
<SegmentedControl
label="Stacking mode"
options={STACK_OPTIONS}
value={stack ?? 'zero'}
onChange={setStack}
/>
</div>
)}
</div>
)}
{warnings.length > 0 && (
<ul
className={styles.warnings}
ref={warningsRef}
tabIndex={-1}
aria-label="Chart guidance"
>
{warnings.map((w) => (
<li key={w.message} className={styles.warning}>
<Icon name="status-warning" className={styles.warningIcon} />
<div className={styles.warningBody}>
<span>{w.message}</span>
{w.fixes && w.fixes.length > 0 && (
<div className={styles.warningFixes}>
{w.fixes.map((fix) => (
<button
key={fix.label}
type="button"
className={styles.warningFix}
onClick={() => handleFix(fix)}
>
{fix.label}
</button>
))}
</div>
)}
</div>
</li>
))}
</ul>
)}
{!valid && (
<p id="cb-create-hint" className={styles.createHint}>
Map at least one channel to a column to {editing ? 'save changes' : 'create a snippet'}.
</p>
)}
<div className={styles.actions}>
<Button size="lg" onClick={() => void closeModal()}>
Cancel
</Button>
<Button
variant="primary"
size="lg"
disabled={!valid}
aria-describedby={!valid ? 'cb-create-hint' : undefined}
// The commit is the user's confirmation — close with no discard prompt.
onClick={() => {
if (editing ? runSave() : runCreate()) void closeModal(true);
}}
>
{editing ? 'Save changes' : 'Create Snippet'}
</Button>
</div>
</div>
<div className={styles.previewSide}>
<OnChartShelves />
<BuilderPreview />
<ChartProps />
</div>
</div>
);
}