mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
1654 lines
62 KiB
TypeScript
1654 lines
62 KiB
TypeScript
/**
|
||
* 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 320–360px 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>
|
||
);
|
||
}
|