mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
1077 lines
40 KiB
TypeScript
1077 lines
40 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,
|
||
FIELD_TYPES,
|
||
MARK_TYPES,
|
||
TIME_UNITS,
|
||
builderWarnings,
|
||
defaultFieldType,
|
||
effectiveColumns,
|
||
filterOpArity,
|
||
isBuilderConfigValid,
|
||
isChannelTypeAllowed,
|
||
supportsAggregate,
|
||
supportsBin,
|
||
supportsSort,
|
||
supportsStack,
|
||
supportsTimeUnit,
|
||
validFieldTypes,
|
||
validFilterOps,
|
||
type BuilderCalculate,
|
||
type AggregateOp,
|
||
type BuilderColumns,
|
||
type BuilderFilter,
|
||
type BuilderWarningFix,
|
||
type ChannelMapping,
|
||
type ChannelName,
|
||
type FieldType,
|
||
type FilterOp,
|
||
type MarkType,
|
||
type TimeUnit,
|
||
} from '@core/chart-builder';
|
||
import { referencedFields, validateExpression } from '@core/expr-validate';
|
||
import { tabularRows } from '@core/dataset';
|
||
import type { ColumnType } from '@core/type-inference';
|
||
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
|
||
import { chartConfigFor } from '@core/vega-themes';
|
||
import { ChartTooLargeError, renderSpec, type RenderHandle } from '../services/chart-renderer';
|
||
import { closeModal } from '../modals/ModalCoordinator';
|
||
import { useAppStore } from '../stores/AppStore';
|
||
import { useDatasetStore } from '../stores/DatasetStore';
|
||
import {
|
||
COUNT_FIELD,
|
||
selectBuilderSpecText,
|
||
selectBuilderValid,
|
||
useChartBuilderStore,
|
||
} from '../stores/ChartBuilderStore';
|
||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||
import { Icon } from './Icon';
|
||
import styles from './ChartBuilderModal.module.css';
|
||
|
||
const RENDER_DEBOUNCE_MS = 300;
|
||
|
||
/**
|
||
* Render-timing diagnostics for the builder preview. A many-mark chart (e.g. the
|
||
* default one-bar-per-row on a 10k-row dataset) is cheap to compile but expensive
|
||
* for the browser to lay out as **SVG**, and that cost lands *after* `embed()`
|
||
* resolves, in the next paint — the chart appears, then the tab freezes for a moment.
|
||
* Each phase is timed, including that post-embed paint (a double rAF lands just after
|
||
* it), so the numbers attribute the cost to layout rather than chart compilation.
|
||
* Logged in dev always; in prod only when a render is slow.
|
||
*/
|
||
const SLOW_RENDER_MS = 250;
|
||
function logBuilderRenderTiming(t: {
|
||
parse: number;
|
||
prepare: number;
|
||
destroy: number;
|
||
embed: number;
|
||
paint: number;
|
||
total: number;
|
||
}): void {
|
||
const total = Math.round(t.total);
|
||
if (!import.meta.env.DEV && total < SLOW_RENDER_MS) return;
|
||
const ms = (n: number) => Math.round(n);
|
||
const { rowCount, config } = useChartBuilderStore.getState();
|
||
console.info(
|
||
`[chart-builder] render ${total}ms — parse ${ms(t.parse)} · prepare ${ms(t.prepare)} · ` +
|
||
`destroy ${ms(t.destroy)} · embed ${ms(t.embed)} · paint ${ms(t.paint)} ` +
|
||
`(mark=${config.mark}, rows=${rowCount ?? 'n/a'})`,
|
||
);
|
||
}
|
||
|
||
/** Title-case a token for display (e.g. `bar` → `Bar`, `sum` → `Sum`). */
|
||
function titleCase(s: string): string {
|
||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||
}
|
||
|
||
const MARK_OPTIONS: ReadonlyArray<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',
|
||
};
|
||
|
||
/** The fixed N | O | Q | T field-type segments (terse, with full-name tooltips). */
|
||
const TYPE_ORDER: readonly FieldType[] = ['nominal', 'ordinal', 'quantitative', 'temporal'];
|
||
const TYPE_ABBR: Record<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';
|
||
}
|
||
}
|
||
|
||
/** Whether a column may be placed on a channel at all (Size discipline, §06). */
|
||
function columnAllowedOnChannel(channel: ChannelName, colType: ColumnType): boolean {
|
||
return isChannelTypeAllowed(channel, defaultFieldType(colType));
|
||
}
|
||
|
||
/** True when the mapping is the field-less Count-of-records measure. */
|
||
function isCount(mapping: ChannelMapping | null): boolean {
|
||
return !!mapping && mapping.aggregate === 'count' && mapping.field === undefined;
|
||
}
|
||
|
||
function ChannelBlock({ channel }: { channel: ChannelName }) {
|
||
const baseColumns = useChartBuilderStore((s) => s.columns);
|
||
const calculates = useChartBuilderStore((s) => s.config.calculates);
|
||
// Effective columns = the dataset's columns plus any calculated fields, so a derived
|
||
// field is selectable on a channel like any real column.
|
||
const columns = useMemo(
|
||
() => effectiveColumns(baseColumns, calculates),
|
||
[baseColumns, calculates],
|
||
);
|
||
const mapping = useChartBuilderStore((s) => s.config.encodings[channel] ?? null);
|
||
const setChannelColumn = useChartBuilderStore((s) => s.setChannelColumn);
|
||
const setChannelType = useChartBuilderStore((s) => s.setChannelType);
|
||
const setChannelAggregate = useChartBuilderStore((s) => s.setChannelAggregate);
|
||
const setChannelBin = useChartBuilderStore((s) => s.setChannelBin);
|
||
const setChannelTimeUnit = useChartBuilderStore((s) => s.setChannelTimeUnit);
|
||
|
||
const colTypeOf = (name: string): ColumnType =>
|
||
columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
|
||
|
||
// The fixed N|O|Q|T control: a column's invalid types and types disallowed on this
|
||
// channel (e.g. a category on Size) are disabled, never hidden, so the control keeps
|
||
// one shape on every channel (APG radio with disabled options).
|
||
const typeSegments: ReadonlyArray<SegmentedOption<FieldType>> = useMemo(() => {
|
||
const valid = mapping?.field !== undefined ? validFieldTypes(colTypeOf(mapping.field)) : [];
|
||
return TYPE_ORDER.filter((t) => FIELD_TYPES.includes(t)).map((t) => ({
|
||
value: t,
|
||
label: TYPE_ABBR[t],
|
||
title: titleCase(t),
|
||
disabled: !(valid.includes(t) && isChannelTypeAllowed(channel, t)),
|
||
}));
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [channel, mapping?.field, columns]);
|
||
|
||
const selectValue =
|
||
mapping === null ? '' : isCount(mapping) ? COUNT_FIELD : (mapping.field ?? '');
|
||
|
||
return (
|
||
<div className={styles.channel}>
|
||
<div className={styles.channelTop}>
|
||
<span className={styles.channelLabel}>{CHANNEL_LABELS[channel]}</span>
|
||
<select
|
||
className={styles.select}
|
||
aria-label={`${CHANNEL_LABELS[channel]} column`}
|
||
value={selectValue}
|
||
onChange={(e) => setChannelColumn(channel, e.target.value === '' ? null : e.target.value)}
|
||
>
|
||
<option value="">None</option>
|
||
<option value={COUNT_FIELD}>Count of records</option>
|
||
{columns.columns.map((name) => {
|
||
const allowed = columnAllowedOnChannel(channel, colTypeOf(name));
|
||
return (
|
||
<option key={name} value={name} disabled={!allowed}>
|
||
{name} · {typeBadge(colTypeOf(name))}
|
||
{allowed ? '' : ' (needs a measure)'}
|
||
</option>
|
||
);
|
||
})}
|
||
</select>
|
||
</div>
|
||
|
||
{mapping && !isCount(mapping) && (
|
||
<div className={styles.channelControls}>
|
||
<SegmentedControl
|
||
label={`${CHANNEL_LABELS[channel]} field type`}
|
||
options={typeSegments}
|
||
value={mapping.type}
|
||
onChange={(t) => setChannelType(channel, t)}
|
||
className={styles.typeSeg}
|
||
/>
|
||
|
||
{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>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
const [error, setError] = useState<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) {
|
||
// TODO: this drops the next-step the error contract wants (arch 10); LivePreview
|
||
// gives "Create it from Datasets…". Near-unreachable here (the builder opens from
|
||
// an existing dataset), so it's terse — restore the next-step if it can be reached.
|
||
setError(`Dataset "${e.datasetName}" not found.`);
|
||
setTooLarge(null);
|
||
} else {
|
||
// TODO: arch 10 routes a raw diagnostic into a disclosure, not the headline. The
|
||
// editor surfaces the Vega message inline by design; the builder could fold it
|
||
// behind a details disclosure and keep the headline plain.
|
||
setError(`Couldn't render this chart: ${(e as Error).message}`);
|
||
setTooLarge(null);
|
||
}
|
||
}
|
||
})();
|
||
}, RENDER_DEBOUNCE_MS);
|
||
|
||
return () => clearTimeout(timer);
|
||
}, [specText, valid, uiTheme, datasets]);
|
||
|
||
useEffect(
|
||
() => () => {
|
||
handleRef.current?.destroy();
|
||
handleRef.current = null;
|
||
},
|
||
[],
|
||
);
|
||
|
||
return (
|
||
<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 && (
|
||
<pre className={styles.previewError} role="alert">
|
||
{error}
|
||
</pre>
|
||
)}
|
||
</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 swapXY = useChartBuilderStore((s) => s.swapXY);
|
||
const setSort = useChartBuilderStore((s) => s.setSort);
|
||
const setStack = useChartBuilderStore((s) => s.setStack);
|
||
const setWidth = useChartBuilderStore((s) => s.setWidth);
|
||
const setHeight = useChartBuilderStore((s) => s.setHeight);
|
||
const applyWarningFix = useChartBuilderStore((s) => s.applyWarningFix);
|
||
const runCreate = useChartBuilderStore((s) => s.createSnippet);
|
||
|
||
// Validity + guidance + which chart-level controls apply are derived from the
|
||
// stable `config` reference via useMemo, NOT a store selector that would build a
|
||
// fresh array each render (which loops useSyncExternalStore — see SnippetStore note).
|
||
const config = useChartBuilderStore((s) => s.config);
|
||
const rowCount = useChartBuilderStore((s) => s.rowCount);
|
||
// `columns` is a stable reference set once at init (not rebuilt per render), so
|
||
// subscribing to it won't loop useSyncExternalStore.
|
||
const columns = useChartBuilderStore((s) => s.columns);
|
||
const valid = useMemo(() => isBuilderConfigValid(config), [config]);
|
||
const warnings = useMemo(
|
||
() => builderWarnings(config, rowCount, columns),
|
||
[config, rowCount, columns],
|
||
);
|
||
const canSort = useMemo(() => supportsSort(config), [config]);
|
||
const canStack = useMemo(() => supportsStack(config), [config]);
|
||
|
||
// Applying a hint's fix removes that hint's list item, so focus would otherwise fall
|
||
// to <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>
|
||
|
||
<div className={styles.channels}>
|
||
<div className={styles.channelsHeader}>
|
||
<span className={styles.fieldLabel}>Encoding</span>
|
||
<button type="button" className={styles.swap} onClick={swapXY}>
|
||
⇄ Swap X/Y
|
||
</button>
|
||
</div>
|
||
{CHANNELS.map((channel) => (
|
||
<ChannelBlock key={channel} channel={channel} />
|
||
))}
|
||
</div>
|
||
|
||
{(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}>
|
||
<span className={styles.fieldLabel}>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>
|
||
|
||
<BuilderPreview />
|
||
</div>
|
||
);
|
||
}
|