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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user