mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart builder: filter/calculate transforms, data preview, and inline expression validation
This commit is contained in:
@@ -18,20 +18,26 @@ 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,
|
||||
@@ -54,6 +60,15 @@ const EMPTY_COLUMNS: BuilderColumns = { columns: [], columnTypes: [] };
|
||||
*/
|
||||
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;
|
||||
@@ -87,6 +102,24 @@ export interface ChartBuilderState {
|
||||
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. */
|
||||
@@ -99,6 +132,11 @@ 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,
|
||||
@@ -150,10 +188,11 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
|
||||
// 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.
|
||||
const valid = validFieldTypes(columnType(s.columns, columnName));
|
||||
// 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(columnType(s.columns, columnName));
|
||||
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 } } };
|
||||
@@ -209,6 +248,106 @@ export const useChartBuilderStore = create<ChartBuilderState>((set, get) => ({
|
||||
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: {
|
||||
|
||||
Reference in New Issue
Block a user