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
+3 -3
View File
@@ -75,9 +75,9 @@ This is the at-a-glance list; keep it in sync with them.
**Next (flagged for build):**
- **Multi-view data model** ([`multi-view-data-model-scope.md`](exploration/multi-view-data-model-scope.md)) —
durable composition support across the data-facing features. M1 (Vega-Lite-fidelity
reference classifier, `core/spec-data`) is done; M2M4 extend the editor data context,
Extract, and the data inspector to be view-scoped.
durable composition support across the data-facing features. Done: the Vega-Lite-fidelity
reference classifier (`core/spec-data`) and the view-scoped editor data context. Remaining:
per-view data inspection (`DataInspector` view selector), then view-scoped Extract.
- **Chart Builder · 3B starter examples** ([`chart-builder-enhancement-scope.md`](exploration/chart-builder-enhancement-scope.md) §3) —
a small set of curated starters, one per covered FT intent. Reshaped by 3C: a
builder-openable starter must reference a dataset, so it ships paired sample datasets (or is
+23 -12
View File
@@ -286,16 +286,22 @@ thin app-layer services.
- `spec-transforms` — wrap a view in `layer`/`hconcat`/`vconcat`/`facet`/`repeat`; collapse
a single-child composition (`unwrapSingleton`). Object-in/object-out.
- `spec-cursor` — `findViewRange` (cursor offset → the enclosing view's byte range) and
`valueKeyAtOffset`/`stringValueAtOffset` (the JSON context at the cursor), over `jsonc-parser`.
- `spec-fields` — field names a spec's own transforms introduce (their `as`).
- `spec-inline-data` — the inline rows a spec carries (`data.values`, a `datasets` entry).
- `spec-data` — the Vega-Lite data model: classify a `data` block (`classifyData`, mirroring
`isNamedData`), the library reference name (`libraryRefName`), and the data binding in scope
at a cursor path (`dataBindingAtPath`, honoring a view's data inheritance from its parent).
- `spec-cursor` — `findViewRange` (cursor offset → the enclosing view's byte range),
`pathAtOffset` (cursor → JSON path), and `valueKeyAtOffset`/`stringValueAtOffset` (the JSON
context at the cursor), over `jsonc-parser`.
- `spec-fields` — field names a spec's transforms introduce (their `as`), scoped to a
cursor's ancestor chain (`derivedFieldNamesAtPath`).
- `spec-inline-data` — the rows a specific `data` binding carries for profiling
(`rowsForDataBinding`: inline `values`, or a self-defined `datasets` entry).
- `spec-insert` — composition arrays + appending a view to one.
**Services (app, store-aware via `getState`):** `spec-transform-actions` (the
wrap/simplify/add-view operations and their surfaces), `spec-dataset-hints` (completion,
hover, inlay providers), `active-dataset` (`dataInfo()` — the columns/types/stats + derived
fields the draft sees). `SpecEditor` does the wiring.
hover, inlay providers), `active-dataset` (`dataInfoAt(text, offset)` — the columns/types/stats
plus derived fields the draft sees at the cursor). `SpecEditor` does the wiring.
Decision rules:
@@ -312,12 +318,17 @@ Decision rules:
- **One edit path.** The lightbulb returns a `WorkspaceEdit` (no editor handle); the toolbar
and palette use `executeEdits`. Both build the replacement through the same
serialize-and-reindent step, bracketed by `pushUndoStop`, so ⌘Z restores the prior text.
- **Field source for hints.** `dataInfo()` reads columns from the named library dataset the
draft references, else profiles the spec's **inline data on the fly** (`spec-inline-data` +
`core/profile`) — a "ghost dataset" with nothing stored — and adds the spec's derived
fields. Memoized by draft text, since providers fire per keystroke and per scroll. Out of
scope: data-dependent derived columns (`pivot`/`lookup` output) and `url`/CSV-string inline
data, which need the pipeline run or format-aware parsing.
- **Field source for hints (view-scoped).** `dataInfoAt(text, offset)` resolves the data
binding of the cursor's **nearest enclosing view** (`dataBindingAtPath` — a child inherits a
parent's data unless it declares its own), then its columns: a named **library dataset**
(matched case-insensitively, like the renderer), else the binding's **inline rows** profiled
on the fly (`rowsForDataBinding` + `core/profile`) — a "ghost dataset" with nothing stored.
Derived fields come from that view's and its ancestors' transforms only
(`derivedFieldNamesAtPath`), so a sibling view's `calculate` does not leak in. Profiling is
memoized per (draft text, enclosing view), so the inlay provider's many per-line queries
profile once. A composed spec whose views bind different datasets therefore gets the right
columns per view. Out of scope: data-dependent derived columns (`pivot`/`lookup` output) and
`url`/CSV-string inline data, which need the pipeline run or format-aware parsing.
- **No unknown-field diagnostic.** Hints are additive and forgiving, so over- or
under-listing costs nothing; a "field not in data" squiggle would false-positive on every
derived or data-dependent field, so there is deliberately none.
+13 -10
View File
@@ -66,18 +66,21 @@ collection (`spec-fields`), config baking (`spec-config`), standalone export
- **M1 — data-model foundation** ✅ — `core/spec-data` classifier mirroring
`isNamedData`; `spec-refs` + `rendering` routed through it. Closes clash 1.
- **M2 — view-scoped editor context** — pure `dataContextAtPath(spec, path)`:
climb the cursor's JSON path to the nearest enclosing `data` (honoring
Vega-Lite's parent→child data inheritance), classify it, and collect
ancestor-chain derived fields. Rework `active-dataset` to be cursor-scoped and
thread the offset through the three Monaco providers and the facet/repeat
defaults. Resolve columns for every form: library ref, inline `values`,
named-inline, self-defined `datasets`, url (no static rows), generator (none).
- **M2 — view-scoped editor context** `dataBindingAtPath` (climb the cursor's
JSON path to the nearest enclosing `data`, honoring Vega-Lite's parent→child
inheritance) + `derivedFieldNamesAtPath` (ancestor-chain `as` outputs).
`active-dataset` is cursor-scoped (`dataInfoAt(text, offset)`), resolving columns
for every form (library ref case-insensitive, inline, named-inline, self-defined
`datasets`, url/generator → none); the three Monaco providers and the
facet/repeat defaults pass the cursor offset.
- **M4 — multi-view inspection** (pulled ahead of M3) — group the dataflow's
datasets into per-view input/resolved pairs (`result-data`) and add a view
selector to `DataInspector` (new interactive widget → `/council`). The data
inspector executes the live pipeline, so it is the place to review post-transform
rows (melt/fold/pivot/aggregate); today it surfaces one heuristic input/resolved
pair across the whole composition, with no per-view choice.
- **M3 — view-scoped extract** — seed Extract from the focused view's inline data
(reusing the cursor-scope machinery) and rewrite that view's `data`.
- **M4 — multi-view inspection** — group the dataflow's datasets into per-view
input/resolved pairs (`result-data`) and add a view selector to `DataInspector`
(new interactive widget → `/council`).
Delivery is incremental, one milestone per commit, verified against real behavior.
The consolidated data-model contract write-up into `docs/architecture` (05/08)
+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)
+10
View File
@@ -70,6 +70,16 @@ export function findViewRange(text: string, offset: number): NodeRange | null {
return null;
}
/**
* The JSON path (object keys and array indices, root → cursor) of the node at
* `offset`. Drives the view-scoped data context: the path is walked to find the
* nearest enclosing view's data binding and ancestor transforms
* (`core/spec-data`, `core/spec-fields`). Error-tolerant, so it works mid-edit.
*/
export function pathAtOffset(text: string, offset: number): (string | number)[] {
return [...getLocation(text, offset).path];
}
/**
* The property key whose *value* the cursor sits in, or null when the cursor is
* on a key, at the top level, or otherwise not in a value. For an array element
+48 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'vitest';
import { classifyData, libraryRefName, selfDefinedNames } from './spec-data';
import { classifyData, dataBindingAtPath, libraryRefName, selfDefinedNames } from './spec-data';
describe('classifyData', () => {
test('classifies the four Vega-Lite data shapes', () => {
@@ -61,3 +61,50 @@ describe('libraryRefName', () => {
expect(libraryRefName({ values: [{ a: 1 }] }, none)).toBeNull();
});
});
describe('dataBindingAtPath', () => {
test('finds the top-level data when no view overrides it', () => {
const spec = { data: { name: 'Sales' }, layer: [{ mark: 'bar', encoding: { x: {} } }] };
// Cursor deep inside the first layer's encoding — inherits the top-level data.
const binding = dataBindingAtPath(spec, ['layer', 0, 'encoding', 'x']);
expect(binding).toEqual({ data: { name: 'Sales' }, anchorPath: [] });
});
test('the nearest enclosing view wins (Vega-Lite data inheritance)', () => {
const spec = {
data: { name: 'Top' },
layer: [{ data: { name: 'Inner' }, encoding: { y: { field: 'a' } } }],
};
const binding = dataBindingAtPath(spec, ['layer', 0, 'encoding', 'y', 'field']);
expect(binding).toEqual({ data: { name: 'Inner' }, anchorPath: ['layer', 0] });
});
test('a sibling view does not leak its data', () => {
const spec = {
layer: [
{ data: { name: 'A' }, mark: 'bar' },
{ data: { name: 'B' }, mark: 'line' },
],
};
expect(dataBindingAtPath(spec, ['layer', 1, 'mark'])).toEqual({
data: { name: 'B' },
anchorPath: ['layer', 1],
});
});
test('resolves through a facet/repeat child spec', () => {
const spec = { facet: { field: 'g' }, spec: { data: { name: 'Child' }, mark: 'point' } };
expect(dataBindingAtPath(spec, ['spec', 'mark'])).toEqual({
data: { name: 'Child' },
anchorPath: ['spec'],
});
});
test('returns null when no ancestor on the path declares data', () => {
expect(dataBindingAtPath({ mark: 'bar' }, ['mark'])).toBeNull();
});
test('descends defensively past a non-object on the path', () => {
expect(dataBindingAtPath({ mark: 'bar' }, ['mark', 'type', 'nope'])).toBeNull();
});
});
+36
View File
@@ -72,3 +72,39 @@ export function libraryRefName(data: unknown, selfDefined: ReadonlySet<string>):
const { name } = data as { name: string };
return selfDefined.has(name) ? null : name;
}
/** A `data` block in scope at a cursor, with the path prefix where it was found. */
export interface DataBinding {
/** The raw `data` value (classify it with `classifyData` / `libraryRefName`). */
data: unknown;
/** The path prefix of the view that declares this `data` — `[]` for the root. */
anchorPath: ReadonlyArray<string | number>;
}
/**
* The data binding in scope at a JSON `path` into the spec — the `data` block of
* the *nearest enclosing view* (docs/architecture/08 → editor augmentation). This
* implements Vega-Lite's data inheritance: a layer/concat/facet/repeat child
* inherits its parent's data unless it declares its own, so the binding is the
* `data` of the deepest object along the path that has one. Returns `null` when no
* ancestor on the path declares `data`.
*
* `path` is a jsonc-parser location path (object keys and array indices, root to
* cursor). The walk descends it defensively, stopping at any non-object/array.
*/
export function dataBindingAtPath(
spec: unknown,
path: ReadonlyArray<string | number>,
): DataBinding | null {
let node: unknown = spec;
let binding: DataBinding | null = null;
if (isJsonObject(node) && 'data' in node) binding = { data: node.data, anchorPath: [] };
for (let i = 0; i < path.length; i++) {
if (node === null || typeof node !== 'object') break;
node = (node as Record<string | number, unknown>)[path[i]];
if (isJsonObject(node) && 'data' in node) {
binding = { data: node.data, anchorPath: path.slice(0, i + 1) };
}
}
return binding;
}
+41 -23
View File
@@ -1,52 +1,70 @@
import { describe, expect, it } from 'vitest';
import { derivedFieldNames } from './spec-fields';
import { derivedFieldNamesAtPath } from './spec-fields';
describe('derivedFieldNamesAtPath', () => {
const spec = {
transform: [{ calculate: 'x', as: 'shared' }],
layer: [
{ transform: [{ calculate: 'y', as: 'inner' }], mark: 'bar' },
{ transform: [{ calculate: 'z', as: 'sibling' }], mark: 'line' },
],
};
describe('derivedFieldNames', () => {
it('collects calculate / timeUnit / bin as-names', () => {
const spec = {
const s = {
transform: [
{ calculate: 'datum.a + 1', as: 'plusOne' },
{ timeUnit: 'month', field: 'date', as: 'mo' },
{ bin: true, field: 'x', as: ['x_start', 'x_end'] },
],
};
expect(derivedFieldNames(spec).sort()).toEqual(['mo', 'plusOne', 'x_end', 'x_start']);
expect(derivedFieldNamesAtPath(s, []).sort()).toEqual(['mo', 'plusOne', 'x_end', 'x_start']);
});
it('reaches into op-list transforms (aggregate/window/joinaggregate)', () => {
const spec = {
const s = {
transform: [
{ aggregate: [{ op: 'mean', field: 'v', as: 'meanV' }], groupby: ['g'] },
{ window: [{ op: 'rank', as: 'rk' }] },
{ joinaggregate: [{ op: 'sum', field: 'v', as: 'total' }] },
],
};
expect(derivedFieldNames(spec).sort()).toEqual(['meanV', 'rk', 'total']);
expect(derivedFieldNamesAtPath(s, []).sort()).toEqual(['meanV', 'rk', 'total']);
});
it('defaults fold output to key/value when as is omitted', () => {
expect(derivedFieldNames({ transform: [{ fold: ['a', 'b'] }] }).sort()).toEqual([
expect(derivedFieldNamesAtPath({ transform: [{ fold: ['a', 'b'] }] }, []).sort()).toEqual([
'key',
'value',
]);
expect(derivedFieldNames({ transform: [{ fold: ['a'], as: ['k', 'v'] }] }).sort()).toEqual([
'k',
'v',
]);
});
it('recurses into per-view transforms and de-duplicates', () => {
const spec = {
transform: [{ calculate: 'x', as: 'shared' }],
layer: [
{ transform: [{ calculate: 'y', as: 'inner' }], mark: 'bar' },
{ transform: [{ calculate: 'z', as: 'shared' }], mark: 'line' },
],
};
expect(derivedFieldNames(spec).sort()).toEqual(['inner', 'shared']);
expect(
derivedFieldNamesAtPath({ transform: [{ fold: ['a'], as: ['k', 'v'] }] }, []).sort(),
).toEqual(['k', 'v']);
});
it('returns nothing for a spec with no transforms', () => {
expect(derivedFieldNames({ mark: 'bar', encoding: { x: { field: 'a' } } })).toEqual([]);
expect(derivedFieldNamesAtPath({ mark: 'bar', encoding: { x: { field: 'a' } } }, [])).toEqual(
[],
);
});
it('includes ancestor transforms but not sibling-view transforms', () => {
// Inside layer 0: sees its own `inner` and the inherited top-level `shared`,
// but never layer 1's `sibling`.
expect(derivedFieldNamesAtPath(spec, ['layer', 0, 'mark']).sort()).toEqual(['inner', 'shared']);
expect(derivedFieldNamesAtPath(spec, ['layer', 1, 'mark']).sort()).toEqual([
'shared',
'sibling',
]);
});
it('at the root sees only the top-level transforms', () => {
expect(derivedFieldNamesAtPath(spec, [])).toEqual(['shared']);
});
it('descends defensively past a non-object on the path', () => {
expect(derivedFieldNamesAtPath(spec, ['transform', 0, 'calculate', 'nope'])).toEqual([
'shared',
]);
});
});
+31 -19
View File
@@ -8,8 +8,9 @@
* offered too. This collects the statically-named ones: every transform `as`
* (`calculate`, `timeUnit`, `bin`, `stack`, `fold`, `flatten`, `regression`, …)
* and the nested `as` of the op-list transforms (`aggregate`, `window`,
* `joinaggregate`). Transforms can sit at the top level or inside any view, so
* the walk recurses the whole spec.
* `joinaggregate`), scoped to the transforms in effect at a cursor — the enclosing
* view's and its ancestors' (Vega-Lite applies a parent view's transforms upstream
* of its children; a sibling view's do not apply).
*
* Out of scope by design: **data-dependent** derived columns — `pivot`'s
* one-column-per-value output and `lookup`'s imported fields — which only exist
@@ -44,25 +45,36 @@ function collectFromTransform(transform: unknown, names: Set<string>): void {
}
}
/** Add every field name a node's own `transform` array introduces. */
function collectNodeTransforms(node: unknown, names: Set<string>): void {
if (
node !== null &&
typeof node === 'object' &&
Array.isArray((node as { transform?: unknown }).transform)
) {
for (const t of (node as { transform: unknown[] }).transform) collectFromTransform(t, names);
}
}
/**
* Every field name the spec derives via its transforms, de-duplicated. Accepts a
* parsed spec object (callers parse the draft once).
* The fields derived by transforms *in scope* at a JSON `path` into the spec — the
* `as` outputs of every transform on the cursor's enclosing view and its ancestors
* (docs/architecture/08 → editor augmentation). Vega-Lite applies a parent view's
* transforms upstream of its children, so a field a parent's `calculate` creates is
* visible to a nested view; sibling views' transforms are not. The walk descends
* `path` (a jsonc-parser location path) defensively, collecting along the way.
*/
export function derivedFieldNames(spec: unknown): string[] {
export function derivedFieldNamesAtPath(
spec: unknown,
path: ReadonlyArray<string | number>,
): string[] {
const names = new Set<string>();
const walk = (node: unknown): void => {
if (Array.isArray(node)) {
for (const item of node) walk(item);
return;
}
if (node !== null && typeof node === 'object') {
const obj = node as Record<string, unknown>;
if (Array.isArray(obj.transform)) {
for (const t of obj.transform) collectFromTransform(t, names);
}
for (const key of Object.keys(obj)) walk(obj[key]);
}
};
walk(spec);
let node: unknown = spec;
collectNodeTransforms(node, names);
for (const key of path) {
if (node === null || typeof node !== 'object') break;
node = (node as Record<string | number, unknown>)[key];
collectNodeTransforms(node, names);
}
return [...names];
}
+15 -26
View File
@@ -1,34 +1,23 @@
import { describe, expect, it } from 'vitest';
import { inlineDataRows } from './spec-inline-data';
import { rowsForDataBinding } from './spec-inline-data';
describe('inlineDataRows', () => {
it('reads top-level data.values', () => {
const rows = inlineDataRows({ data: { values: [{ a: 1 }, { a: 2 }] }, mark: 'bar' });
expect(rows).toEqual([{ a: 1 }, { a: 2 }]);
describe('rowsForDataBinding', () => {
it('reads inline values from the given binding', () => {
const spec = { data: { values: [{ a: 1 }, { a: 2 }] } };
expect(rowsForDataBinding(spec, spec.data)).toEqual([{ a: 1 }, { a: 2 }]);
});
it('falls back to a nested data.values when there is no top-level one', () => {
const spec = { layer: [{ data: { values: [{ b: 1 }] }, mark: 'bar' }] };
expect(inlineDataRows(spec)).toEqual([{ b: 1 }]);
it('resolves a self-defined named binding against top-level datasets', () => {
const spec = { datasets: { ds: [{ c: 1 }, { c: 2 }] }, data: { name: 'ds' } };
expect(rowsForDataBinding(spec, { name: 'ds' })).toEqual([{ c: 1 }, { c: 2 }]);
});
it('prefers top-level over nested', () => {
const spec = {
data: { values: [{ top: 1 }] },
layer: [{ data: { values: [{ nested: 1 }] } }],
};
expect(inlineDataRows(spec)).toEqual([{ top: 1 }]);
});
it('reads a top-level datasets entry when no data.values exist', () => {
const spec = { datasets: { ds: [{ c: 1 }] }, data: { name: 'ds' } };
expect(inlineDataRows(spec)).toEqual([{ c: 1 }]);
});
it('returns null for URL data, empty values, or non-object rows', () => {
expect(inlineDataRows({ data: { url: 'x.csv' } })).toBeNull();
expect(inlineDataRows({ data: { values: [] } })).toBeNull();
expect(inlineDataRows({ data: { values: [1, 2, 3] } })).toBeNull();
expect(inlineDataRows({ mark: 'bar' })).toBeNull();
it('returns null for a library reference, url, generator, or CSV-string values', () => {
const spec = { datasets: { ds: [{ c: 1 }] } };
expect(rowsForDataBinding(spec, { name: 'LibraryRef' })).toBeNull(); // not self-defined
expect(rowsForDataBinding(spec, { url: 'x.csv' })).toBeNull();
expect(rowsForDataBinding(spec, { sequence: { start: 0, stop: 5 } })).toBeNull();
expect(rowsForDataBinding(spec, { values: 'a,b\n1,2' })).toBeNull();
expect(rowsForDataBinding({ mark: 'bar' }, { values: [] })).toBeNull(); // empty rows
});
});
+25 -51
View File
@@ -1,20 +1,18 @@
/**
* Inline data carried by a spec (docs/architecture/08 → editor augmentation).
*
* Portable core. When a spec has no named library dataset, its columns still
* exist — inline, in `data.values` or a top-level `datasets` entry. This pulls
* those rows out so the editor can profile them (core/profile) and offer the same
* field hints a library-bound spec gets — a "ghost dataset" derived from the spec
* itself, with nothing stored.
* Portable core. When a view binds no named library dataset, its columns still
* exist — inline, in `data.values` or a self-defined top-level `datasets` entry.
* This pulls those rows out so the editor can profile them (core/profile) and offer
* the same field hints a library-bound view gets — a "ghost dataset" derived from
* the spec itself, with nothing stored.
*
* Resolution order mirrors what the renderer sees first: the nearest `data.values`
* (top level, then any nested view — pruning the `data` payload from the walk like
* spec-refs does), then a top-level `datasets` entry. URL data has no rows to read
* statically, and `values` given as a CSV/TSV string needs format-aware parsing —
* both out of scope here.
* URL data has no rows to read statically, and `values` given as a CSV/TSV string
* needs format-aware parsing — both out of scope here (they yield null).
*/
import { isJsonObject } from './spec-config';
import { classifyData, selfDefinedNames } from './spec-data';
/** An array of row objects, or null when the value is not tabular inline data. */
function asRows(value: unknown): Record<string, unknown>[] | null {
@@ -24,47 +22,23 @@ function asRows(value: unknown): Record<string, unknown>[] | null {
return null;
}
/** Rows from a `data` object's `values`, or null. */
function rowsFromData(data: unknown): Record<string, unknown>[] | null {
return isJsonObject(data) ? asRows(data.values) : null;
}
/** The first `data.values` reachable from `node`, top level before nested. */
function firstDataValues(node: unknown): Record<string, unknown>[] | null {
if (Array.isArray(node)) {
for (const item of node) {
const rows = firstDataValues(item);
if (rows) return rows;
}
return null;
}
if (isJsonObject(node)) {
const here = rowsFromData(node.data);
if (here) return here;
for (const key of Object.keys(node)) {
if (key === 'data') continue; // its `values` are payload, already taken above
const rows = firstDataValues(node[key]);
if (rows) return rows;
}
}
return null;
}
/** The first non-empty table among the spec's top-level `datasets`, or null. */
function firstNamedDataset(spec: Record<string, unknown>): Record<string, unknown>[] | null {
if (!isJsonObject(spec.datasets)) return null;
for (const value of Object.values(spec.datasets)) {
const rows = asRows(value);
if (rows) return rows;
}
return null;
}
/**
* The spec's inline data rows — `data.values` (top level or nested), else a
* top-level `datasets` entry — or null when the spec carries none.
* The tabular rows a *specific* `data` binding provides for static profiling, or
* null when it has none readable here: inline `data.values` (a JSON array of row
* objects), or a self-defined `{ name }` resolved against the spec's top-level
* `datasets`. A library `{ name }` reference (resolved from the dataset store), a
* `url`, a generator, or a CSV/TSV string `values` payload yields null — those
* carry no statically-readable JSON rows. Used by the view-scoped editor hints to
* profile the data bound at the cursor (`dataBindingAtPath` → this).
*/
export function inlineDataRows(spec: unknown): Record<string, unknown>[] | null {
if (!isJsonObject(spec)) return null;
return firstDataValues(spec) ?? firstNamedDataset(spec);
export function rowsForDataBinding(spec: unknown, data: unknown): Record<string, unknown>[] | null {
const kind = classifyData(data);
if (kind === 'inline') return asRows((data as { values: unknown }).values);
if (kind === 'named') {
const name = (data as { name: string }).name;
if (selfDefinedNames(spec).has(name) && isJsonObject(spec) && isJsonObject(spec.datasets)) {
return asRows((spec.datasets as Record<string, unknown>)[name]);
}
}
return null;
}