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