mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
407 lines
16 KiB
TypeScript
407 lines
16 KiB
TypeScript
/**
|
|
* 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<Omit<BuilderFilter, 'id'>>) => 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<Omit<BuilderCalculate, 'id'>>) => 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<ChartBuilderState>((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);
|