/** * Chart Builder state (spec §06). * * Backs the Chart Builder modal: a no-JSON composer that turns a dataset + a mark * + four channel mappings into a Vega-Lite spec saved as a new snippet. All the * spec grammar and the Tier-B defaults/guards live in the portable core * (`@core/chart-builder`); this store is the thin app-layer state + actions over * that, plus the create-flow side effects (new snippet, toast, activate, close). * * `init(datasetId)` loads the dataset's columns and pre-populates a smart default * config; with no dataset it lands empty so the modal can show "No dataset loaded". * The mark is sticky after open (changing a column does not re-derive it) so the * user's choice is never overridden mid-edit. */ import { create } from 'zustand'; import { buildSnippetSpecText, defaultBuilderConfig, defaultFieldType, effectiveColumns, generateChartName, isBuilderConfigValid, isChannelTypeAllowed, pruneEncodings, supportsAggregate, supportsBin, supportsTimeUnit, validFieldTypes, validFilterOps, type AggregateOp, type BuilderCalculate, type BuilderColumns, type BuilderConfig, type BuilderFilter, type BuilderWarningFix, type ChannelMapping, type ChannelName, type FieldType, type FilterMode, type MarkType, type SortOrder, type StackMode, type TimeUnit, } from '@core/chart-builder'; import type { ColumnType } from '@core/type-inference'; import { closeModal } from '../modals/ModalCoordinator'; import { useDatasetStore } from './DatasetStore'; import { useSnippetStore } from './SnippetStore'; /** An empty config — no dataset, nothing mapped (the "No dataset loaded" state). */ const EMPTY_CONFIG: BuilderConfig = { datasetName: '', mark: 'bar', encodings: {} }; const EMPTY_COLUMNS: BuilderColumns = { columns: [], columnTypes: [] }; /** * Sentinel column value for the "Count of records" dropdown option — a field-less * `count` measure (Voyager's `count(*)`). Distinct from any real column name (a NUL * byte can't appear in one), so the dropdown can offer it alongside the columns. * The NUL is written as a `\u0000` escape so the source stays text, not binary. */ export const COUNT_FIELD = '\u0000count'; /** * Monotonic id source for filter / calculated-field list rows. Ids are stable React * keys and edit handles only — they never reach the produced spec — so a plain * session counter is enough (no need for crypto/uuid), and it keeps the rows * order-stable as the user adds and removes them. */ let transformSeq = 0; const nextTransformId = (prefix: 'f' | 'c'): string => `${prefix}${++transformSeq}`; export interface ChartBuilderState { /** The dataset being built from, or null when none is loaded. */ datasetId: number | null; /** The dataset's columns + inferred types (drives the dropdowns and defaults). */ columns: BuilderColumns; /** The dataset's row count (null for URL/non-tabular); powers the crowded-axis hint. */ rowCount: number | null; /** The working configuration the preview and the produced spec read from. */ config: BuilderConfig; /** Load a dataset and pre-populate a smart default config (spec §06 → Opening). */ init: (datasetId: number | null) => void; setMark: (mark: MarkType) => void; /** * Map a column to a channel (null = "None", `COUNT_FIELD` = a field-less count); * seeds the channel's default type and clears any prior transforms. */ setChannelColumn: (channel: ChannelName, columnName: string | null) => void; setChannelType: (channel: ChannelName, type: FieldType) => void; /** Set/clear a channel's aggregate (sum/mean/…); `undefined` clears it. */ setChannelAggregate: (channel: ChannelName, aggregate: AggregateOp | undefined) => void; /** Toggle binning a quantitative channel into ranges. */ setChannelBin: (channel: ChannelName, bin: boolean) => void; /** Set/clear a temporal channel's granularity (timeUnit). */ setChannelTimeUnit: (channel: ChannelName, timeUnit: TimeUnit | undefined) => void; /** Swap the X and Y mappings (a one-click axis flip). */ swapXY: () => void; /** Apply a guidance hint's one-click remedy (Tier-C actionable hint, §06). */ applyWarningFix: (fix: BuilderWarningFix) => void; /** Sort the categorical axis by its measure (ranking); `undefined` = unsorted. */ setSort: (sort: SortOrder | undefined) => void; /** Stacking mode for bar/area + a colour series; `undefined` = Vega-Lite default. */ setStack: (stack: StackMode | undefined) => void; /** Append a new, empty predicate filter (defaults to the first column, equals). */ addFilter: () => void; /** Patch one filter row by id (op/value/value2/expr/mode). */ updateFilter: (id: string, patch: Partial>) => void; /** Re-point a filter to a column: derives its field type and clamps the operator. */ setFilterField: (id: string, field: string) => void; /** Switch a filter between the guarded predicate shelf and a raw expression. */ setFilterMode: (id: string, mode: FilterMode) => void; /** Remove a filter row by id. */ removeFilter: (id: string) => void; /** Append a new, empty calculated field. */ addCalculate: () => void; /** Patch one calculated field by id (expr / as); prunes any now-dangling encoding. */ updateCalculate: (id: string, patch: Partial>) => void; /** Remove a calculated field by id; clears any channel that referenced it. */ removeCalculate: (id: string) => void; setWidth: (width: number | undefined) => void; setHeight: (height: number | undefined) => void; /** Build the spec, create + activate a linked snippet, toast, and close. */ createSnippet: (now?: Date) => boolean; reset: () => void; } /** The inferred type of a named column, defaulting to `string` if unknown. */ function columnType(columns: BuilderColumns, name: string): ColumnType { return columns.columnTypes.find((c) => c.name === name)?.type ?? 'string'; } /** The dataset columns plus the config's calculated fields (what the dropdowns offer). */ function effCols(s: ChartBuilderState): BuilderColumns { return effectiveColumns(s.columns, s.config.calculates); } /** Replace one channel's mapping, returning the new `{ config }` state slice. */ function updateEncoding( s: ChartBuilderState, channel: ChannelName, mapping: ChannelMapping, ): { config: BuilderConfig } { return { config: { ...s.config, encodings: { ...s.config.encodings, [channel]: mapping } } }; } export const useChartBuilderStore = create((set, get) => ({ datasetId: null, columns: EMPTY_COLUMNS, rowCount: null, config: EMPTY_CONFIG, init: (datasetId) => { const dataset = datasetId === null ? undefined : useDatasetStore.getState().datasets.find((d) => d.id === datasetId); if (!dataset) { set({ datasetId: null, columns: EMPTY_COLUMNS, rowCount: null, config: EMPTY_CONFIG }); return; } const columns: BuilderColumns = { columns: dataset.columns, columnTypes: dataset.columnTypes, columnStats: dataset.columnStats, }; set({ datasetId: dataset.id, columns, rowCount: dataset.rowCount, config: defaultBuilderConfig(dataset.name, columns), }); }, setMark: (mark) => set((s) => ({ config: { ...s.config, mark } })), setChannelColumn: (channel, columnName) => set((s) => { let mapping: ChannelMapping | null; if (columnName === null) { mapping = null; } else if (columnName === COUNT_FIELD) { // The field-less "Count of records" measure. mapping = { type: 'quantitative', aggregate: 'count' }; } else { // Default to the column's natural type, but if that type isn't allowed on // this channel (e.g. a category on Size), fall back to the first valid type // that is — the UI also disables unsuitable columns, this is the guard. // Effective columns include calculated fields (which default to numeric). const colType = columnType(effCols(s), columnName); const valid = validFieldTypes(colType); const type = valid.find((t) => isChannelTypeAllowed(channel, t)) ?? defaultFieldType(colType); mapping = { field: columnName, type }; // a fresh mapping clears prior transforms } return { config: { ...s.config, encodings: { ...s.config.encodings, [channel]: mapping } } }; }), setChannelType: (channel, type) => set((s) => { const current = s.config.encodings[channel]; if (!current) return s; // no field on this channel → nothing to retype // Drop transforms that no longer apply to the new type (e.g. an aggregate or // bin when leaving Quantitative, a granularity when leaving Temporal). const next: ChannelMapping = { ...current, type }; if (!supportsAggregate(type)) delete next.aggregate; if (!supportsBin(type)) delete next.bin; if (!supportsTimeUnit(type)) delete next.timeUnit; return updateEncoding(s, channel, next); }), setChannelAggregate: (channel, aggregate) => set((s) => { const current = s.config.encodings[channel]; if (!current) return s; const next: ChannelMapping = { ...current }; if (aggregate) { next.aggregate = aggregate; delete next.bin; // a field can't be both aggregated and binned (Draco hard:28) } else delete next.aggregate; return updateEncoding(s, channel, next); }), setChannelBin: (channel, bin) => set((s) => { const current = s.config.encodings[channel]; if (!current) return s; const next: ChannelMapping = { ...current }; if (bin) { next.bin = true; delete next.aggregate; // mutually exclusive with aggregate (Draco hard:28) } else delete next.bin; return updateEncoding(s, channel, next); }), setChannelTimeUnit: (channel, timeUnit) => set((s) => { const current = s.config.encodings[channel]; if (!current) return s; const next: ChannelMapping = { ...current }; if (timeUnit) next.timeUnit = timeUnit; else delete next.timeUnit; return updateEncoding(s, channel, next); }), setSort: (sort) => set((s) => ({ config: { ...s.config, sort } })), setStack: (stack) => set((s) => ({ config: { ...s.config, stack } })), addFilter: () => set((s) => { // Seed the new row on the first available column so it is immediately usable; // an unmapped dataset (no columns) yields an expression-mode row instead. const first = effCols(s).columns[0]; const filter: BuilderFilter = first ? { id: nextTransformId('f'), mode: 'predicate', field: first, fieldType: defaultFieldType(columnType(effCols(s), first)), op: 'equal', value: '', } : { id: nextTransformId('f'), mode: 'expression', expr: '' }; return { config: { ...s.config, filters: [...(s.config.filters ?? []), filter] } }; }), updateFilter: (id, patch) => set((s) => ({ config: { ...s.config, filters: (s.config.filters ?? []).map((f) => (f.id === id ? { ...f, ...patch } : f)), }, })), setFilterField: (id, field) => set((s) => { const fieldType = defaultFieldType(columnType(effCols(s), field)); return { config: { ...s.config, filters: (s.config.filters ?? []).map((f) => { if (f.id !== id) return f; // Re-point the field and its type; keep the operator only if it is still // valid for the new type (a measure op on a category resets to equals). const op = f.op && validFilterOps(fieldType).includes(f.op) ? f.op : 'equal'; return { ...f, field, fieldType, op }; }), }, }; }), setFilterMode: (id, mode) => set((s) => { const first = effCols(s).columns[0]; return { config: { ...s.config, filters: (s.config.filters ?? []).map((f) => { if (f.id !== id) return f; // Switching to the predicate shelf without a field yet (e.g. the row was // born in expression mode) seeds the first column so it's usable at once. if (mode === 'predicate' && !f.field && first) { return { ...f, mode, field: first, fieldType: defaultFieldType(columnType(effCols(s), first)), op: f.op ?? 'equal', }; } return { ...f, mode }; }), }, }; }), removeFilter: (id) => set((s) => ({ config: { ...s.config, filters: (s.config.filters ?? []).filter((f) => f.id !== id) }, })), addCalculate: () => set((s) => ({ config: { ...s.config, calculates: [ ...(s.config.calculates ?? []), { id: nextTransformId('c'), expr: '', as: '' }, ], }, })), updateCalculate: (id, patch) => set((s) => { const calculates = (s.config.calculates ?? []).map((c) => c.id === id ? { ...c, ...patch } : c, ); // A rename (changed `as`) can orphan a channel that mapped the old name; prune // any encoding whose field no longer exists among the effective columns. return { config: pruneEncodings({ ...s.config, calculates }, s.columns) }; }), removeCalculate: (id) => set((s) => { const calculates = (s.config.calculates ?? []).filter((c) => c.id !== id); return { config: pruneEncodings({ ...s.config, calculates }, s.columns) }; }), swapXY: () => set((s) => ({ config: { ...s.config, encodings: { ...s.config.encodings, x: s.config.encodings.y ?? null, y: s.config.encodings.x ?? null, }, }, })), applyWarningFix: (fix) => set((s) => ({ config: fix.apply(s.config) })), setWidth: (width) => set((s) => ({ config: { ...s.config, width } })), setHeight: (height) => set((s) => ({ config: { ...s.config, height } })), createSnippet: (now) => { const { config } = get(); if (!isBuilderConfigValid(config)) return false; // guarded by a disabled action too const name = generateChartName(config); const specText = buildSnippetSpecText(config); // createSnippet mirrors datasetRefs from the spec, so the new snippet is linked // to its dataset (§09F) without extra wiring. Provenance kept in meta (§06). useSnippetStore.getState().createSnippet({ name, spec: specText, now, meta: { createdWith: 'chart-builder', builtFromDataset: config.datasetName }, }); // No success toast: the new snippet immediately becomes active and opens in the // editor, so the result is visible — toasting it would be noise (contract 10 §1, // "toast only what the user can't already see"). void closeModal(true); // the create is the user's confirmation — no discard prompt get().reset(); return true; }, reset: () => set({ datasetId: null, columns: EMPTY_COLUMNS, rowCount: null, config: EMPTY_CONFIG }), })); /** * Selector: whether the config can be saved (≥1 channel mapped, spec §06 → * Validation). Returns a boolean (stable under Object.is), so it is safe to * subscribe to directly. Non-blocking *guidance* (`builderWarnings`) deliberately * has NO selector here — it builds a fresh array of objects each call, which no * subscription equality can stabilize; the component derives it via `useMemo` over * the stable `config` reference instead (see ChartBuilderModal). */ export const selectBuilderValid = (s: ChartBuilderState) => isBuilderConfigValid(s.config); /** Selector: the built spec as JSON text, for the live preview. */ export const selectBuilderSpecText = (s: ChartBuilderState) => buildSnippetSpecText(s.config);