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

1321 lines
48 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 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<SegmentedOption<MarkType>> = MARK_TYPES.map((m) => ({
value: m,
label: titleCase(m),
}));
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',
};
/** 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<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/';
/** 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 (
<div className={`${styles.pill} ${styles.pillConst}`}>
<span className={styles.pillTag}>value</span>
{isColor ? (
<input
type="color"
className={styles.constColor}
aria-label={`${CHANNEL_LABELS[channel]} constant colour`}
value={typeof mapping.value === 'string' ? mapping.value : '#000000'}
onChange={(e) => setChannelConstant(channel, e.target.value)}
/>
) : (
<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 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 (
<div className={styles.pillWrap}>
<div className={styles.pill}>
{/* 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). */}
<button
type="button"
className={styles.pillType}
aria-label={`Field type: ${titleCase(currentType)}${canCycle ? ' — activate to change' : ''}`}
disabled={!canCycle}
onClick={cycleType}
>
{TYPE_ABBR[currentType]}
</button>
<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) && (
<label className={styles.transform}>
<span className={styles.miniLabel}>Aggregate</span>
<select
className={styles.mini}
value={mapping.aggregate && mapping.aggregate !== 'count' ? mapping.aggregate : ''}
onChange={(e) =>
setChannelAggregate(
channel,
(e.target.value || undefined) as AggregateOp | undefined,
)
}
>
<option value="">None</option>
{FIELD_AGGREGATES.map((op) => (
<option key={op} value={op}>
{titleCase(op)}
</option>
))}
</select>
</label>
)}
{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) && (
<label className={styles.transform}>
<span className={styles.miniLabel}>Granularity</span>
<select
className={styles.mini}
value={mapping.timeUnit ?? ''}
onChange={(e) =>
setChannelTimeUnit(channel, (e.target.value || undefined) as TimeUnit | undefined)
}
>
<option value="">None (raw)</option>
{TIME_UNITS.map((u) => (
<option key={u} value={u}>
{TIME_UNIT_LABELS[u]}
</option>
))}
</select>
</label>
)}
</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) && (
<button
type="button"
className={styles.slotConst}
onClick={() => setChannelConstant(channel, String(defaultChannelValue(channel)))}
>
or constant
</button>
)}
</div>
);
}
/**
* 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<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';
const fieldButton = (name: string) => (
<button
key={name}
type="button"
className={`${styles.shelfField} ${assigned.has(name) ? styles.shelfFieldUsed : ''}`}
onClick={() => assignField(name)}
>
<span className={styles.shelfGlyph} aria-hidden="true">
{TYPE_ABBR[defaultFieldType(colTypeOf(name))]}
</span>
<span className={styles.shelfFieldName}>{name}</span>
</button>
);
const countButton = (
<button
key="__count"
type="button"
className={styles.shelfField}
onClick={() => assignField(COUNT_FIELD)}
>
<span className={styles.shelfGlyph} aria-hidden="true">
</span>
<span className={styles.shelfFieldName}>Count of records</span>
</button>
);
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>
{split ? (
<>
<span className={styles.shelfGroupHead}>Dimensions</span>
<div className={styles.shelfList}>{dimensions.map(fieldButton)}</div>
<span className={styles.shelfGroupHead}>Measures</span>
<div className={styles.shelfList}>
{measures.map(fieldButton)}
{countButton}
</div>
</>
) : (
<div className={styles.shelfList}>
{columns.columns.map(fieldButton)}
{countButton}
</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>
);
}
/** 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 (
<div className={styles.facetSlot} title="Faceting → small multiples (coming later)">
<span>+ {kind} facet</span>
<span className={styles.facetTag}>later</span>
</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. 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 (
<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" />
<FacetSlot kind="column" />
</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" />
<FacetSlot kind="row" />
</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 })}
/>
) : (
<select
className={styles.filterField}
aria-label="Filter field"
value={filter.field ?? ''}
onChange={(e) => setFilterField(filter.id, e.target.value)}
>
{!filter.field && <option value="">Choose a field</option>}
{columns.columns.map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
)}
<button
type="button"
className={styles.removeRow}
aria-label="Remove filter"
onClick={() => removeFilter(filter.id)}
>
<Icon name="close" />
</button>
</div>
{!expressionMode && (
<div className={styles.filterPredicate}>
<select
className={styles.mini}
aria-label="Filter operator"
value={op}
onChange={(e) => updateFilter(filter.id, { op: e.target.value as FilterOp })}
>
{validFilterOps(fieldType).map((o) => (
<option key={o} value={o}>
{FILTER_OP_LABELS[o]}
</option>
))}
</select>
{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 })}
/>
<button
type="button"
className={styles.removeRow}
aria-label="Remove calculated field"
onClick={() => removeCalculate(calc.id)}
>
<Icon name="close" />
</button>
</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 ? (
<div
className={styles.previewTableWrap}
tabIndex={0}
role="group"
aria-label="Data preview"
>
<table className={styles.previewTable}>
<thead>
<tr>
{baseColumns.columns.map((col) => (
<th key={col} scope="col">
<span className={styles.previewColName}>{col}</span>{' '}
<span className={styles.previewColType}>{typeBadge(typeOf(col))}</span>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, ri) => (
<tr key={ri}>
{baseColumns.columns.map((col) => (
<td key={col}>{cellText(row[col])}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className={styles.previewEmptyNote}>This dataset has no tabular rows to preview.</p>
))}
{open && rows && dataset.rowCount != null && dataset.rowCount > rows.length && (
<p className={styles.previewEmptyNote}>
Showing the first {rows.length} of {dataset.rowCount.toLocaleString()} rows.
</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);
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 (
<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>
)}
</div>
);
}
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 <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) {
return <p className={styles.muted}>No dataset loaded. Open this from a dataset in Datasets.</p>;
}
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 (
<div className={styles.builder}>
<div className={styles.configPane} ref={configPaneRef} tabIndex={-1}>
<div className="visually-hidden" role="status" aria-live="polite">
{fixAnnouncement}
</div>
<p className={styles.datasetName}>
Building from <strong>{datasetName}</strong>
</p>
<DataSection />
<div className={styles.field}>
<span className={styles.fieldLabel}>Mark</span>
<SegmentedControl
label="Mark type"
options={MARK_OPTIONS}
value={mark}
onChange={setMark}
/>
</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>
)}
<div className={styles.dimensions}>
{/* "Chart size", not just "Size" — the Marks card now has a Size *encoding*
channel; this is the rendered chart's width/height. */}
<span className={styles.fieldLabel}>Chart size (optional)</span>
<div className={styles.dimInputs}>
<label className={styles.dimField}>
<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.dimField}>
<span>Height</span>
<input
type="number"
min={1}
className={styles.dimInput}
value={height ?? ''}
placeholder="auto"
onChange={(e) => setHeight(parseDim(e.target.value))}
/>
</label>
</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 create a snippet.
</p>
)}
<div className={styles.actions}>
<button type="button" className={styles.action} onClick={() => void closeModal()}>
Cancel
</button>
<button
type="button"
className={`${styles.action} ${styles.primary}`}
disabled={!valid}
aria-describedby={!valid ? 'cb-create-hint' : undefined}
onClick={() => runCreate()}
>
Create Snippet
</button>
</div>
</div>
<div className={styles.previewSide}>
<OnChartShelves />
<BuilderPreview />
</div>
</div>
);
}