Editor: spec transforms (wrap/simplify/add-view) and dataset-aware hints

This commit is contained in:
2026-06-28 16:56:20 +03:00
parent 20e70bee0e
commit 31458114fb
20 changed files with 2498 additions and 7 deletions
+166
View File
@@ -0,0 +1,166 @@
/**
* Dataset-aware editor hints (docs/architecture/08 → editor augmentation) — the
* three things the Vega-Lite JSON schema *can't* know, because they depend on the
* user's data and this spec:
*
* - **Completion** — in a `field` / `groupby` value, the bound dataset's real
* column names (plus the spec's derived fields). The schema only knows `field`
* takes a string; it can't list your columns. Enum values (`type`, `mark`, …)
* are left to the schema — we add only what it lacks, no second source.
* - **Hover** — a column's inferred type + cardinality/range (from the stored
* profile); over a `calculate`/`filter`/`expr` string, a live validity check
* via core/expr-validate. Monaco merges these with the schema's own hovers.
* - **Inlay hints** — a faint `: <type>` beside each `field`, annotation without
* touching the text.
*
* All three read the active draft's bound dataset (services/active-dataset) and
* the cursor's JSON context (core/spec-cursor) at provide-time via `getState()` —
* outside React. They register **once, globally for JSON** (like the schema and
* formatter), not per editor. Suggestion-only: over- or under-listing is
* harmless, which is why there is deliberately no "unknown field" diagnostic
* (that would false-positive on every data-dependent derived column).
*/
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import { defaultFieldType } from '@core/chart-builder';
import { validateExpression } from '@core/expr-validate';
import { stringValueAtOffset, valueKeyAtOffset } from '@core/spec-cursor';
import type { ColumnType } from '@core/type-inference';
import { useSnippetStore } from '../stores/SnippetStore';
import { availableFields, dataInfo, type DataInfo, type FieldHint } from './active-dataset';
/** Property values that reference a data field (where column names belong). */
const FIELD_KEYS = new Set(['field', 'groupby']);
/** Property values that hold a Vega expression (where the validity check fires). */
const EXPR_KEYS = new Set(['calculate', 'filter', 'expr']);
/** A short type label for a source field; null type (derived) is shown elsewhere. */
const typeLabel = (type: ColumnType): string => defaultFieldType(type);
/** Markdown hover for a field hint: type + stats for source, a note for derived. */
function fieldHoverContents(hint: FieldHint, info: DataInfo): { value: string }[] {
if (hint.derived || hint.type === null) {
return [{ value: `**${hint.name}** · _derived by a transform_` }];
}
const lines = [`**${hint.name}** · \`${typeLabel(hint.type)}\``];
const stat = info.columnStats.find((s) => s.name === hint.name);
if (stat) {
if (stat.numericExtent)
lines.push(`Range ${stat.numericExtent.min} ${stat.numericExtent.max}`);
lines.push(`${stat.distinct}${stat.distinctCapped ? '+' : ''} distinct`);
}
lines.push(info.name ? `_from dataset “${info.name}”_` : '_from the specs inline data_');
return lines.map((value) => ({ value }));
}
let registered = false;
/** Register the dataset-aware completion / hover / inlay providers once. */
export function configureSpecDatasetHints(): void {
if (registered) return;
registered = true;
monaco.languages.registerCompletionItemProvider('json', {
triggerCharacters: ['"'],
provideCompletionItems(model, position) {
// Field suggestions only edit the draft; nothing to offer on the published view.
if (useSnippetStore.getState().editorView !== 'draft') return { suggestions: [] };
const key = valueKeyAtOffset(model.getValue(), model.getOffsetAt(position));
if (key === null || !FIELD_KEYS.has(key)) return { suggestions: [] };
const fields = availableFields();
if (fields.length === 0) return { suggestions: [] };
const word = model.getWordUntilPosition(position);
const range = new monaco.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn,
);
return {
suggestions: fields.map((f) => ({
label: f.name,
kind: f.derived
? monaco.languages.CompletionItemKind.Variable
: monaco.languages.CompletionItemKind.Field,
detail: f.derived || f.type === null ? 'derived field' : typeLabel(f.type),
insertText: f.name,
range,
})),
};
},
});
// All three providers annotate the draft only: they derive their field set from
// the draft buffer (dataInfo), so gating to the draft view keeps the hints
// consistent with the text they are computed from. The published view is a
// read-only reference, where field hints are marginal.
monaco.languages.registerHoverProvider('json', {
provideHover(model, position) {
if (useSnippetStore.getState().editorView !== 'draft') return null;
const text = model.getValue();
const offset = model.getOffsetAt(position);
// Over an expression value: a live validity check.
const key = valueKeyAtOffset(text, offset);
if (key !== null && EXPR_KEYS.has(key)) {
const expr = stringValueAtOffset(text, offset);
if (expr !== null) {
const result = validateExpression(expr);
return {
contents: [
{
value: result.valid
? '✓ Valid Vega expression'
: `${result.error ?? 'Invalid expression'}`,
},
],
};
}
}
// Over a field name: its type + stats.
const word = model.getWordAtPosition(position);
if (word) {
const info = dataInfo();
const hint = info.fields.find((f) => f.name === word.word);
if (hint) {
return {
range: new monaco.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn,
),
contents: fieldHoverContents(hint, info),
};
}
}
return null;
},
});
monaco.languages.registerInlayHintsProvider('json', {
provideInlayHints(model, range) {
if (useSnippetStore.getState().editorView !== 'draft') return { hints: [], dispose() {} };
const types = new Map(availableFields().map((f) => [f.name, f.type]));
if (types.size === 0) return { hints: [], dispose() {} };
const hints: monaco.languages.InlayHint[] = [];
for (let line = range.startLineNumber; line <= range.endLineNumber; line++) {
// First `field` per line; the compact format keeps each encoding channel
// (and its lone field) on its own line, so one match per line suffices.
const match = /"field"\s*:\s*"([^"]+)"/.exec(model.getLineContent(line));
if (!match) continue;
const type = types.get(match[1]);
if (!type) continue; // unknown or derived (no type to annotate)
hints.push({
position: { lineNumber: line, column: match.index + match[0].length + 1 },
label: `: ${typeLabel(type)}`,
kind: monaco.languages.InlayHintKind.Type,
paddingLeft: true,
});
}
return { hints, dispose() {} };
},
});
}