Chart builder: filter/calculate transforms, data preview, and inline expression validation

This commit is contained in:
2026-06-11 13:01:23 +03:00
parent 1791ee9f8d
commit 05df0cd7d3
13 changed files with 1919 additions and 27 deletions
+106
View File
@@ -0,0 +1,106 @@
/**
* Vega expression validation (spec §06 → Data; chart-builder enhancement 1E).
*
* Portable core: no browser APIs, no React. The Chart Builder's filter
* (expression mode) and calculated-field controls let the user write a raw Vega
* expression; this module checks it with **the same parser Vega-Lite uses at render
* time** (`vega-expression`'s `parseExpression`), so the inline "is this valid?"
* feedback agrees exactly with what the chart will accept — no second, divergent
* grammar. It also extracts the `datum.<field>` references so the UI can flag a typo
* against the dataset's actual columns before the chart silently renders empty.
*
* `vega-expression` is a pure dependency already in the bundle (Vega pulls it in for
* rendering), so importing it here adds nothing and keeps this module browser-free.
*/
import { parseExpression } from 'vega-expression';
/** The outcome of validating one expression string. */
export interface ExprValidation {
/** True when the expression parses (or is empty — an empty field isn't an error). */
valid: boolean;
/** A short parser message when invalid; absent when valid. */
error?: string;
}
/**
* Validate a Vega expression. An **empty** string is treated as valid (it is an
* incomplete entry, not a mistake — the assembler skips it), so the UI shows no
* error until the user actually types something malformed. A parse failure returns
* the parser's message, trimmed of the noisy position suffix where present.
*/
export function validateExpression(expr: string): ExprValidation {
if (expr.trim() === '') return { valid: true };
try {
parseExpression(expr);
return { valid: true };
} catch (e) {
return { valid: false, error: cleanParserMessage((e as Error).message) };
}
}
/** Tidy a vega-expression parse error for inline display (drop a trailing "(N)"). */
function cleanParserMessage(message: string): string {
return message.replace(/\s*\(\d+\)\s*$/, '').trim() || 'Invalid expression.';
}
/**
* The distinct `datum.<field>` (and `datum['field']`) column names an expression
* references, in first-seen order — best-effort, for flagging unknown fields in the
* UI. Returns an empty list for an empty or unparseable expression (validation
* surfaces the parse error separately). Only direct member access off `datum` is
* collected; dynamic access (`datum[someVar]`) is not a static field name and is
* skipped.
*/
export function referencedFields(expr: string): string[] {
if (expr.trim() === '') return [];
let ast: unknown;
try {
ast = parseExpression(expr);
} catch {
return [];
}
const fields: string[] = [];
const seen = new Set<string>();
const visit = (node: unknown): void => {
if (Array.isArray(node)) {
for (const child of node) visit(child);
return;
}
if (!node || typeof node !== 'object') return;
const rec = node as Record<string, unknown>;
if (rec.type === 'MemberExpression') {
const name = datumMemberName(rec);
if (name !== null && !seen.has(name)) {
seen.add(name);
fields.push(name);
}
}
for (const key of Object.keys(rec)) {
if (key === 'type') continue;
visit(rec[key]);
}
};
visit(ast);
return fields;
}
/**
* The static field name of a `datum.<name>` / `datum['name']` member access, or
* `null` when the node isn't such an access (a different object, computed-dynamic
* access, or a non-string key).
*/
function datumMemberName(member: Record<string, unknown>): string | null {
const object = member.object as Record<string, unknown> | undefined;
if (!object || object.type !== 'Identifier' || object.name !== 'datum') return null;
const property = member.property as Record<string, unknown> | undefined;
if (!property) return null;
if (member.computed) {
// datum['field'] — a string literal key is a static field name.
return property.type === 'Literal' && typeof property.value === 'string'
? property.value
: null;
}
// datum.field — an identifier key.
return property.type === 'Identifier' && typeof property.name === 'string' ? property.name : null;
}