Editor: view-scoped data context — per-view dataset columns in hints and transforms

This commit is contained in:
2026-06-28 20:54:46 +03:00
parent e69430834a
commit a75ea5b59e
14 changed files with 547 additions and 244 deletions
+113
View File
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, test } from 'vitest';
import { createDataset } from '@core/dataset';
import { useDatasetStore } from '../stores/DatasetStore';
import { dataInfoAt } from './active-dataset';
/** Seed a library dataset, profiled from inline rows. */
function seed(name: string, rows: Record<string, unknown>[]): void {
useDatasetStore
.getState()
.add(createDataset({ name, data: rows, format: 'json', source: 'inline' }));
}
/** An offset inside the first occurrence of `marker` in `text`. */
function at(text: string, marker: string): number {
return text.indexOf(marker) + 1;
}
const names = (cols: ReadonlyArray<{ name: string }>) => cols.map((c) => c.name).sort();
beforeEach(() => {
useDatasetStore.getState().reset();
});
describe('dataInfoAt — view-scoped data context', () => {
test('resolves a library binding case-insensitively', () => {
seed('Sales', [{ region: 'W', revenue: 10 }]);
const text = '{ "data": { "name": "sales" }, "encoding": { "x": { "field": "MK" } } }';
const info = dataInfoAt(text, at(text, 'MK'));
expect(info.name).toBe('Sales');
expect(names(info.columnTypes)).toEqual(['region', 'revenue']);
});
test('each layer sees its own dataset, not a sibling views', () => {
seed('Sales', [{ region: 'W', revenue: 10 }]);
seed('Regions', [{ region: 'W', population: 100 }]);
const text = JSON.stringify(
{
layer: [
{ data: { name: 'Sales' }, encoding: { x: { field: 'sales_mk' } } },
{ data: { name: 'Regions' }, encoding: { y: { field: 'regions_mk' } } },
],
},
null,
2,
);
const inLayer0 = dataInfoAt(text, at(text, 'sales_mk'));
const inLayer1 = dataInfoAt(text, at(text, 'regions_mk'));
expect(inLayer0.name).toBe('Sales');
expect(names(inLayer0.columnTypes)).toEqual(['region', 'revenue']);
expect(inLayer1.name).toBe('Regions');
expect(names(inLayer1.columnTypes)).toEqual(['population', 'region']);
});
test('a child without its own data inherits the parent binding', () => {
seed('Sales', [{ region: 'W', revenue: 10 }]);
const text = JSON.stringify(
{ data: { name: 'Sales' }, layer: [{ encoding: { x: { field: 'mk' } } }] },
null,
2,
);
const info = dataInfoAt(text, at(text, '"mk"'));
expect(info.name).toBe('Sales');
expect(names(info.columnTypes)).toEqual(['region', 'revenue']);
});
test('profiles an inline binding (ghost dataset, no name)', () => {
const text =
'{ "data": { "values": [{ "a": 1, "b": 2 }] }, "encoding": { "x": { "field": "MK" } } }';
const info = dataInfoAt(text, at(text, 'MK'));
expect(info.name).toBeNull();
expect(names(info.columnTypes)).toEqual(['a', 'b']);
});
test('profiles a self-defined top-level datasets binding', () => {
const text = JSON.stringify(
{
datasets: { local: [{ c: 1, d: 2 }] },
data: { name: 'local' },
encoding: { x: { field: 'mk' } },
},
null,
2,
);
const info = dataInfoAt(text, at(text, '"mk"'));
expect(info.name).toBeNull();
expect(names(info.columnTypes)).toEqual(['c', 'd']);
});
test('offers ancestor-derived fields, not a sibling views', () => {
seed('Sales', [{ region: 'W', revenue: 10 }]);
const text = JSON.stringify(
{
data: { name: 'Sales' },
transform: [{ calculate: 'datum.revenue * 2', as: 'shared' }],
layer: [
{ transform: [{ calculate: 'x', as: 'inner' }], encoding: { x: { field: 'mk0' } } },
{ transform: [{ calculate: 'y', as: 'sibling' }], encoding: { y: { field: 'mk1' } } },
],
},
null,
2,
);
const fields = dataInfoAt(text, at(text, 'mk0')).fields.map((f) => f.name);
expect(fields).toContain('shared'); // top-level (inherited)
expect(fields).toContain('inner'); // this view
expect(fields).not.toContain('sibling'); // the other layer
});
test('a url/generator binding yields no static columns', () => {
const url = '{ "data": { "url": "x.csv" }, "encoding": { "x": { "field": "MK" } } }';
expect(dataInfoAt(url, at(url, 'MK')).columnTypes).toEqual([]);
});
});
+130 -68
View File
@@ -1,29 +1,35 @@
/**
* The data context of the active draft (docs/architecture/08 → editor
* augmentation): the columns/types/stats the spec can reference, and the fields
* the editor hints offer. One resolver, shared by the transform actions
* (facet/repeat field defaults) and the dataset-aware hints
* The data context of the active draft **at the cursor** (docs/architecture/08 →
* editor augmentation): the columns/types/stats the spec can reference where the
* cursor sits, and the fields the editor hints offer. One resolver, shared by the
* transform actions (facet/repeat field defaults) and the dataset-aware hints
* (completion/hover/inlay).
*
* Source, in order: a **named library dataset** the draft references (its stored
* profile), else the spec's **inline data** profiled on the fly (the "ghost
* dataset" — `core/spec-inline-data` + `core/profile`, nothing stored). On top of
* either, the spec's **derived** fields (transform `as`, `core/spec-fields`) are
* added, so a `calculate` output is offered alongside the data columns.
* View-scoped: a composed spec (layer/concat/facet/repeat) can bind a different
* dataset per view, and Vega-Lite inherits a parent view's data into its children.
* So the context is resolved at a JSON path — the data binding of the nearest
* enclosing view (`dataBindingAtPath`), plus the fields derived by that view's and
* its ancestors' transforms (`derivedFieldNamesAtPath`).
*
* The result is memoized by draft text: providers fire often (every keystroke
* completes, inlay refreshes on scroll), and profiling inline data each time would
* be wasteful. App layer — reads stores via `getState`, outside React.
* Source columns, in order: a named **library dataset** the binding references
* (its stored profile, matched case-insensitively like the renderer), else the
* binding's **inline rows** profiled on the fly — inline `values` or a self-defined
* top-level `datasets` entry (the "ghost dataset", `core/spec-inline-data` +
* `core/profile`, nothing stored). URL and generator bindings have no static
* columns.
*
* Profiling is memoized per (draft text, enclosing view) so the inlay provider —
* which queries many field lines at one draft — never re-profiles the same inline
* data. App layer — reads stores via `getState`, outside React.
*/
import type { Dataset } from '@core/dataset';
import { type ColumnStats, profileData } from '@core/profile';
import { derivedFieldNames } from '@core/spec-fields';
import { inlineDataRows } from '@core/spec-inline-data';
import { extractDatasetRefs } from '@core/spec-refs';
import { pathAtOffset } from '@core/spec-cursor';
import { dataBindingAtPath, libraryRefName, selfDefinedNames } from '@core/spec-data';
import { derivedFieldNamesAtPath } from '@core/spec-fields';
import { rowsForDataBinding } from '@core/spec-inline-data';
import type { ColumnType } from '@core/type-inference';
import { useDatasetStore } from '../stores/DatasetStore';
import { useSnippetStore } from '../stores/SnippetStore';
/** A field offered to the editor hints. */
export interface FieldHint {
@@ -34,7 +40,7 @@ export interface FieldHint {
derived: boolean;
}
/** Everything the hints need about the active draft's data. */
/** Everything the hints need about the active draft's data at a cursor. */
export interface DataInfo {
/** The library dataset's name, or null for inline data (the ghost dataset). */
name: string | null;
@@ -44,7 +50,15 @@ export interface DataInfo {
fields: ReadonlyArray<FieldHint>;
}
const EMPTY: DataInfo = { name: null, columnTypes: [], columnStats: [], fields: [] };
/** The source columns of one view's data binding (the expensive, cached part). */
interface SourceColumns {
name: string | null;
columnTypes: ReadonlyArray<{ name: string; type: ColumnType }>;
columnStats: ReadonlyArray<ColumnStats>;
}
const EMPTY_SOURCE: SourceColumns = { name: null, columnTypes: [], columnStats: [] };
const EMPTY: DataInfo = { ...EMPTY_SOURCE, fields: [] };
function safeParse(text: string): unknown {
try {
@@ -54,74 +68,122 @@ function safeParse(text: string): unknown {
}
}
/** The first library dataset the draft references, or null. */
function libraryDataset(draftText: string): Dataset | null {
const refs = extractDatasetRefs(draftText);
if (refs.length === 0) return null;
const { datasets } = useDatasetStore.getState();
for (const ref of refs) {
const ds = datasets.find((d) => d.name === ref);
if (ds) return ds;
}
return null;
// The parsed draft, memoized by text — both the binding walk and the derived-field
// walk read it, and providers fire often at one stable draft.
let parseCache: { text: string; spec: unknown } | null = null;
function parsedSpec(text: string): unknown {
if (parseCache?.text === text) return parseCache.spec;
const spec = safeParse(text);
parseCache = { text, spec };
return spec;
}
function compute(draftText: string): DataInfo {
const spec = safeParse(draftText);
// Source columns: a named library dataset wins; otherwise profile inline data.
let name: string | null = null;
let columnTypes: ReadonlyArray<{ name: string; type: ColumnType }> = [];
let columnStats: ReadonlyArray<ColumnStats> = [];
const library = libraryDataset(draftText);
if (library && library.columnTypes.length > 0) {
({ name, columnTypes, columnStats } = library);
} else {
const rows = inlineDataRows(spec);
if (rows) {
const profile = profileData(rows, 0);
columnTypes = profile.columnTypes;
columnStats = profile.columnStats;
}
/** The stored profile of the library dataset a binding references, or null. */
function libraryProfile(refName: string): SourceColumns | null {
const lower = refName.toLowerCase();
const ds = useDatasetStore.getState().datasets.find((d) => d.name.toLowerCase() === lower);
if (ds && ds.columnTypes.length > 0) {
return { name: ds.name, columnTypes: ds.columnTypes, columnStats: ds.columnStats };
}
return null; // unknown name, or a dataset with no profiled columns yet
}
const fields: FieldHint[] = columnTypes.map((c) => ({
/** Source columns for one binding: library profile, else inline rows profiled. */
function resolveSource(spec: unknown, data: unknown): SourceColumns {
const refName = libraryRefName(data, selfDefinedNames(spec));
if (refName !== null) return libraryProfile(refName) ?? EMPTY_SOURCE;
const rows = rowsForDataBinding(spec, data);
if (!rows) return EMPTY_SOURCE; // url / generator / library miss / CSV-string values
const profile = profileData(rows, 0);
return { name: null, columnTypes: profile.columnTypes, columnStats: profile.columnStats };
}
// Source columns memoized by (text, enclosing view). Keyed by the binding's anchor
// path so the inlay provider's many per-line queries within one view profile once.
// A re-import that changes a dataset's columns under unchanged text serves stale
// hints until the next keystroke — harmless, since hints are additive.
let sourceCache: { text: string; byAnchor: Map<string, SourceColumns> } | null = null;
function sourceColumns(
text: string,
spec: unknown,
anchorKey: string,
data: unknown,
): SourceColumns {
if (!sourceCache || sourceCache.text !== text) sourceCache = { text, byAnchor: new Map() };
const hit = sourceCache.byAnchor.get(anchorKey);
if (hit) return hit;
const result = resolveSource(spec, data);
sourceCache.byAnchor.set(anchorKey, result);
return result;
}
/**
* The shared resolve step: the source columns of the binding in scope at `offset`,
* with the parsed spec and cursor path so callers can layer derived fields on top
* without re-parsing or re-walking.
*/
function resolveAt(
text: string,
offset: number,
): {
source: SourceColumns;
spec: unknown;
path: ReadonlyArray<string | number>;
} {
const spec = parsedSpec(text);
const path = pathAtOffset(text, offset);
const binding = dataBindingAtPath(spec, path);
const source = binding
? sourceColumns(text, spec, JSON.stringify(binding.anchorPath), binding.data)
: EMPTY_SOURCE;
return { source, spec, path };
}
/** Source columns of the binding in scope at `offset` — no derived-field merge. */
function sourceAt(text: string, offset: number): SourceColumns {
return text.trim() === '' ? EMPTY_SOURCE : resolveAt(text, offset).source;
}
/** The data context at `offset` in the editor's draft `text`. */
export function dataInfoAt(text: string, offset: number): DataInfo {
if (text.trim() === '') return EMPTY;
const { source, spec, path } = resolveAt(text, offset);
const fields: FieldHint[] = source.columnTypes.map((c) => ({
name: c.name,
type: c.type,
derived: false,
}));
const seen = new Set(fields.map((f) => f.name));
for (const derived of derivedFieldNames(spec)) {
for (const derived of derivedFieldNamesAtPath(spec, path)) {
if (!seen.has(derived)) {
fields.push({ name: derived, type: null, derived: true });
seen.add(derived);
}
}
return { name, columnTypes, columnStats, fields };
return {
name: source.name,
columnTypes: source.columnTypes,
columnStats: source.columnStats,
fields,
};
}
// Keyed by draft text alone: this assumes a bound dataset's profile is stable
// for a given draft. A re-import that changes columns under unchanged text serves
// stale hints until the next keystroke — harmless, since hints are additive.
let cache: { text: string; info: DataInfo } | null = null;
/** The active draft's data context, memoized by draft text. */
export function dataInfo(): DataInfo {
const text = useSnippetStore.getState().draftText;
if (text.trim() === '') return EMPTY;
if (cache && cache.text === text) return cache.info;
const info = compute(text);
cache = { text, info };
return info;
/** Source columns + derived fields available at `offset` (for completion). */
export function availableFieldsAt(text: string, offset: number): ReadonlyArray<FieldHint> {
return dataInfoAt(text, offset).fields;
}
/** Source columns + inferred types available to the draft (for facet/repeat defaults). */
export function boundColumns(): ReadonlyArray<{ name: string; type: ColumnType }> {
return dataInfo().columnTypes;
/** Source columns + inferred types in scope at `offset` (for facet/repeat defaults). */
export function boundColumnsAt(
text: string,
offset: number,
): ReadonlyArray<{ name: string; type: ColumnType }> {
return sourceAt(text, offset).columnTypes;
}
/** Source columns plus the spec's derived fields (for completion/inlay). */
export function availableFields(): ReadonlyArray<FieldHint> {
return dataInfo().fields;
/** The inferred type of a source field `name` at `offset`, or null (for inlay). */
export function fieldTypeAt(text: string, offset: number, name: string): ColumnType | null {
return sourceAt(text, offset).columnTypes.find((c) => c.name === name)?.type ?? null;
}
+21 -9
View File
@@ -27,7 +27,13 @@ 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';
import {
availableFieldsAt,
dataInfoAt,
fieldTypeAt,
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']);
@@ -65,9 +71,11 @@ export function configureSpecDatasetHints(): void {
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));
const text = model.getValue();
const offset = model.getOffsetAt(position);
const key = valueKeyAtOffset(text, offset);
if (key === null || !FIELD_KEYS.has(key)) return { suggestions: [] };
const fields = availableFields();
const fields = availableFieldsAt(text, offset);
if (fields.length === 0) return { suggestions: [] };
const word = model.getWordUntilPosition(position);
@@ -92,7 +100,7 @@ export function configureSpecDatasetHints(): void {
});
// 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
// the draft buffer (dataInfoAt), 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', {
@@ -119,10 +127,10 @@ export function configureSpecDatasetHints(): void {
}
}
// Over a field name: its type + stats.
// Over a field name: its type + stats, resolved at this view's data binding.
const word = model.getWordAtPosition(position);
if (word) {
const info = dataInfo();
const info = dataInfoAt(text, offset);
const hint = info.fields.find((f) => f.name === word.word);
if (hint) {
return {
@@ -143,15 +151,19 @@ export function configureSpecDatasetHints(): void {
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 text = model.getValue();
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]);
// Resolve the type at this field's own data binding — views in a
// composition can bind different datasets. The offset points inside the
// field's value so the path resolves to its enclosing view.
const valueCol = match.index + match[0].length - match[1].length;
const offset = model.getOffsetAt({ lineNumber: line, column: valueCol });
const type = fieldTypeAt(text, offset, match[1]);
if (!type) continue; // unknown or derived (no type to annotate)
hints.push({
position: { lineNumber: line, column: match.index + match[0].length + 1 },
+38 -22
View File
@@ -43,7 +43,7 @@ import {
} from '@core/spec-transforms';
import { notify } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { boundColumns } from './active-dataset';
import { boundColumnsAt } from './active-dataset';
import { parseSpecObject } from './spec-config-actions';
/** The composition operators the wrap actions offer. */
@@ -53,6 +53,8 @@ type WrapKind = 'layer' | 'hconcat' | 'vconcat' | 'facet' | 'repeat';
interface Scope {
range: monaco.Range;
text: string;
/** Whole-document offset of the focused view, for resolving its data binding. */
offset: number;
/** Column the slice starts at (0-based), so re-indented output stays aligned. */
baseCol: number;
}
@@ -63,29 +65,31 @@ const isEmptyRange = (r: monaco.IRange): boolean =>
/** The whole document, as a scope. */
function wholeDocument(model: monaco.editor.ITextModel): Scope {
const range = model.getFullModelRange();
return { range, text: model.getValue(), baseCol: 0 };
return { range, text: model.getValue(), offset: 0, baseCol: 0 };
}
/**
* The slice a transform acts on: an explicit selection if present; else the view
* the cursor sits in (core/spec-cursor); else the whole document.
* the cursor sits in (core/spec-cursor); else the whole document. `offset` is the
* cursor/selection-start position in the whole document, used to resolve the
* focused view's data binding (facet/repeat field defaults).
*/
function resolveScope(model: monaco.editor.ITextModel, range: monaco.IRange | null): Scope {
if (!range) return wholeDocument(model);
if (!isEmptyRange(range)) {
const r = monaco.Range.lift(range);
return { range: r, text: model.getValueInRange(r), baseCol: r.startColumn - 1 };
}
const offset = model.getOffsetAt({
lineNumber: range.startLineNumber,
column: range.startColumn,
});
if (!isEmptyRange(range)) {
const r = monaco.Range.lift(range);
return { range: r, text: model.getValueInRange(r), offset, baseCol: r.startColumn - 1 };
}
const node = findViewRange(model.getValue(), offset);
if (!node) return wholeDocument(model);
const start = model.getPositionAt(node.offset);
const end = model.getPositionAt(node.offset + node.length);
const r = new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column);
return { range: r, text: model.getValueInRange(r), baseCol: start.column - 1 };
return { range: r, text: model.getValueInRange(r), offset, baseCol: start.column - 1 };
}
/** Indent every line after the first by `baseCol`, so a scoped edit stays aligned. */
@@ -106,8 +110,8 @@ function formatScoped(model: monaco.editor.ITextModel, scope: Scope, next: JsonO
}
/** A categorical column to facet by (first nominal/ordinal), or a placeholder. */
function defaultFacet(): { field: string; type: string } {
const cols = boundColumns();
function defaultFacet(text: string, offset: number): { field: string; type: string } {
const cols = boundColumnsAt(text, offset);
const categorical = cols.find((c) => {
const t = defaultFieldType(c.type);
return t === 'nominal' || t === 'ordinal';
@@ -119,8 +123,12 @@ function defaultFacet(): { field: string; type: string } {
}
/** Quantitative columns to repeat over + the channel to rewire, with fallbacks. */
function defaultRepeat(spec: JsonObject): { fields: string[]; channel: string | null } {
const cols = boundColumns();
function defaultRepeat(
spec: JsonObject,
text: string,
offset: number,
): { fields: string[]; channel: string | null } {
const cols = boundColumnsAt(text, offset);
const numeric = cols
.filter((c) => defaultFieldType(c.type) === 'quantitative')
.map((c) => c.name);
@@ -130,8 +138,12 @@ function defaultRepeat(spec: JsonObject): { fields: string[]; channel: string |
return { fields: fields.length > 0 ? fields : ['field1', 'field2'], channel };
}
/** Apply a wrap of the given kind to the parsed spec. */
function buildNext(spec: JsonObject, kind: WrapKind): JsonObject {
/**
* Apply a wrap of the given kind to the parsed (scoped) spec. `text`/`offset` are
* the whole document and the focused view's position, used to resolve that view's
* data binding for the facet/repeat field defaults.
*/
function buildNext(spec: JsonObject, kind: WrapKind, text: string, offset: number): JsonObject {
switch (kind) {
case 'layer':
return wrapInLayer(spec);
@@ -140,11 +152,11 @@ function buildNext(spec: JsonObject, kind: WrapKind): JsonObject {
case 'vconcat':
return wrapInConcat(spec, 'v');
case 'facet': {
const { field, type } = defaultFacet();
const { field, type } = defaultFacet(text, offset);
return wrapInFacet(spec, field, type);
}
case 'repeat': {
const { fields, channel } = defaultRepeat(spec);
const { fields, channel } = defaultRepeat(spec, text, offset);
return wrapInRepeat(spec, fields, channel);
}
}
@@ -190,7 +202,8 @@ export function runWrap(editor: monaco.editor.IStandaloneCodeEditor, kind: WrapK
const target = resolveTarget(editor);
if (!target) return;
const { model, scope, spec } = target;
writeBack(editor, scope.range, formatScoped(model, scope, buildNext(spec, kind)));
const next = buildNext(spec, kind, model.getValue(), scope.offset);
writeBack(editor, scope.range, formatScoped(model, scope, next));
notify({
kind: 'success',
title: 'View wrapped',
@@ -381,13 +394,16 @@ export function configureSpecTransformCodeActions(): void {
return empty;
}
if (!isJsonObject(spec)) return empty;
const viewSpec = spec;
const fullText = model.getValue();
const build = (kind: WrapKind) => buildNext(viewSpec, kind, fullText, scope.offset);
const actions: monaco.languages.CodeAction[] = [
editAction(model, scope, 'Wrap view in a layer', buildNext(spec, 'layer')),
editAction(model, scope, 'Wrap view in horizontal concat', buildNext(spec, 'hconcat')),
editAction(model, scope, 'Wrap view in vertical concat', buildNext(spec, 'vconcat')),
editAction(model, scope, 'Wrap view in a facet', buildNext(spec, 'facet')),
editAction(model, scope, 'Wrap view in a repeat', buildNext(spec, 'repeat')),
editAction(model, scope, 'Wrap view in a layer', build('layer')),
editAction(model, scope, 'Wrap view in horizontal concat', build('hconcat')),
editAction(model, scope, 'Wrap view in vertical concat', build('vconcat')),
editAction(model, scope, 'Wrap view in a facet', build('facet')),
editAction(model, scope, 'Wrap view in a repeat', build('repeat')),
];
const collapsed = unwrapSingleton(spec);
if (collapsed)