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
+38 -22
View File
@@ -43,7 +43,7 @@ import {
} from '@core/spec-transforms';
import { notify } from '../stores/NotificationStore';
import { useSnippetStore } from '../stores/SnippetStore';
import { boundColumns } from './active-dataset';
import { boundColumnsAt } from './active-dataset';
import { parseSpecObject } from './spec-config-actions';
/** The composition operators the wrap actions offer. */
@@ -53,6 +53,8 @@ type WrapKind = 'layer' | 'hconcat' | 'vconcat' | 'facet' | 'repeat';
interface Scope {
range: monaco.Range;
text: string;
/** Whole-document offset of the focused view, for resolving its data binding. */
offset: number;
/** Column the slice starts at (0-based), so re-indented output stays aligned. */
baseCol: number;
}
@@ -63,29 +65,31 @@ const isEmptyRange = (r: monaco.IRange): boolean =>
/** The whole document, as a scope. */
function wholeDocument(model: monaco.editor.ITextModel): Scope {
const range = model.getFullModelRange();
return { range, text: model.getValue(), baseCol: 0 };
return { range, text: model.getValue(), offset: 0, baseCol: 0 };
}
/**
* The slice a transform acts on: an explicit selection if present; else the view
* the cursor sits in (core/spec-cursor); else the whole document.
* the cursor sits in (core/spec-cursor); else the whole document. `offset` is the
* cursor/selection-start position in the whole document, used to resolve the
* focused view's data binding (facet/repeat field defaults).
*/
function resolveScope(model: monaco.editor.ITextModel, range: monaco.IRange | null): Scope {
if (!range) return wholeDocument(model);
if (!isEmptyRange(range)) {
const r = monaco.Range.lift(range);
return { range: r, text: model.getValueInRange(r), baseCol: r.startColumn - 1 };
}
const offset = model.getOffsetAt({
lineNumber: range.startLineNumber,
column: range.startColumn,
});
if (!isEmptyRange(range)) {
const r = monaco.Range.lift(range);
return { range: r, text: model.getValueInRange(r), offset, baseCol: r.startColumn - 1 };
}
const node = findViewRange(model.getValue(), offset);
if (!node) return wholeDocument(model);
const start = model.getPositionAt(node.offset);
const end = model.getPositionAt(node.offset + node.length);
const r = new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column);
return { range: r, text: model.getValueInRange(r), baseCol: start.column - 1 };
return { range: r, text: model.getValueInRange(r), offset, baseCol: start.column - 1 };
}
/** Indent every line after the first by `baseCol`, so a scoped edit stays aligned. */
@@ -106,8 +110,8 @@ function formatScoped(model: monaco.editor.ITextModel, scope: Scope, next: JsonO
}
/** A categorical column to facet by (first nominal/ordinal), or a placeholder. */
function defaultFacet(): { field: string; type: string } {
const cols = boundColumns();
function defaultFacet(text: string, offset: number): { field: string; type: string } {
const cols = boundColumnsAt(text, offset);
const categorical = cols.find((c) => {
const t = defaultFieldType(c.type);
return t === 'nominal' || t === 'ordinal';
@@ -119,8 +123,12 @@ function defaultFacet(): { field: string; type: string } {
}
/** Quantitative columns to repeat over + the channel to rewire, with fallbacks. */
function defaultRepeat(spec: JsonObject): { fields: string[]; channel: string | null } {
const cols = boundColumns();
function defaultRepeat(
spec: JsonObject,
text: string,
offset: number,
): { fields: string[]; channel: string | null } {
const cols = boundColumnsAt(text, offset);
const numeric = cols
.filter((c) => defaultFieldType(c.type) === 'quantitative')
.map((c) => c.name);
@@ -130,8 +138,12 @@ function defaultRepeat(spec: JsonObject): { fields: string[]; channel: string |
return { fields: fields.length > 0 ? fields : ['field1', 'field2'], channel };
}
/** Apply a wrap of the given kind to the parsed spec. */
function buildNext(spec: JsonObject, kind: WrapKind): JsonObject {
/**
* Apply a wrap of the given kind to the parsed (scoped) spec. `text`/`offset` are
* the whole document and the focused view's position, used to resolve that view's
* data binding for the facet/repeat field defaults.
*/
function buildNext(spec: JsonObject, kind: WrapKind, text: string, offset: number): JsonObject {
switch (kind) {
case 'layer':
return wrapInLayer(spec);
@@ -140,11 +152,11 @@ function buildNext(spec: JsonObject, kind: WrapKind): JsonObject {
case 'vconcat':
return wrapInConcat(spec, 'v');
case 'facet': {
const { field, type } = defaultFacet();
const { field, type } = defaultFacet(text, offset);
return wrapInFacet(spec, field, type);
}
case 'repeat': {
const { fields, channel } = defaultRepeat(spec);
const { fields, channel } = defaultRepeat(spec, text, offset);
return wrapInRepeat(spec, fields, channel);
}
}
@@ -190,7 +202,8 @@ export function runWrap(editor: monaco.editor.IStandaloneCodeEditor, kind: WrapK
const target = resolveTarget(editor);
if (!target) return;
const { model, scope, spec } = target;
writeBack(editor, scope.range, formatScoped(model, scope, buildNext(spec, kind)));
const next = buildNext(spec, kind, model.getValue(), scope.offset);
writeBack(editor, scope.range, formatScoped(model, scope, next));
notify({
kind: 'success',
title: 'View wrapped',
@@ -381,13 +394,16 @@ export function configureSpecTransformCodeActions(): void {
return empty;
}
if (!isJsonObject(spec)) return empty;
const viewSpec = spec;
const fullText = model.getValue();
const build = (kind: WrapKind) => buildNext(viewSpec, kind, fullText, scope.offset);
const actions: monaco.languages.CodeAction[] = [
editAction(model, scope, 'Wrap view in a layer', buildNext(spec, 'layer')),
editAction(model, scope, 'Wrap view in horizontal concat', buildNext(spec, 'hconcat')),
editAction(model, scope, 'Wrap view in vertical concat', buildNext(spec, 'vconcat')),
editAction(model, scope, 'Wrap view in a facet', buildNext(spec, 'facet')),
editAction(model, scope, 'Wrap view in a repeat', buildNext(spec, 'repeat')),
editAction(model, scope, 'Wrap view in a layer', build('layer')),
editAction(model, scope, 'Wrap view in horizontal concat', build('hconcat')),
editAction(model, scope, 'Wrap view in vertical concat', build('vconcat')),
editAction(model, scope, 'Wrap view in a facet', build('facet')),
editAction(model, scope, 'Wrap view in a repeat', build('repeat')),
];
const collapsed = unwrapSingleton(spec);
if (collapsed)