Add Chart Builder: no-JSON Vega-Lite composer from a dataset (M4)

This commit is contained in:
2026-06-05 23:46:21 +03:00
parent 693f5d7073
commit c11afc273d
16 changed files with 1856 additions and 26 deletions
+333
View File
@@ -0,0 +1,333 @@
/**
* Chart Builder — the modal body (spec §06).
*
* A two-pane composer: left is the configuration (dataset name, mark selector, one
* row per channel, 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. 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,
builderWarnings,
defaultFieldType,
isBuilderConfigValid,
isChannelTypeAllowed,
validFieldTypes,
type ChannelName,
type FieldType,
type MarkType,
} from '@core/chart-builder';
import type { ColumnType } from '@core/type-inference';
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
import { chartConfigFor } from '@core/vega-themes';
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
import { closeModal } from '../modals/ModalCoordinator';
import { useAppStore } from '../stores/AppStore';
import { useDatasetStore } from '../stores/DatasetStore';
import {
selectBuilderSpecText,
selectBuilderValid,
useChartBuilderStore,
} from '../stores/ChartBuilderStore';
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
import styles from './ChartBuilderModal.module.css';
const RENDER_DEBOUNCE_MS = 300;
/** Title-case a token for display (e.g. `bar` → `Bar`, `quantitative` → `Quantitative`). */
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',
};
/** A compact type indicator for a column option (text · # · date · ✓). */
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));
}
function ChannelRow({ channel }: { channel: ChannelName }) {
const columns = useChartBuilderStore((s) => s.columns);
const mapping = useChartBuilderStore((s) => s.config.encodings[channel] ?? null);
const setChannelColumn = useChartBuilderStore((s) => s.setChannelColumn);
const setChannelType = useChartBuilderStore((s) => s.setChannelType);
const colTypeOf = (name: string): ColumnType =>
columns.columnTypes.find((c) => c.name === name)?.type ?? 'string';
// Type options valid for this column AND allowed on this channel (e.g. Size hides
// Nominal). Shown only when >1 option and a column is selected (spec §06).
const typeOptions: FieldType[] = mapping
? validFieldTypes(colTypeOf(mapping.field)).filter((t) => isChannelTypeAllowed(channel, t))
: [];
return (
<div className={styles.channelRow}>
<label className={styles.channelLabel} htmlFor={`ch-${channel}`}>
{CHANNEL_LABELS[channel]}
</label>
<select
id={`ch-${channel}`}
className={styles.select}
value={mapping?.field ?? ''}
onChange={(e) => setChannelColumn(channel, e.target.value === '' ? null : e.target.value)}
>
<option value="">None</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>
{mapping && typeOptions.length > 1 && (
<select
className={styles.typeSelect}
aria-label={`${CHANNEL_LABELS[channel]} field type`}
value={mapping.type}
onChange={(e) => setChannelType(channel, e.target.value as FieldType)}
>
{typeOptions.map((t) => (
<option key={t} value={t}>
{titleCase(t)}
</option>
))}
</select>
)}
</div>
);
}
function BuilderPreview() {
const hostRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<RenderHandle | null>(null);
const generationRef = useRef(0);
const [error, setError] = useState<string | 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;
// Below validation there is nothing to draw — clear the chart and show the
// configuration prompt, not an error (spec §06 → Live Preview placeholder).
if (!valid) {
handleRef.current?.destroy();
handleRef.current = null;
setError(null);
return;
}
if (!node) return;
try {
const parsed: unknown = JSON.parse(specText);
const prepared = prepareSpecForRender(parsed, { fitMode: 'width', datasets });
handleRef.current?.destroy();
handleRef.current = null;
const handle = await renderSpec(
node,
prepared as VisualizationSpec,
chartConfigFor(uiTheme),
);
if (mine !== generationRef.current) {
handle.destroy();
return;
}
handleRef.current = handle;
setError(null);
} catch (e) {
if (mine !== generationRef.current) return;
if (e instanceof DatasetNotFoundError) {
setError(`Dataset "${e.datasetName}" not found.`);
} else {
setError(`Couldn't render this chart: ${(e as Error).message}`);
}
}
})();
}, RENDER_DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [specText, valid, uiTheme, datasets]);
// Finalize the view on unmount so the Vega view and its listeners don't leak.
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>
)}
<div className={styles.previewFrame} hidden={!valid || error !== null}>
<div className={styles.previewHost} ref={hostRef} />
</div>
{valid && 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 setMark = useChartBuilderStore((s) => s.setMark);
const swapXY = useChartBuilderStore((s) => s.swapXY);
const setWidth = useChartBuilderStore((s) => s.setWidth);
const setHeight = useChartBuilderStore((s) => s.setHeight);
const runCreate = useChartBuilderStore((s) => s.createSnippet);
// Derive validity + guidance from the stable `config` reference via useMemo, NOT
// from a store selector: `builderWarnings` builds a fresh array of objects each
// call, which no selector-equality (even useShallow, since the element objects
// differ every time) can stabilize — subscribing to it would re-render forever.
const config = useChartBuilderStore((s) => s.config);
const valid = useMemo(() => isBuilderConfigValid(config), [config]);
const warnings = useMemo(() => builderWarnings(config), [config]);
if (datasetId === null) {
return <p className={styles.muted}>No dataset loaded. Open this from a dataset in Datasets.</p>;
}
/** Parse a dimension input: blank → undefined, otherwise a non-negative integer. */
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}>
<p className={styles.datasetName}>
Building from <strong>{datasetName}</strong>
</p>
<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) => (
<ChannelRow key={channel} channel={channel} />
))}
</div>
<div className={styles.dimensions}>
<span className={styles.fieldLabel}>Dimensions (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}>
{warnings.map((w) => (
<li key={w.message} className={styles.warning}>
{w.message}
</li>
))}
</ul>
)}
<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}
onClick={() => runCreate()}
>
Create Snippet
</button>
</div>
</div>
<BuilderPreview />
</div>
);
}