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:
@@ -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);
|
||||
|
||||
// Source columns: a named library dataset wins; otherwise profile inline data.
|
||||
let name: string | null = null;
|
||||
let columnTypes: ReadonlyArray<{ name: string; type: ColumnType }> = [];
|
||||
let columnStats: ReadonlyArray<ColumnStats> = [];
|
||||
const library = libraryDataset(draftText);
|
||||
if (library && library.columnTypes.length > 0) {
|
||||
({ name, columnTypes, columnStats } = library);
|
||||
} else {
|
||||
const rows = inlineDataRows(spec);
|
||||
if (rows) {
|
||||
const profile = profileData(rows, 0);
|
||||
columnTypes = profile.columnTypes;
|
||||
columnStats = profile.columnStats;
|
||||
}
|
||||
/** The stored profile of the library dataset a binding references, or null. */
|
||||
function libraryProfile(refName: string): SourceColumns | null {
|
||||
const lower = refName.toLowerCase();
|
||||
const ds = useDatasetStore.getState().datasets.find((d) => d.name.toLowerCase() === lower);
|
||||
if (ds && ds.columnTypes.length > 0) {
|
||||
return { name: ds.name, columnTypes: ds.columnTypes, columnStats: ds.columnStats };
|
||||
}
|
||||
return null; // unknown name, or a dataset with no profiled columns yet
|
||||
}
|
||||
|
||||
const fields: FieldHint[] = columnTypes.map((c) => ({
|
||||
/** Source columns for one binding: library profile, else inline rows profiled. */
|
||||
function resolveSource(spec: unknown, data: unknown): SourceColumns {
|
||||
const refName = libraryRefName(data, selfDefinedNames(spec));
|
||||
if (refName !== null) return libraryProfile(refName) ?? EMPTY_SOURCE;
|
||||
const rows = rowsForDataBinding(spec, data);
|
||||
if (!rows) return EMPTY_SOURCE; // url / generator / library miss / CSV-string values
|
||||
const profile = profileData(rows, 0);
|
||||
return { name: null, columnTypes: profile.columnTypes, columnStats: profile.columnStats };
|
||||
}
|
||||
|
||||
// Source columns memoized by (text, enclosing view). Keyed by the binding's anchor
|
||||
// path so the inlay provider's many per-line queries within one view profile once.
|
||||
// A re-import that changes a dataset's columns under unchanged text serves stale
|
||||
// hints until the next keystroke — harmless, since hints are additive.
|
||||
let sourceCache: { text: string; byAnchor: Map<string, SourceColumns> } | null = null;
|
||||
function sourceColumns(
|
||||
text: string,
|
||||
spec: unknown,
|
||||
anchorKey: string,
|
||||
data: unknown,
|
||||
): SourceColumns {
|
||||
if (!sourceCache || sourceCache.text !== text) sourceCache = { text, byAnchor: new Map() };
|
||||
const hit = sourceCache.byAnchor.get(anchorKey);
|
||||
if (hit) return hit;
|
||||
const result = resolveSource(spec, data);
|
||||
sourceCache.byAnchor.set(anchorKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared resolve step: the source columns of the binding in scope at `offset`,
|
||||
* with the parsed spec and cursor path so callers can layer derived fields on top
|
||||
* without re-parsing or re-walking.
|
||||
*/
|
||||
function resolveAt(
|
||||
text: string,
|
||||
offset: number,
|
||||
): {
|
||||
source: SourceColumns;
|
||||
spec: unknown;
|
||||
path: ReadonlyArray<string | number>;
|
||||
} {
|
||||
const spec = parsedSpec(text);
|
||||
const path = pathAtOffset(text, offset);
|
||||
const binding = dataBindingAtPath(spec, path);
|
||||
const source = binding
|
||||
? sourceColumns(text, spec, JSON.stringify(binding.anchorPath), binding.data)
|
||||
: EMPTY_SOURCE;
|
||||
return { source, spec, path };
|
||||
}
|
||||
|
||||
/** Source columns of the binding in scope at `offset` — no derived-field merge. */
|
||||
function sourceAt(text: string, offset: number): SourceColumns {
|
||||
return text.trim() === '' ? EMPTY_SOURCE : resolveAt(text, offset).source;
|
||||
}
|
||||
|
||||
/** The data context at `offset` in the editor's draft `text`. */
|
||||
export function dataInfoAt(text: string, offset: number): DataInfo {
|
||||
if (text.trim() === '') return EMPTY;
|
||||
const { source, spec, path } = resolveAt(text, offset);
|
||||
|
||||
const fields: FieldHint[] = source.columnTypes.map((c) => ({
|
||||
name: c.name,
|
||||
type: c.type,
|
||||
derived: false,
|
||||
}));
|
||||
const seen = new Set(fields.map((f) => f.name));
|
||||
for (const derived of derivedFieldNames(spec)) {
|
||||
for (const derived of derivedFieldNamesAtPath(spec, path)) {
|
||||
if (!seen.has(derived)) {
|
||||
fields.push({ name: derived, type: null, derived: true });
|
||||
seen.add(derived);
|
||||
}
|
||||
}
|
||||
|
||||
return { name, columnTypes, columnStats, fields };
|
||||
return {
|
||||
name: source.name,
|
||||
columnTypes: source.columnTypes,
|
||||
columnStats: source.columnStats,
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
// Keyed by draft text alone: this assumes a bound dataset's profile is stable
|
||||
// for a given draft. A re-import that changes columns under unchanged text serves
|
||||
// stale hints until the next keystroke — harmless, since hints are additive.
|
||||
let cache: { text: string; info: DataInfo } | null = null;
|
||||
|
||||
/** The active draft's data context, memoized by draft text. */
|
||||
export function dataInfo(): DataInfo {
|
||||
const text = useSnippetStore.getState().draftText;
|
||||
if (text.trim() === '') return EMPTY;
|
||||
if (cache && cache.text === text) return cache.info;
|
||||
const info = compute(text);
|
||||
cache = { text, info };
|
||||
return info;
|
||||
/** Source columns + derived fields available at `offset` (for completion). */
|
||||
export function availableFieldsAt(text: string, offset: number): ReadonlyArray<FieldHint> {
|
||||
return dataInfoAt(text, offset).fields;
|
||||
}
|
||||
|
||||
/** Source columns + inferred types available to the draft (for facet/repeat defaults). */
|
||||
export function boundColumns(): ReadonlyArray<{ name: string; type: ColumnType }> {
|
||||
return dataInfo().columnTypes;
|
||||
/** Source columns + inferred types in scope at `offset` (for facet/repeat defaults). */
|
||||
export function boundColumnsAt(
|
||||
text: string,
|
||||
offset: number,
|
||||
): ReadonlyArray<{ name: string; type: ColumnType }> {
|
||||
return sourceAt(text, offset).columnTypes;
|
||||
}
|
||||
|
||||
/** Source columns plus the spec's derived fields (for completion/inlay). */
|
||||
export function availableFields(): ReadonlyArray<FieldHint> {
|
||||
return dataInfo().fields;
|
||||
/** The inferred type of a source field `name` at `offset`, or null (for inlay). */
|
||||
export function fieldTypeAt(text: string, offset: number, name: string): ColumnType | null {
|
||||
return sourceAt(text, offset).columnTypes.find((c) => c.name === name)?.type ?? null;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user