mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Editor: view-scoped data context — per-view dataset columns in hints and transforms
This commit is contained in:
@@ -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; M2–M4 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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 view’s', () => {
|
||||
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 view’s', () => {
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
/** 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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
/** 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);
|
||||
columnTypes = profile.columnTypes;
|
||||
columnStats = profile.columnStats;
|
||||
}
|
||||
return { name: null, columnTypes: profile.columnTypes, 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,
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,52 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { derivedFieldNames } from './spec-fields';
|
||||
import { derivedFieldNamesAtPath } from './spec-fields';
|
||||
|
||||
describe('derivedFieldNames', () => {
|
||||
it('collects calculate / timeUnit / bin as-names', () => {
|
||||
describe('derivedFieldNamesAtPath', () => {
|
||||
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: [
|
||||
{ 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+30
-18
@@ -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;
|
||||
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);
|
||||
}
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user