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):** **Next (flagged for build):**
- **Multi-view data model** ([`multi-view-data-model-scope.md`](exploration/multi-view-data-model-scope.md)) — - **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 durable composition support across the data-facing features. Done: the Vega-Lite-fidelity
reference classifier, `core/spec-data`) is done; M2M4 extend the editor data context, reference classifier (`core/spec-data`) and the view-scoped editor data context. Remaining:
Extract, and the data inspector to be view-scoped. 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) — - **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 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 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 - `spec-transforms` — wrap a view in `layer`/`hconcat`/`vconcat`/`facet`/`repeat`; collapse
a single-child composition (`unwrapSingleton`). Object-in/object-out. a single-child composition (`unwrapSingleton`). Object-in/object-out.
- `spec-cursor` — `findViewRange` (cursor offset → the enclosing view's byte range) and - `spec-data` — the Vega-Lite data model: classify a `data` block (`classifyData`, mirroring
`valueKeyAtOffset`/`stringValueAtOffset` (the JSON context at the cursor), over `jsonc-parser`. `isNamedData`), the library reference name (`libraryRefName`), and the data binding in scope
- `spec-fields` — field names a spec's own transforms introduce (their `as`). at a cursor path (`dataBindingAtPath`, honoring a view's data inheritance from its parent).
- `spec-inline-data` — the inline rows a spec carries (`data.values`, a `datasets` entry). - `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. - `spec-insert` — composition arrays + appending a view to one.
**Services (app, store-aware via `getState`):** `spec-transform-actions` (the **Services (app, store-aware via `getState`):** `spec-transform-actions` (the
wrap/simplify/add-view operations and their surfaces), `spec-dataset-hints` (completion, wrap/simplify/add-view operations and their surfaces), `spec-dataset-hints` (completion,
hover, inlay providers), `active-dataset` (`dataInfo()` — the columns/types/stats + derived hover, inlay providers), `active-dataset` (`dataInfoAt(text, offset)` — the columns/types/stats
fields the draft sees). `SpecEditor` does the wiring. plus derived fields the draft sees at the cursor). `SpecEditor` does the wiring.
Decision rules: Decision rules:
@@ -312,12 +318,17 @@ Decision rules:
- **One edit path.** The lightbulb returns a `WorkspaceEdit` (no editor handle); the toolbar - **One edit path.** The lightbulb returns a `WorkspaceEdit` (no editor handle); the toolbar
and palette use `executeEdits`. Both build the replacement through the same and palette use `executeEdits`. Both build the replacement through the same
serialize-and-reindent step, bracketed by `pushUndoStop`, so ⌘Z restores the prior text. 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 - **Field source for hints (view-scoped).** `dataInfoAt(text, offset)` resolves the data
draft references, else profiles the spec's **inline data on the fly** (`spec-inline-data` + binding of the cursor's **nearest enclosing view** (`dataBindingAtPath` — a child inherits a
`core/profile`) — a "ghost dataset" with nothing stored — and adds the spec's derived parent's data unless it declares its own), then its columns: a named **library dataset**
fields. Memoized by draft text, since providers fire per keystroke and per scroll. Out of (matched case-insensitively, like the renderer), else the binding's **inline rows** profiled
scope: data-dependent derived columns (`pivot`/`lookup` output) and `url`/CSV-string inline on the fly (`rowsForDataBinding` + `core/profile`) — a "ghost dataset" with nothing stored.
data, which need the pipeline run or format-aware parsing. 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 - **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 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. 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 - **M1 — data-model foundation** ✅ — `core/spec-data` classifier mirroring
`isNamedData`; `spec-refs` + `rendering` routed through it. Closes clash 1. `isNamedData`; `spec-refs` + `rendering` routed through it. Closes clash 1.
- **M2 — view-scoped editor context** — pure `dataContextAtPath(spec, path)`: - **M2 — view-scoped editor context** `dataBindingAtPath` (climb the cursor's
climb the cursor's JSON path to the nearest enclosing `data` (honoring JSON path to the nearest enclosing `data`, honoring Vega-Lite's parent→child
Vega-Lite's parent→child data inheritance), classify it, and collect inheritance) + `derivedFieldNamesAtPath` (ancestor-chain `as` outputs).
ancestor-chain derived fields. Rework `active-dataset` to be cursor-scoped and `active-dataset` is cursor-scoped (`dataInfoAt(text, offset)`), resolving columns
thread the offset through the three Monaco providers and the facet/repeat for every form (library ref case-insensitive, inline, named-inline, self-defined
defaults. Resolve columns for every form: library ref, inline `values`, `datasets`, url/generator → none); the three Monaco providers and the
named-inline, self-defined `datasets`, url (no static rows), generator (none). 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 - **M3 — view-scoped extract** — seed Extract from the focused view's inline data
(reusing the cursor-scope machinery) and rewrite that view's `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. Delivery is incremental, one milestone per commit, verified against real behavior.
The consolidated data-model contract write-up into `docs/architecture` (05/08) 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([]);
});
});
+129 -67
View File
@@ -1,29 +1,35 @@
/** /**
* The data context of the active draft (docs/architecture/08 → editor * The data context of the active draft **at the cursor** (docs/architecture/08 →
* augmentation): the columns/types/stats the spec can reference, and the fields * editor augmentation): the columns/types/stats the spec can reference where the
* the editor hints offer. One resolver, shared by the transform actions * cursor sits, and the fields the editor hints offer. One resolver, shared by the
* (facet/repeat field defaults) and the dataset-aware hints * transform actions (facet/repeat field defaults) and the dataset-aware hints
* (completion/hover/inlay). * (completion/hover/inlay).
* *
* Source, in order: a **named library dataset** the draft references (its stored * View-scoped: a composed spec (layer/concat/facet/repeat) can bind a different
* profile), else the spec's **inline data** profiled on the fly (the "ghost * dataset per view, and Vega-Lite inherits a parent view's data into its children.
* dataset" — `core/spec-inline-data` + `core/profile`, nothing stored). On top of * So the context is resolved at a JSON path — the data binding of the nearest
* either, the spec's **derived** fields (transform `as`, `core/spec-fields`) are * enclosing view (`dataBindingAtPath`), plus the fields derived by that view's and
* added, so a `calculate` output is offered alongside the data columns. * its ancestors' transforms (`derivedFieldNamesAtPath`).
* *
* The result is memoized by draft text: providers fire often (every keystroke * Source columns, in order: a named **library dataset** the binding references
* completes, inlay refreshes on scroll), and profiling inline data each time would * (its stored profile, matched case-insensitively like the renderer), else the
* be wasteful. App layer — reads stores via `getState`, outside React. * 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 { type ColumnStats, profileData } from '@core/profile';
import { derivedFieldNames } from '@core/spec-fields'; import { pathAtOffset } from '@core/spec-cursor';
import { inlineDataRows } from '@core/spec-inline-data'; import { dataBindingAtPath, libraryRefName, selfDefinedNames } from '@core/spec-data';
import { extractDatasetRefs } from '@core/spec-refs'; import { derivedFieldNamesAtPath } from '@core/spec-fields';
import { rowsForDataBinding } from '@core/spec-inline-data';
import type { ColumnType } from '@core/type-inference'; import type { ColumnType } from '@core/type-inference';
import { useDatasetStore } from '../stores/DatasetStore'; import { useDatasetStore } from '../stores/DatasetStore';
import { useSnippetStore } from '../stores/SnippetStore';
/** A field offered to the editor hints. */ /** A field offered to the editor hints. */
export interface FieldHint { export interface FieldHint {
@@ -34,7 +40,7 @@ export interface FieldHint {
derived: boolean; 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 { export interface DataInfo {
/** The library dataset's name, or null for inline data (the ghost dataset). */ /** The library dataset's name, or null for inline data (the ghost dataset). */
name: string | null; name: string | null;
@@ -44,7 +50,15 @@ export interface DataInfo {
fields: ReadonlyArray<FieldHint>; 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 { function safeParse(text: string): unknown {
try { try {
@@ -54,74 +68,122 @@ function safeParse(text: string): unknown {
} }
} }
/** The first library dataset the draft references, or null. */ // The parsed draft, memoized by text — both the binding walk and the derived-field
function libraryDataset(draftText: string): Dataset | null { // walk read it, and providers fire often at one stable draft.
const refs = extractDatasetRefs(draftText); let parseCache: { text: string; spec: unknown } | null = null;
if (refs.length === 0) return null; function parsedSpec(text: string): unknown {
const { datasets } = useDatasetStore.getState(); if (parseCache?.text === text) return parseCache.spec;
for (const ref of refs) { const spec = safeParse(text);
const ds = datasets.find((d) => d.name === ref); parseCache = { text, spec };
if (ds) return ds; return spec;
}
return null;
} }
function compute(draftText: string): DataInfo { /** The stored profile of the library dataset a binding references, or null. */
const spec = safeParse(draftText); 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
}
// Source columns: a named library dataset wins; otherwise profile inline data. /** Source columns for one binding: library profile, else inline rows profiled. */
let name: string | null = null; function resolveSource(spec: unknown, data: unknown): SourceColumns {
let columnTypes: ReadonlyArray<{ name: string; type: ColumnType }> = []; const refName = libraryRefName(data, selfDefinedNames(spec));
let columnStats: ReadonlyArray<ColumnStats> = []; if (refName !== null) return libraryProfile(refName) ?? EMPTY_SOURCE;
const library = libraryDataset(draftText); const rows = rowsForDataBinding(spec, data);
if (library && library.columnTypes.length > 0) { if (!rows) return EMPTY_SOURCE; // url / generator / library miss / CSV-string values
({ name, columnTypes, columnStats } = library);
} else {
const rows = inlineDataRows(spec);
if (rows) {
const profile = profileData(rows, 0); const profile = profileData(rows, 0);
columnTypes = profile.columnTypes; return { name: null, columnTypes: profile.columnTypes, columnStats: profile.columnStats };
columnStats = profile.columnStats; }
}
}
const fields: FieldHint[] = columnTypes.map((c) => ({ // 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, name: c.name,
type: c.type, type: c.type,
derived: false, derived: false,
})); }));
const seen = new Set(fields.map((f) => f.name)); 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)) { if (!seen.has(derived)) {
fields.push({ name: derived, type: null, derived: true }); fields.push({ name: derived, type: null, derived: true });
seen.add(derived); 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 /** Source columns + derived fields available at `offset` (for completion). */
// for a given draft. A re-import that changes columns under unchanged text serves export function availableFieldsAt(text: string, offset: number): ReadonlyArray<FieldHint> {
// stale hints until the next keystroke — harmless, since hints are additive. return dataInfoAt(text, offset).fields;
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 + inferred types available to the draft (for facet/repeat defaults). */ /** Source columns + inferred types in scope at `offset` (for facet/repeat defaults). */
export function boundColumns(): ReadonlyArray<{ name: string; type: ColumnType }> { export function boundColumnsAt(
return dataInfo().columnTypes; 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). */ /** The inferred type of a source field `name` at `offset`, or null (for inlay). */
export function availableFields(): ReadonlyArray<FieldHint> { export function fieldTypeAt(text: string, offset: number, name: string): ColumnType | null {
return dataInfo().fields; 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 { stringValueAtOffset, valueKeyAtOffset } from '@core/spec-cursor';
import type { ColumnType } from '@core/type-inference'; import type { ColumnType } from '@core/type-inference';
import { useSnippetStore } from '../stores/SnippetStore'; 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). */ /** Property values that reference a data field (where column names belong). */
const FIELD_KEYS = new Set(['field', 'groupby']); const FIELD_KEYS = new Set(['field', 'groupby']);
@@ -65,9 +71,11 @@ export function configureSpecDatasetHints(): void {
provideCompletionItems(model, position) { provideCompletionItems(model, position) {
// Field suggestions only edit the draft; nothing to offer on the published view. // Field suggestions only edit the draft; nothing to offer on the published view.
if (useSnippetStore.getState().editorView !== 'draft') return { suggestions: [] }; 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: [] }; if (key === null || !FIELD_KEYS.has(key)) return { suggestions: [] };
const fields = availableFields(); const fields = availableFieldsAt(text, offset);
if (fields.length === 0) return { suggestions: [] }; if (fields.length === 0) return { suggestions: [] };
const word = model.getWordUntilPosition(position); 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 // 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 // consistent with the text they are computed from. The published view is a
// read-only reference, where field hints are marginal. // read-only reference, where field hints are marginal.
monaco.languages.registerHoverProvider('json', { 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); const word = model.getWordAtPosition(position);
if (word) { if (word) {
const info = dataInfo(); const info = dataInfoAt(text, offset);
const hint = info.fields.find((f) => f.name === word.word); const hint = info.fields.find((f) => f.name === word.word);
if (hint) { if (hint) {
return { return {
@@ -143,15 +151,19 @@ export function configureSpecDatasetHints(): void {
monaco.languages.registerInlayHintsProvider('json', { monaco.languages.registerInlayHintsProvider('json', {
provideInlayHints(model, range) { provideInlayHints(model, range) {
if (useSnippetStore.getState().editorView !== 'draft') return { hints: [], dispose() {} }; if (useSnippetStore.getState().editorView !== 'draft') return { hints: [], dispose() {} };
const types = new Map(availableFields().map((f) => [f.name, f.type])); const text = model.getValue();
if (types.size === 0) return { hints: [], dispose() {} };
const hints: monaco.languages.InlayHint[] = []; const hints: monaco.languages.InlayHint[] = [];
for (let line = range.startLineNumber; line <= range.endLineNumber; line++) { for (let line = range.startLineNumber; line <= range.endLineNumber; line++) {
// First `field` per line; the compact format keeps each encoding channel // 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. // (and its lone field) on its own line, so one match per line suffices.
const match = /"field"\s*:\s*"([^"]+)"/.exec(model.getLineContent(line)); const match = /"field"\s*:\s*"([^"]+)"/.exec(model.getLineContent(line));
if (!match) continue; 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) if (!type) continue; // unknown or derived (no type to annotate)
hints.push({ hints.push({
position: { lineNumber: line, column: match.index + match[0].length + 1 }, position: { lineNumber: line, column: match.index + match[0].length + 1 },
+38 -22
View File
@@ -43,7 +43,7 @@ import {
} from '@core/spec-transforms'; } from '@core/spec-transforms';
import { notify } from '../stores/NotificationStore'; import { notify } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore'; import { useSnippetStore } from '../stores/SnippetStore';
import { boundColumns } from './active-dataset'; import { boundColumnsAt } from './active-dataset';
import { parseSpecObject } from './spec-config-actions'; import { parseSpecObject } from './spec-config-actions';
/** The composition operators the wrap actions offer. */ /** The composition operators the wrap actions offer. */
@@ -53,6 +53,8 @@ type WrapKind = 'layer' | 'hconcat' | 'vconcat' | 'facet' | 'repeat';
interface Scope { interface Scope {
range: monaco.Range; range: monaco.Range;
text: string; 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. */ /** Column the slice starts at (0-based), so re-indented output stays aligned. */
baseCol: number; baseCol: number;
} }
@@ -63,29 +65,31 @@ const isEmptyRange = (r: monaco.IRange): boolean =>
/** The whole document, as a scope. */ /** The whole document, as a scope. */
function wholeDocument(model: monaco.editor.ITextModel): Scope { function wholeDocument(model: monaco.editor.ITextModel): Scope {
const range = model.getFullModelRange(); 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 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 { function resolveScope(model: monaco.editor.ITextModel, range: monaco.IRange | null): Scope {
if (!range) return wholeDocument(model); 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({ const offset = model.getOffsetAt({
lineNumber: range.startLineNumber, lineNumber: range.startLineNumber,
column: range.startColumn, 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); const node = findViewRange(model.getValue(), offset);
if (!node) return wholeDocument(model); if (!node) return wholeDocument(model);
const start = model.getPositionAt(node.offset); const start = model.getPositionAt(node.offset);
const end = model.getPositionAt(node.offset + node.length); const end = model.getPositionAt(node.offset + node.length);
const r = new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column); 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. */ /** 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. */ /** A categorical column to facet by (first nominal/ordinal), or a placeholder. */
function defaultFacet(): { field: string; type: string } { function defaultFacet(text: string, offset: number): { field: string; type: string } {
const cols = boundColumns(); const cols = boundColumnsAt(text, offset);
const categorical = cols.find((c) => { const categorical = cols.find((c) => {
const t = defaultFieldType(c.type); const t = defaultFieldType(c.type);
return t === 'nominal' || t === 'ordinal'; 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. */ /** Quantitative columns to repeat over + the channel to rewire, with fallbacks. */
function defaultRepeat(spec: JsonObject): { fields: string[]; channel: string | null } { function defaultRepeat(
const cols = boundColumns(); spec: JsonObject,
text: string,
offset: number,
): { fields: string[]; channel: string | null } {
const cols = boundColumnsAt(text, offset);
const numeric = cols const numeric = cols
.filter((c) => defaultFieldType(c.type) === 'quantitative') .filter((c) => defaultFieldType(c.type) === 'quantitative')
.map((c) => c.name); .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 }; 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) { switch (kind) {
case 'layer': case 'layer':
return wrapInLayer(spec); return wrapInLayer(spec);
@@ -140,11 +152,11 @@ function buildNext(spec: JsonObject, kind: WrapKind): JsonObject {
case 'vconcat': case 'vconcat':
return wrapInConcat(spec, 'v'); return wrapInConcat(spec, 'v');
case 'facet': { case 'facet': {
const { field, type } = defaultFacet(); const { field, type } = defaultFacet(text, offset);
return wrapInFacet(spec, field, type); return wrapInFacet(spec, field, type);
} }
case 'repeat': { case 'repeat': {
const { fields, channel } = defaultRepeat(spec); const { fields, channel } = defaultRepeat(spec, text, offset);
return wrapInRepeat(spec, fields, channel); return wrapInRepeat(spec, fields, channel);
} }
} }
@@ -190,7 +202,8 @@ export function runWrap(editor: monaco.editor.IStandaloneCodeEditor, kind: WrapK
const target = resolveTarget(editor); const target = resolveTarget(editor);
if (!target) return; if (!target) return;
const { model, scope, spec } = target; 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({ notify({
kind: 'success', kind: 'success',
title: 'View wrapped', title: 'View wrapped',
@@ -381,13 +394,16 @@ export function configureSpecTransformCodeActions(): void {
return empty; return empty;
} }
if (!isJsonObject(spec)) 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[] = [ const actions: monaco.languages.CodeAction[] = [
editAction(model, scope, 'Wrap view in a layer', buildNext(spec, 'layer')), editAction(model, scope, 'Wrap view in a layer', build('layer')),
editAction(model, scope, 'Wrap view in horizontal concat', buildNext(spec, 'hconcat')), editAction(model, scope, 'Wrap view in horizontal concat', build('hconcat')),
editAction(model, scope, 'Wrap view in vertical concat', buildNext(spec, 'vconcat')), editAction(model, scope, 'Wrap view in vertical concat', build('vconcat')),
editAction(model, scope, 'Wrap view in a facet', buildNext(spec, 'facet')), editAction(model, scope, 'Wrap view in a facet', build('facet')),
editAction(model, scope, 'Wrap view in a repeat', buildNext(spec, 'repeat')), editAction(model, scope, 'Wrap view in a repeat', build('repeat')),
]; ];
const collapsed = unwrapSingleton(spec); const collapsed = unwrapSingleton(spec);
if (collapsed) if (collapsed)
+10
View File
@@ -70,6 +70,16 @@ export function findViewRange(text: string, offset: number): NodeRange | null {
return 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 * 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 * 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 { describe, expect, test } from 'vitest';
import { classifyData, libraryRefName, selfDefinedNames } from './spec-data'; import { classifyData, dataBindingAtPath, libraryRefName, selfDefinedNames } from './spec-data';
describe('classifyData', () => { describe('classifyData', () => {
test('classifies the four Vega-Lite data shapes', () => { test('classifies the four Vega-Lite data shapes', () => {
@@ -61,3 +61,50 @@ describe('libraryRefName', () => {
expect(libraryRefName({ values: [{ a: 1 }] }, none)).toBeNull(); 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 }; const { name } = data as { name: string };
return selfDefined.has(name) ? null : name; 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 { describe, expect, it } from 'vitest';
import { derivedFieldNames } from './spec-fields'; import { derivedFieldNamesAtPath } from './spec-fields';
describe('derivedFieldNames', () => { describe('derivedFieldNamesAtPath', () => {
it('collects calculate / timeUnit / bin as-names', () => {
const spec = { const spec = {
transform: [{ calculate: 'x', as: 'shared' }],
layer: [
{ transform: [{ calculate: 'y', as: 'inner' }], mark: 'bar' },
{ transform: [{ calculate: 'z', as: 'sibling' }], mark: 'line' },
],
};
it('collects calculate / timeUnit / bin as-names', () => {
const s = {
transform: [ transform: [
{ calculate: 'datum.a + 1', as: 'plusOne' }, { calculate: 'datum.a + 1', as: 'plusOne' },
{ timeUnit: 'month', field: 'date', as: 'mo' }, { timeUnit: 'month', field: 'date', as: 'mo' },
{ bin: true, field: 'x', as: ['x_start', 'x_end'] }, { 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)', () => { it('reaches into op-list transforms (aggregate/window/joinaggregate)', () => {
const spec = { const s = {
transform: [ transform: [
{ aggregate: [{ op: 'mean', field: 'v', as: 'meanV' }], groupby: ['g'] }, { aggregate: [{ op: 'mean', field: 'v', as: 'meanV' }], groupby: ['g'] },
{ window: [{ op: 'rank', as: 'rk' }] }, { window: [{ op: 'rank', as: 'rk' }] },
{ joinaggregate: [{ op: 'sum', field: 'v', as: 'total' }] }, { 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', () => { 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', 'key',
'value', 'value',
]); ]);
expect(derivedFieldNames({ transform: [{ fold: ['a'], as: ['k', 'v'] }] }).sort()).toEqual([ expect(
'k', derivedFieldNamesAtPath({ transform: [{ fold: ['a'], as: ['k', 'v'] }] }, []).sort(),
'v', ).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']);
}); });
it('returns nothing for a spec with no transforms', () => { 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',
]);
}); });
}); });
+30 -18
View File
@@ -8,8 +8,9 @@
* offered too. This collects the statically-named ones: every transform `as` * offered too. This collects the statically-named ones: every transform `as`
* (`calculate`, `timeUnit`, `bin`, `stack`, `fold`, `flatten`, `regression`, …) * (`calculate`, `timeUnit`, `bin`, `stack`, `fold`, `flatten`, `regression`, …)
* and the nested `as` of the op-list transforms (`aggregate`, `window`, * and the nested `as` of the op-list transforms (`aggregate`, `window`,
* `joinaggregate`). Transforms can sit at the top level or inside any view, so * `joinaggregate`), scoped to the transforms in effect at a cursor — the enclosing
* the walk recurses the whole spec. * 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 * Out of scope by design: **data-dependent** derived columns — `pivot`'s
* one-column-per-value output and `lookup`'s imported fields — which only exist * 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 * The fields derived by transforms *in scope* at a JSON `path` into the spec — the
* parsed spec object (callers parse the draft once). * `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 names = new Set<string>();
const walk = (node: unknown): void => { let node: unknown = spec;
if (Array.isArray(node)) { collectNodeTransforms(node, names);
for (const item of node) walk(item); for (const key of path) {
return; if (node === null || typeof node !== 'object') break;
node = (node as Record<string | number, unknown>)[key];
collectNodeTransforms(node, names);
} }
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);
return [...names]; return [...names];
} }
+15 -26
View File
@@ -1,34 +1,23 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { inlineDataRows } from './spec-inline-data'; import { rowsForDataBinding } from './spec-inline-data';
describe('inlineDataRows', () => { describe('rowsForDataBinding', () => {
it('reads top-level data.values', () => { it('reads inline values from the given binding', () => {
const rows = inlineDataRows({ data: { values: [{ a: 1 }, { a: 2 }] }, mark: 'bar' }); const spec = { data: { values: [{ a: 1 }, { a: 2 }] } };
expect(rows).toEqual([{ 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', () => { it('resolves a self-defined named binding against top-level datasets', () => {
const spec = { layer: [{ data: { values: [{ b: 1 }] }, mark: 'bar' }] }; const spec = { datasets: { ds: [{ c: 1 }, { c: 2 }] }, data: { name: 'ds' } };
expect(inlineDataRows(spec)).toEqual([{ b: 1 }]); expect(rowsForDataBinding(spec, { name: 'ds' })).toEqual([{ c: 1 }, { c: 2 }]);
}); });
it('prefers top-level over nested', () => { it('returns null for a library reference, url, generator, or CSV-string values', () => {
const spec = { const spec = { datasets: { ds: [{ c: 1 }] } };
data: { values: [{ top: 1 }] }, expect(rowsForDataBinding(spec, { name: 'LibraryRef' })).toBeNull(); // not self-defined
layer: [{ data: { values: [{ nested: 1 }] } }], expect(rowsForDataBinding(spec, { url: 'x.csv' })).toBeNull();
}; expect(rowsForDataBinding(spec, { sequence: { start: 0, stop: 5 } })).toBeNull();
expect(inlineDataRows(spec)).toEqual([{ top: 1 }]); expect(rowsForDataBinding(spec, { values: 'a,b\n1,2' })).toBeNull();
}); expect(rowsForDataBinding({ mark: 'bar' }, { values: [] })).toBeNull(); // empty rows
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();
}); });
}); });
+25 -51
View File
@@ -1,20 +1,18 @@
/** /**
* Inline data carried by a spec (docs/architecture/08 → editor augmentation). * Inline data carried by a spec (docs/architecture/08 → editor augmentation).
* *
* Portable core. When a spec has no named library dataset, its columns still * Portable core. When a view binds no named library dataset, its columns still
* exist — inline, in `data.values` or a top-level `datasets` entry. This pulls * exist — inline, in `data.values` or a self-defined top-level `datasets` entry.
* those rows out so the editor can profile them (core/profile) and offer the same * This pulls those rows out so the editor can profile them (core/profile) and offer
* field hints a library-bound spec gets — a "ghost dataset" derived from the spec * the same field hints a library-bound view gets — a "ghost dataset" derived from
* itself, with nothing stored. * the spec itself, with nothing stored.
* *
* Resolution order mirrors what the renderer sees first: the nearest `data.values` * URL data has no rows to read statically, and `values` given as a CSV/TSV string
* (top level, then any nested view — pruning the `data` payload from the walk like * needs format-aware parsing — both out of scope here (they yield null).
* 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.
*/ */
import { isJsonObject } from './spec-config'; 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. */ /** An array of row objects, or null when the value is not tabular inline data. */
function asRows(value: unknown): Record<string, unknown>[] | null { function asRows(value: unknown): Record<string, unknown>[] | null {
@@ -24,47 +22,23 @@ function asRows(value: unknown): Record<string, unknown>[] | null {
return 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 * The tabular rows a *specific* `data` binding provides for static profiling, or
* top-level `datasets` entry — or null when the spec carries none. * 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 { export function rowsForDataBinding(spec: unknown, data: unknown): Record<string, unknown>[] | null {
if (!isJsonObject(spec)) return null; const kind = classifyData(data);
return firstDataValues(spec) ?? firstNamedDataset(spec); 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;
} }