From 31458114fb979c62d90deac5c391b724a7d350ff Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Sun, 28 Jun 2026 16:56:20 +0300 Subject: [PATCH] Editor: spec transforms (wrap/simplify/add-view) and dataset-aware hints --- docs/architecture/00-overview.md | 2 +- .../architecture/08-vega-editor-techniques.md | 54 ++ .../editor-augmentation-demo.html | 853 ++++++++++++++++++ package-lock.json | 7 + package.json | 1 + src/app/components/SpecEditor.tsx | 60 ++ src/app/services/active-dataset.ts | 127 +++ src/app/services/spec-config-actions.ts | 17 +- src/app/services/spec-dataset-hints.ts | 166 ++++ src/app/services/spec-transform-actions.ts | 399 ++++++++ src/core/spec-cursor.test.ts | 107 +++ src/core/spec-cursor.ts | 95 ++ src/core/spec-fields.test.ts | 52 ++ src/core/spec-fields.ts | 68 ++ src/core/spec-inline-data.test.ts | 34 + src/core/spec-inline-data.ts | 70 ++ src/core/spec-insert.test.ts | 49 + src/core/spec-insert.ts | 72 ++ src/core/spec-transforms.test.ts | 127 +++ src/core/spec-transforms.ts | 145 +++ 20 files changed, 2498 insertions(+), 7 deletions(-) create mode 100644 docs/architecture/editor-augmentation-demo.html create mode 100644 src/app/services/active-dataset.ts create mode 100644 src/app/services/spec-dataset-hints.ts create mode 100644 src/app/services/spec-transform-actions.ts create mode 100644 src/core/spec-cursor.test.ts create mode 100644 src/core/spec-cursor.ts create mode 100644 src/core/spec-fields.test.ts create mode 100644 src/core/spec-fields.ts create mode 100644 src/core/spec-inline-data.test.ts create mode 100644 src/core/spec-inline-data.ts create mode 100644 src/core/spec-insert.test.ts create mode 100644 src/core/spec-insert.ts create mode 100644 src/core/spec-transforms.test.ts create mode 100644 src/core/spec-transforms.ts diff --git a/docs/architecture/00-overview.md b/docs/architecture/00-overview.md index 1d3b7dc..19b2778 100644 --- a/docs/architecture/00-overview.md +++ b/docs/architecture/00-overview.md @@ -44,7 +44,7 @@ about user-facing widgets. At that overlap, one rule keeps them from drifting: | 05 | [Rendering, Theming & Preview](05-rendering-theming-preview.md) | vega-embed integration (`actions:false`, `view.finalize()`); field-name escaping; theme→config mapping; debounced non-blocking renderer; resilient error display. | | 06 | [Type Inference & Profiling](06-type-inference.md) | Pure, portable column-type inference (number/text/date/boolean) and the dataset profile shape. | | 07 | [Naming & Relationships](07-naming-and-relationships.md) | Unique-name enforcement + import auto-suffix; the bidirectional snippet↔dataset name link; rename propagation into specs. | -| 08 | [vega/editor Techniques](08-vega-editor-techniques.md) | Reference brief: borrowable Monaco-schema wiring, vega-embed lifecycle, two-tier validation, and data-flow/debounce techniques distilled from the official Vega-Lite editor — plus where we do better. | +| 08 | [vega/editor Techniques](08-vega-editor-techniques.md) | Reference brief: borrowable Monaco-schema wiring, vega-embed lifecycle, two-tier validation, and data-flow/debounce techniques distilled from the official Vega-Lite editor — plus where we do better, and our editor-augmentation layer (structural transforms + data-aware hints). | | 09 | [Visual Design Language](09-visual-design.md) | The _visual_ contract: principles inspired by IBM/Carbon, deliberate divergences (square chrome, free color/theming), the token system (Plex type, 8px spacing, role-based color, motion), component conventions, and where to mine the Carbon/IBM source repos for more. Companion: [`visual-specimen.html`](visual-specimen.html). | | 10 | [Interaction & Feedback](10-interaction-and-feedback.md) | The _interaction_ contract: the feedback-channel decision table, latency/feedback budgets, the non-happy-path triad, the recovery & data-safety contract, the keyboard/focus contract, and the resolved widget patterns (window splitter, toolbar, segmented controls, selectable lists, search, sort, empty states, modals). Cites `spec/` for behavior; owns the _how_. | | 11 | [Learning Section](11-learning-section.md) | The `/learn/` deep-dive: a marketing-surface Vite entry reusing core + the landing chart embed; markdown-authored lessons (`import.meta.glob`) parsed into an ordered block model; the authoring/engine split (pure parser in core; `marked` only in `src/learn`). | diff --git a/docs/architecture/08-vega-editor-techniques.md b/docs/architecture/08-vega-editor-techniques.md index 6ef79e6..3355c0a 100644 --- a/docs/architecture/08-vega-editor-techniques.md +++ b/docs/architecture/08-vega-editor-techniques.md @@ -275,6 +275,60 @@ defaults-spread" discipline is worth keeping. --- +## 5 · Editor augmentation (our layer over the borrowed base) + +Beyond schema validation/completion (§1), the spec editor adds structural refactors and +data-aware hints — the edits that are awkward in raw JSON and out of reach of the +single-view visual builder. All transform logic is pure `src/core/`; the Monaco glue is +thin app-layer services. + +**Core (pure, portable):** + +- `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-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. + +Decision rules: + +- **Provider lifetime — global-once vs per-editor.** Language providers that need no editor + handle (code actions, completion, hover, inlay) register **once** for `json`, like the + schema and formatter; per-editor registration would duplicate them on remount. Pieces that + need the editor handle — the `addAction` context/F1 commands, and the CodeLens whose command + runs `executeEdits` — are installed **per editor** and disposed with it. +- **Transform scope.** A transform targets, in order: an explicit selection → the view the + cursor sits in (`findViewRange`) → the whole document. A "view" is a composition-array + element or a facet/repeat `spec` child; a flat unit spec has no inner view, so it scopes to + the whole document. `jsonc-parser` is error-tolerant, so scoping holds mid-edit; the path + logic stays in core and only the Monaco `Range` is built in the service. +- **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. +- **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. +- **Code-action menu icons are kind-derived** (a wrench for the `refactor.*` kinds) — Monaco's + `CodeAction` carries no icon field. Custom iconography lives only where it is supported: + CodeLens titles (`$(codicon)`), completion-item kinds, and glyph-margin decorations. + +`jsonc-parser` is a direct dependency (Monaco bundles its own copy internally but does not +re-export it). A standalone `editor-augmentation-demo.html` loads Monaco from a CDN to +exercise these provider surfaces in isolation. + ## Borrow list (where each lands) | Technique | Lands in | Milestone | diff --git a/docs/architecture/editor-augmentation-demo.html b/docs/architecture/editor-augmentation-demo.html new file mode 100644 index 0000000..2a6c85f --- /dev/null +++ b/docs/architecture/editor-augmentation-demo.html @@ -0,0 +1,853 @@ + + + + + + Editor augmentation — Monaco interactivity sandbox + + + + + + +
+

Editor augmentation

+ Monaco interactivity sandbox · companion to architecture 08 + + + + +
+ +
+
+ +
+ +
+ + + + + diff --git a/package-lock.json b/package-lock.json index ad2bde0..dd1a39c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@fontsource/space-mono": "^5.2.9", "@fontsource/spectral": "^5.2.8", "json-stringify-pretty-compact": "^4.0.0", + "jsonc-parser": "^3.3.1", "marked": "^18.0.5", "monaco-editor": "^0.54.0", "react": "^19.2.7", @@ -6148,6 +6149,12 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, "node_modules/jsonfile": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", diff --git a/package.json b/package.json index 9db9eac..c5729f1 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@fontsource/space-mono": "^5.2.9", "@fontsource/spectral": "^5.2.8", "json-stringify-pretty-compact": "^4.0.0", + "jsonc-parser": "^3.3.1", "marked": "^18.0.5", "monaco-editor": "^0.54.0", "react": "^19.2.7", diff --git a/src/app/components/SpecEditor.tsx b/src/app/components/SpecEditor.tsx index 454ba9e..aba8e37 100644 --- a/src/app/components/SpecEditor.tsx +++ b/src/app/components/SpecEditor.tsx @@ -33,6 +33,14 @@ import { runExtractConfigToTheme, runMergeChartTheme, } from '../services/spec-config-actions'; +import { + configureSpecTransformCodeActions, + installSpecTransformActions, + installSpecTransformCodeLens, + runUnwrap, + runWrap, +} from '../services/spec-transform-actions'; +import { configureSpecDatasetHints } from '../services/spec-dataset-hints'; import { useAppStore } from '../stores/AppStore'; import { confirm } from '../stores/ConfirmStore'; import { useDatasetStore } from '../stores/DatasetStore'; @@ -149,6 +157,11 @@ function EditorSettings() { configureVegaLiteJson(); // Register the compact JSON formatter once (Format Document + format-on-paste, §03A). configureJsonFormatter(); +// Register the structural-transform refactors (the lightbulb) once, globally for +// JSON — like the schema/formatter, not per editor (docs/architecture/08). +configureSpecTransformCodeActions(); +// Register the dataset-aware completion/hover/inlay providers once (docs/architecture/08). +configureSpecDatasetHints(); /** The two spec↔config operations, surfaced as an overflow menu (council: * Carbon menu-buttons — overflow for additional options under space @@ -174,6 +187,25 @@ const CONFIG_ACTIONS = [ type ConfigActionId = (typeof CONFIG_ACTIONS)[number]['value']; +/** Structural transforms, surfaced as a sibling menu to Config (the discoverable + * home; the lightbulb and F1 palette are the accelerators — see + * services/spec-transform-actions). They act on the selection, else the whole + * spec. */ +const TRANSFORM_ACTIONS = [ + { value: 'layer', label: 'Wrap in layer', detail: 'Overlay marks on shared scales' }, + { value: 'hconcat', label: 'Wrap in horizontal concat', detail: 'Place views side by side' }, + { value: 'vconcat', label: 'Wrap in vertical concat', detail: 'Stack views top to bottom' }, + { value: 'facet', label: 'Wrap in facet', detail: 'Small multiples across a field' }, + { value: 'repeat', label: 'Wrap in repeat', detail: 'Repeat the chart across fields' }, + { + value: 'simplify', + label: 'Simplify composition', + detail: 'Collapse a single-child layer/concat back to a unit', + }, +] as const; + +type TransformActionId = (typeof TRANSFORM_ACTIONS)[number]['value']; + function EditorToolbar({ editorRef, }: { @@ -228,6 +260,13 @@ function EditorToolbar({ else runExtractConfigToTheme(editor); }; + const handleTransformAction = (action: TransformActionId) => { + const editor = editorRef.current; + if (!editor) return; + if (action === 'simplify') runUnwrap(editor); + else runWrap(editor, action); + }; + const handleRevert = async () => { const ok = await confirm({ title: 'Revert draft', @@ -284,6 +323,16 @@ function EditorToolbar({ Extract to Dataset )} + ; + columnStats: ReadonlyArray; + /** Source columns plus derived fields, de-duplicated (source wins). */ + fields: ReadonlyArray; +} + +const EMPTY: DataInfo = { name: null, columnTypes: [], columnStats: [], fields: [] }; + +function safeParse(text: string): unknown { + try { + return JSON.parse(text); + } catch { + return null; + } +} + +/** 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; +} + +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 = []; + 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; + } + } + + const fields: FieldHint[] = 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)) { + if (!seen.has(derived)) { + fields.push({ name: derived, type: null, derived: true }); + seen.add(derived); + } + } + + return { name, columnTypes, 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 + inferred types available to the draft (for facet/repeat defaults). */ +export function boundColumns(): ReadonlyArray<{ name: string; type: ColumnType }> { + return dataInfo().columnTypes; +} + +/** Source columns plus the spec's derived fields (for completion/inlay). */ +export function availableFields(): ReadonlyArray { + return dataInfo().fields; +} diff --git a/src/app/services/spec-config-actions.ts b/src/app/services/spec-config-actions.ts index aac4a96..5d52c2e 100644 --- a/src/app/services/spec-config-actions.ts +++ b/src/app/services/spec-config-actions.ts @@ -40,11 +40,16 @@ import { useCustomThemeStore } from '../stores/CustomThemeStore'; import { notify } from '../stores/NotificationStore'; import { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore'; -/** Parse the model's JSON, or toast (and return null) when it isn't a JSON object. */ -function parseSpecObject(model: monaco.editor.ITextModel): Record | null { +/** + * Parse spec JSON, or toast (and return null) when it isn't a JSON object. Takes + * the text (not the model) so it serves both whole-document and selection-scoped + * callers — the config actions pass `model.getValue()`, the transform actions a + * selection. + */ +export function parseSpecObject(text: string): Record | null { let parsed: unknown; try { - parsed = JSON.parse(model.getValue()); + parsed = JSON.parse(text); } catch { notify({ kind: 'error', @@ -57,7 +62,7 @@ function parseSpecObject(model: monaco.editor.ITextModel): Record` beside each `field`, annotation without + * touching the text. + * + * All three read the active draft's bound dataset (services/active-dataset) and + * the cursor's JSON context (core/spec-cursor) at provide-time via `getState()` — + * outside React. They register **once, globally for JSON** (like the schema and + * formatter), not per editor. Suggestion-only: over- or under-listing is + * harmless, which is why there is deliberately no "unknown field" diagnostic + * (that would false-positive on every data-dependent derived column). + */ + +import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main'; +import { defaultFieldType } from '@core/chart-builder'; +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'; + +/** Property values that reference a data field (where column names belong). */ +const FIELD_KEYS = new Set(['field', 'groupby']); +/** Property values that hold a Vega expression (where the validity check fires). */ +const EXPR_KEYS = new Set(['calculate', 'filter', 'expr']); + +/** A short type label for a source field; null type (derived) is shown elsewhere. */ +const typeLabel = (type: ColumnType): string => defaultFieldType(type); + +/** Markdown hover for a field hint: type + stats for source, a note for derived. */ +function fieldHoverContents(hint: FieldHint, info: DataInfo): { value: string }[] { + if (hint.derived || hint.type === null) { + return [{ value: `**${hint.name}** · _derived by a transform_` }]; + } + const lines = [`**${hint.name}** · \`${typeLabel(hint.type)}\``]; + const stat = info.columnStats.find((s) => s.name === hint.name); + if (stat) { + if (stat.numericExtent) + lines.push(`Range ${stat.numericExtent.min} – ${stat.numericExtent.max}`); + lines.push(`${stat.distinct}${stat.distinctCapped ? '+' : ''} distinct`); + } + lines.push(info.name ? `_from dataset “${info.name}”_` : '_from the spec’s inline data_'); + return lines.map((value) => ({ value })); +} + +let registered = false; + +/** Register the dataset-aware completion / hover / inlay providers once. */ +export function configureSpecDatasetHints(): void { + if (registered) return; + registered = true; + + monaco.languages.registerCompletionItemProvider('json', { + triggerCharacters: ['"'], + 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)); + if (key === null || !FIELD_KEYS.has(key)) return { suggestions: [] }; + const fields = availableFields(); + if (fields.length === 0) return { suggestions: [] }; + + const word = model.getWordUntilPosition(position); + const range = new monaco.Range( + position.lineNumber, + word.startColumn, + position.lineNumber, + word.endColumn, + ); + return { + suggestions: fields.map((f) => ({ + label: f.name, + kind: f.derived + ? monaco.languages.CompletionItemKind.Variable + : monaco.languages.CompletionItemKind.Field, + detail: f.derived || f.type === null ? 'derived field' : typeLabel(f.type), + insertText: f.name, + range, + })), + }; + }, + }); + + // 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 + // 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', { + provideHover(model, position) { + if (useSnippetStore.getState().editorView !== 'draft') return null; + const text = model.getValue(); + const offset = model.getOffsetAt(position); + + // Over an expression value: a live validity check. + const key = valueKeyAtOffset(text, offset); + if (key !== null && EXPR_KEYS.has(key)) { + const expr = stringValueAtOffset(text, offset); + if (expr !== null) { + const result = validateExpression(expr); + return { + contents: [ + { + value: result.valid + ? '✓ Valid Vega expression' + : `✗ ${result.error ?? 'Invalid expression'}`, + }, + ], + }; + } + } + + // Over a field name: its type + stats. + const word = model.getWordAtPosition(position); + if (word) { + const info = dataInfo(); + const hint = info.fields.find((f) => f.name === word.word); + if (hint) { + return { + range: new monaco.Range( + position.lineNumber, + word.startColumn, + position.lineNumber, + word.endColumn, + ), + contents: fieldHoverContents(hint, info), + }; + } + } + return null; + }, + }); + + 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 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]); + if (!type) continue; // unknown or derived (no type to annotate) + hints.push({ + position: { lineNumber: line, column: match.index + match[0].length + 1 }, + label: `: ${typeLabel(type)}`, + kind: monaco.languages.InlayHintKind.Type, + paddingLeft: true, + }); + } + return { hints, dispose() {} }; + }, + }); +} diff --git a/src/app/services/spec-transform-actions.ts b/src/app/services/spec-transform-actions.ts new file mode 100644 index 0000000..3e1cfc8 --- /dev/null +++ b/src/app/services/spec-transform-actions.ts @@ -0,0 +1,399 @@ +/** + * Structural spec transforms as editor actions (docs/architecture/08 → editor + * augmentation) — the refactor counterpart to spec-config-actions. Wrap the + * focused view in a composition (layer / hconcat / vconcat / facet / repeat) or + * collapse a single-child composition back to a unit, over the portable core + * transforms (core/spec-transforms). + * + * **Scope** is the current selection when there is one (the user said exactly + * what to target); otherwise the view the cursor sits in — an element of a + * layer/concat or a facet/repeat child, resolved by core/spec-cursor — falling + * back to the whole document for a flat unit spec with no inner view. + * + * **Surfacing** mirrors spec-config-actions' three-tier model: + * - the editor toolbar's **Transform menu** (SpecEditor) is the discoverable + * home, calling `runWrap` / `runUnwrap`; + * - the **lightbulb** (`configureSpecTransformCodeActions`, registered once, + * global per-language) offers the same transforms contextually at the cursor; + * - the **F1 palette** (`installSpecTransformActions`, per editor) is the + * keyboard accelerator. These are *not* added to the right-click menu — the + * lightbulb already covers the in-place case, and config-actions hold the + * three context-menu slots; nine items there would be a thicket. + * + * Edits go through `executeEdits` (toolbar/palette) or a `WorkspaceEdit` (the + * lightbulb, which has no editor handle) — both build the replacement text via + * the one `buildNext` + `formatScoped` path, so there is a single transform + * path, only the application differs. ⌘Z restores the previous text; invalid + * JSON no-ops with a toast; `!editorReadonly` hides the actions on the published + * view, and the lightbulb is gated to the active draft. + */ + +import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main'; +import { defaultFieldType } from '@core/chart-builder'; +import { formatJson } from '@core/json-format'; +import { isJsonObject, type JsonObject } from '@core/spec-config'; +import { findViewRange } from '@core/spec-cursor'; +import { appendView, compositionArrays, type SpecPath } from '@core/spec-insert'; +import { + unwrapSingleton, + wrapInConcat, + wrapInFacet, + wrapInLayer, + wrapInRepeat, +} from '@core/spec-transforms'; +import { notify } from '../stores/NotificationStore'; +import { useSnippetStore } from '../stores/SnippetStore'; +import { boundColumns } from './active-dataset'; +import { parseSpecObject } from './spec-config-actions'; + +/** The composition operators the wrap actions offer. */ +type WrapKind = 'layer' | 'hconcat' | 'vconcat' | 'facet' | 'repeat'; + +/** The slice of the document a transform reads and rewrites. */ +interface Scope { + range: monaco.Range; + text: string; + /** Column the slice starts at (0-based), so re-indented output stays aligned. */ + baseCol: number; +} + +const isEmptyRange = (r: monaco.IRange): boolean => + r.startLineNumber === r.endLineNumber && r.startColumn === r.endColumn; + +/** The whole document, as a scope. */ +function wholeDocument(model: monaco.editor.ITextModel): Scope { + const range = model.getFullModelRange(); + return { range, text: model.getValue(), 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. + */ +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, + }); + 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 }; +} + +/** Indent every line after the first by `baseCol`, so a scoped edit stays aligned. */ +function reindent(text: string, baseCol: number): string { + if (baseCol <= 0) return text; + const pad = ' '.repeat(baseCol); + return text + .split('\n') + .map((line, i) => (i === 0 ? line : pad + line)) + .join('\n'); +} + +/** Serialize the replacement in the app's compact JSON style, aligned to the scope. */ +function formatScoped(model: monaco.editor.ITextModel, scope: Scope, next: JsonObject): string { + const raw = JSON.stringify(next); + const formatted = formatJson(raw, { indent: model.getOptions().tabSize }) ?? raw; + return reindent(formatted, scope.baseCol); +} + +/** A categorical column to facet by (first nominal/ordinal), or a placeholder. */ +function defaultFacet(): { field: string; type: string } { + const cols = boundColumns(); + const categorical = cols.find((c) => { + const t = defaultFieldType(c.type); + return t === 'nominal' || t === 'ordinal'; + }); + const col = categorical ?? cols[0]; + return col + ? { field: col.name, type: defaultFieldType(col.type) } + : { field: 'field', type: 'nominal' }; +} + +/** Quantitative columns to repeat over + the channel to rewire, with fallbacks. */ +function defaultRepeat(spec: JsonObject): { fields: string[]; channel: string | null } { + const cols = boundColumns(); + const numeric = cols + .filter((c) => defaultFieldType(c.type) === 'quantitative') + .map((c) => c.name); + const fields = numeric.length > 0 ? numeric.slice(0, 3) : cols.slice(0, 2).map((c) => c.name); + const encoding = isJsonObject(spec.encoding) ? spec.encoding : null; + const channel = encoding ? ('y' in encoding ? 'y' : (Object.keys(encoding)[0] ?? null)) : null; + 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 { + switch (kind) { + case 'layer': + return wrapInLayer(spec); + case 'hconcat': + return wrapInConcat(spec, 'h'); + case 'vconcat': + return wrapInConcat(spec, 'v'); + case 'facet': { + const { field, type } = defaultFacet(); + return wrapInFacet(spec, field, type); + } + case 'repeat': { + const { fields, channel } = defaultRepeat(spec); + return wrapInRepeat(spec, fields, channel); + } + } +} + +const WRAP_NOUN: Record = { + layer: 'a layer', + hconcat: 'a horizontal concat', + vconcat: 'a vertical concat', + facet: 'a facet', + repeat: 'a repeat', +}; + +/** Replace the scope as one undoable edit (the toolbar / palette path). */ +function writeBack( + editor: monaco.editor.IStandaloneCodeEditor, + range: monaco.Range, + text: string, +): void { + editor.pushUndoStop(); + editor.executeEdits('spec-transform', [{ range, text }]); + editor.pushUndoStop(); + editor.focus(); +} + +/** + * The model, focused scope, and the spec parsed off it — the shared prologue of + * the scoped actions, or null (after toasting on invalid JSON) when there is + * nothing to act on. + */ +function resolveTarget( + editor: monaco.editor.IStandaloneCodeEditor, +): { model: monaco.editor.ITextModel; scope: Scope; spec: JsonObject } | null { + const model = editor.getModel(); + if (!model) return null; + const scope = resolveScope(model, editor.getSelection()); + const spec = parseSpecObject(scope.text); + return spec ? { model, scope, spec } : null; +} + +/** Wrap the focused view (selection, else whole document) in a composition. */ +export function runWrap(editor: monaco.editor.IStandaloneCodeEditor, kind: WrapKind): void { + const target = resolveTarget(editor); + if (!target) return; + const { model, scope, spec } = target; + writeBack(editor, scope.range, formatScoped(model, scope, buildNext(spec, kind))); + notify({ + kind: 'success', + title: 'View wrapped', + message: `Wrapped in ${WRAP_NOUN[kind]}. Undo with ⌘/Ctrl+Z.`, + }); +} + +/** Collapse a single-child layer/concat in the focused scope back to a unit. */ +export function runUnwrap(editor: monaco.editor.IStandaloneCodeEditor): void { + const target = resolveTarget(editor); + if (!target) return; + const { model, scope, spec } = target; + const next = unwrapSingleton(spec); + if (!next) { + notify({ + kind: 'info', + title: 'Nothing to simplify', + message: 'Select a layer or concat with a single child to collapse it.', + }); + return; + } + writeBack(editor, scope.range, formatScoped(model, scope, next)); + notify({ + kind: 'success', + title: 'Composition simplified', + message: 'Collapsed the single-child composition. Undo with ⌘/Ctrl+Z.', + }); +} + +/** Append an empty view to the composition at `path` (the CodeLens affordance). */ +function runAddView(editor: monaco.editor.IStandaloneCodeEditor, path: SpecPath): void { + const model = editor.getModel(); + if (!model) return; + const spec = parseSpecObject(model.getValue()); + if (!spec) return; + const next = appendView(spec, path); + if (!next) { + notify({ + kind: 'info', + title: 'Could not add a view', + message: 'The composition changed — try the affordance again.', + }); + return; + } + // Whole-document edit (the change is structural and deep); reformatted in the + // app's compact style, which is idempotent on an already-formatted draft. + writeBack(editor, model.getFullModelRange(), formatScoped(model, wholeDocument(model), next)); + notify({ + kind: 'success', + title: 'View added', + message: `Added an empty view to the ${path[path.length - 1]}. Undo with ⌘/Ctrl+Z.`, + }); +} + +/** + * Register the "+ Add view" CodeLens over each composition array (per editor — + * the lens command needs the editor handle to apply the edit). Returns a + * disposable; dispose on unmount. + */ +export function installSpecTransformCodeLens( + editor: monaco.editor.IStandaloneCodeEditor, +): monaco.IDisposable { + const addViewCommand = editor.addCommand(0, (_accessor, path: SpecPath) => + runAddView(editor, path), + ); + const provider = monaco.languages.registerCodeLensProvider('json', { + provideCodeLenses(model) { + if (useSnippetStore.getState().editorView !== 'draft') return { lenses: [], dispose() {} }; + const lenses = compositionArrays(model.getValue()).map((array) => { + const { lineNumber } = model.getPositionAt(array.offset); + return { + range: new monaco.Range(lineNumber, 1, lineNumber, 1), + command: { + id: addViewCommand ?? '', + title: '$(add) Add view', + arguments: [array.path], + }, + }; + }); + return { lenses, dispose() {} }; + }, + }); + return { dispose: () => provider.dispose() }; +} + +/** + * Register the F1-palette actions on the editor (the keyboard accelerator). + * Returns a disposable; dispose on editor unmount, like the other per-editor + * installs. Deliberately no `contextMenuGroupId` — see the module header. + */ +export function installSpecTransformActions( + editor: monaco.editor.IStandaloneCodeEditor, +): monaco.IDisposable { + const actions = [ + editor.addAction({ + id: 'astrolabe.wrap-layer', + label: 'Wrap View in a Layer', + precondition: '!editorReadonly', + run: () => runWrap(editor, 'layer'), + }), + editor.addAction({ + id: 'astrolabe.wrap-hconcat', + label: 'Wrap View in Horizontal Concat', + precondition: '!editorReadonly', + run: () => runWrap(editor, 'hconcat'), + }), + editor.addAction({ + id: 'astrolabe.wrap-vconcat', + label: 'Wrap View in Vertical Concat', + precondition: '!editorReadonly', + run: () => runWrap(editor, 'vconcat'), + }), + editor.addAction({ + id: 'astrolabe.wrap-facet', + label: 'Wrap View in a Facet', + precondition: '!editorReadonly', + run: () => runWrap(editor, 'facet'), + }), + editor.addAction({ + id: 'astrolabe.wrap-repeat', + label: 'Wrap View in a Repeat', + precondition: '!editorReadonly', + run: () => runWrap(editor, 'repeat'), + }), + editor.addAction({ + id: 'astrolabe.unwrap', + label: 'Simplify Single-Child Composition', + precondition: '!editorReadonly', + run: () => runUnwrap(editor), + }), + ]; + return { + dispose() { + for (const action of actions) action.dispose(); + }, + }; +} + +/** + * The lightbulb has no editor handle, so it returns a `WorkspaceEdit` instead of + * calling `executeEdits`; both paths build the replacement through `buildNext` + + * `formatScoped`. + */ +function editAction( + model: monaco.editor.ITextModel, + scope: Scope, + title: string, + next: JsonObject, +): monaco.languages.CodeAction { + return { + title, + kind: 'refactor.rewrite', + edit: { + edits: [ + { + resource: model.uri, + versionId: model.getVersionId(), + textEdit: { range: scope.range, text: formatScoped(model, scope, next) }, + }, + ], + }, + }; +} + +let codeActionsRegistered = false; + +/** + * Register the wrap/simplify refactors as code actions (the lightbulb) once, + * globally for JSON — like the schema and formatter, not per editor. Idempotent. + */ +export function configureSpecTransformCodeActions(): void { + if (codeActionsRegistered) return; + codeActionsRegistered = true; + + monaco.languages.registerCodeActionProvider('json', { + provideCodeActions(model, range) { + const empty = { actions: [], dispose() {} }; + // Global provider, no editor handle: gate on the store the way the run* + // path is gated by `!editorReadonly` — only on the active snippet's draft. + const snippet = useSnippetStore.getState(); + if (snippet.activeSnippetId === null || snippet.editorView !== 'draft') return empty; + + const scope = resolveScope(model, range); + let spec: unknown; + try { + spec = JSON.parse(scope.text); + } catch { + return empty; + } + if (!isJsonObject(spec)) return empty; + + 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')), + ]; + const collapsed = unwrapSingleton(spec); + if (collapsed) + actions.push(editAction(model, scope, 'Simplify single-child composition', collapsed)); + + return { actions, dispose() {} }; + }, + }); +} diff --git a/src/core/spec-cursor.test.ts b/src/core/spec-cursor.test.ts new file mode 100644 index 0000000..5fb92d5 --- /dev/null +++ b/src/core/spec-cursor.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { findViewRange, valueKeyAtOffset } from './spec-cursor'; + +/** Parse the slice a range points at, for asserting which view was targeted. */ +function sliceObject(text: string, range: { offset: number; length: number }) { + return JSON.parse(text.slice(range.offset, range.offset + range.length)) as Record< + string, + unknown + >; +} + +const layered = JSON.stringify( + { + $schema: 'https://vega.github.io/schema/vega-lite/v6.json', + description: 'A simple bar chart.', + data: { values: [{ category: 'A', value: 28 }] }, + layer: [ + { + name: 'outer1', + mark: 'bar', + encoding: { + x: { field: 'category', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + }, + }, + { mark: 'point', encoding: {}, name: 'outer2' }, + ], + }, + null, + 2, +); + +describe('findViewRange', () => { + it('targets the layer element the cursor sits in, not the whole doc', () => { + const at = layered.indexOf('"outer2"'); // cursor inside the second layer element + const range = findViewRange(layered, at)!; + expect(range).not.toBeNull(); + expect(sliceObject(layered, range).name).toBe('outer2'); + }); + + it('climbs out of a deeply nested property to the enclosing view', () => { + const at = layered.indexOf('"nominal"'); // deep inside outer1's x encoding + const range = findViewRange(layered, at)!; + expect(sliceObject(layered, range).name).toBe('outer1'); + }); + + it('returns null at a top-level key (caller wraps the whole document)', () => { + const at = layered.indexOf('"description"'); + expect(findViewRange(layered, at)).toBeNull(); + }); + + it('returns null for a flat unit spec (no inner view)', () => { + const unit = JSON.stringify( + { mark: 'bar', encoding: { x: { field: 'a', type: 'nominal' } } }, + null, + 2, + ); + expect(findViewRange(unit, unit.indexOf('"field"'))).toBeNull(); + }); + + it('targets the facet/repeat child spec', () => { + const faceted = JSON.stringify( + { facet: { field: 'g', type: 'nominal' }, spec: { mark: 'bar', encoding: {} } }, + null, + 2, + ); + const range = findViewRange(faceted, faceted.indexOf('"bar"'))!; + expect(sliceObject(faceted, range).mark).toBe('bar'); + }); + + it('still resolves a complete inner view while the outer doc is mid-edit', () => { + // Trailing junk makes the whole document unparseable; the inner object is intact. + const broken = layered.replace(/}\s*$/, '} ,'); + const at = broken.indexOf('"outer2"'); + const range = findViewRange(broken, at)!; + expect(sliceObject(broken, range).name).toBe('outer2'); + }); +}); + +describe('valueKeyAtOffset', () => { + const spec = JSON.stringify( + { mark: 'bar', encoding: { x: { field: 'category', type: 'nominal' } }, transform: [] }, + null, + 2, + ); + + it('reports the property whose value the cursor is in', () => { + // inside the "category" value of "field": "category" + const at = spec.indexOf('category') + 2; + expect(valueKeyAtOffset(spec, at)).toBe('field'); + }); + + it('reports the array key for an element position', () => { + const grouped = JSON.stringify( + { transform: [{ aggregate: [], groupby: ['a', 'b'] }] }, + null, + 2, + ); + const at = grouped.indexOf('"b"') + 1; + expect(valueKeyAtOffset(grouped, at)).toBe('groupby'); + }); + + it('is null on a property key, not its value', () => { + const at = spec.indexOf('"field"') + 1; // on the key itself + expect(valueKeyAtOffset(spec, at)).toBe(null); + }); +}); diff --git a/src/core/spec-cursor.ts b/src/core/spec-cursor.ts new file mode 100644 index 0000000..7dae7f1 --- /dev/null +++ b/src/core/spec-cursor.ts @@ -0,0 +1,95 @@ +/** + * Cursor → enclosing-view mapping (docs/architecture/08 → editor augmentation). + * + * Portable core: no browser APIs, no React, no Monaco — just `jsonc-parser`'s + * error-tolerant scanner (so this keeps working while the draft is briefly + * unparseable mid-edit). Given the spec text and a cursor offset, it finds the + * range of the *view* the cursor sits in, so a structural transform can act on + * "the element I'm focused on" rather than the whole document. + * + * A **view** is a spec object you can meaningfully wrap in a composition: an + * element of a `layer`/`hconcat`/`vconcat`/`concat` array, or the `spec` child of + * a facet/repeat. Encoding blocks, `data`, a single `field` — the other objects + * the cursor might land in — are not views and are skipped; the walk climbs to + * the nearest enclosing one. When the cursor is in a top-level unit spec (no + * nesting), there is no inner view and the result is null — the caller falls back + * to the whole document, which is the right target for a flat spec. + * + * Only the byte range is returned; turning it into a Monaco range is the editor + * integration's job (app/services/spec-transform-actions). + */ + +import { findNodeAtOffset, getLocation, parseTree, type Node } from 'jsonc-parser'; +import { ARRAY_COMPOSITIONS } from './spec-transforms'; + +/** A byte range into the spec text. */ +interface NodeRange { + offset: number; + length: number; +} + +/** The property key a node sits under, when it is a property's value. */ +function propertyKey(node: Node | undefined): string | undefined { + if (node?.type === 'property') { + const key = node.children?.[0]; + return typeof key?.value === 'string' ? key.value : undefined; + } + return undefined; +} + +/** + * Is this object node a view — an element of a composition array, or a facet / + * repeat `spec` child? (The object's parent is the array/property that frames it.) + */ +function isViewObject(node: Node): boolean { + const parent = node.parent; + if (!parent) return false; // the root object: not an *inner* view + if (parent.type === 'property') return propertyKey(parent) === 'spec'; + if (parent.type === 'array') { + const key = propertyKey(parent.parent); + return key !== undefined && ARRAY_COMPOSITIONS.includes(key); + } + return false; +} + +/** + * The range of the nested view enclosing `offset`, or null when the cursor is not + * inside one (a flat unit spec, or whitespace between top-level keys) — the + * caller then targets the whole document. + */ +export function findViewRange(text: string, offset: number): NodeRange | null { + const tree = parseTree(text); + if (!tree) return null; + let node: Node | undefined = findNodeAtOffset(tree, offset); + while (node) { + if (node.type === 'object' && isViewObject(node)) { + return { offset: node.offset, length: node.length }; + } + node = node.parent; + } + return null; +} + +/** + * 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 + * the key is the array's property (so a `groupby: ["a", "b|"]` element reports + * `groupby`). Drives the field-position field hints — "am I completing a + * `field`?". Error-tolerant, so it works mid-edit. + */ +export function valueKeyAtOffset(text: string, offset: number): string | null { + const { path, isAtPropertyKey } = getLocation(text, offset); + if (isAtPropertyKey || path.length === 0) return null; + const last = path[path.length - 1]; + // An array element reports the array's own key (e.g. groupby[i] → "groupby"). + const key = typeof last === 'number' ? path[path.length - 2] : last; + return typeof key === 'string' ? key : null; +} + +/** The string literal the cursor is inside, or null when it is not on a string. */ +export function stringValueAtOffset(text: string, offset: number): string | null { + const tree = parseTree(text); + if (!tree) return null; + const node = findNodeAtOffset(tree, offset); + return node?.type === 'string' && typeof node.value === 'string' ? node.value : null; +} diff --git a/src/core/spec-fields.test.ts b/src/core/spec-fields.test.ts new file mode 100644 index 0000000..d8fd94c --- /dev/null +++ b/src/core/spec-fields.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { derivedFieldNames } from './spec-fields'; + +describe('derivedFieldNames', () => { + it('collects calculate / timeUnit / bin as-names', () => { + const spec = { + 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']); + }); + + it('reaches into op-list transforms (aggregate/window/joinaggregate)', () => { + const spec = { + 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']); + }); + + it('defaults fold output to key/value when as is omitted', () => { + expect(derivedFieldNames({ 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']); + }); + + it('returns nothing for a spec with no transforms', () => { + expect(derivedFieldNames({ mark: 'bar', encoding: { x: { field: 'a' } } })).toEqual([]); + }); +}); diff --git a/src/core/spec-fields.ts b/src/core/spec-fields.ts new file mode 100644 index 0000000..94beef1 --- /dev/null +++ b/src/core/spec-fields.ts @@ -0,0 +1,68 @@ +/** + * Fields a spec introduces through its own transforms (docs/architecture/08 → + * editor augmentation). + * + * Portable core: pure spec analysis, no data. The editor's field hints + * (completion/hover/inlay) suggest the bound dataset's source columns *plus* the + * fields the spec derives — so a column you just created with a `calculate` is + * 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. + * + * Out of scope by design: **data-dependent** derived columns — `pivot`'s + * one-column-per-value output and `lookup`'s imported fields — which only exist + * once the pipeline runs over the actual data. Naming those would mean executing + * transforms, far beyond a static hint. + */ + +/** Add an `as` value (a string, or the `[start, end]` / key-value pair array). */ +function addAs(names: Set, as: unknown): void { + if (typeof as === 'string') names.add(as); + else if (Array.isArray(as)) for (const a of as) if (typeof a === 'string') names.add(a); +} + +/** Collect the field name(s) a single transform entry introduces. */ +function collectFromTransform(transform: unknown, names: Set): void { + if (transform === null || typeof transform !== 'object') return; + const t = transform as Record; + addAs(names, t.as); + // `fold` defaults its output to ['key', 'value'] when `as` is omitted. + if ('fold' in t && !('as' in t)) { + names.add('key'); + names.add('value'); + } + // Op-list transforms nest their `as` inside each operation. + for (const key of ['aggregate', 'window', 'joinaggregate']) { + const ops = t[key]; + if (Array.isArray(ops)) { + for (const op of ops) { + if (op !== null && typeof op === 'object') addAs(names, (op as Record).as); + } + } + } +} + +/** + * Every field name the spec derives via its transforms, de-duplicated. Accepts a + * parsed spec object (callers parse the draft once). + */ +export function derivedFieldNames(spec: unknown): string[] { + const names = new Set(); + const walk = (node: unknown): void => { + if (Array.isArray(node)) { + for (const item of node) walk(item); + return; + } + if (node !== null && typeof node === 'object') { + const obj = node as Record; + 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]; +} diff --git a/src/core/spec-inline-data.test.ts b/src/core/spec-inline-data.test.ts new file mode 100644 index 0000000..19d799c --- /dev/null +++ b/src/core/spec-inline-data.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { inlineDataRows } 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 }]); + }); + + 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('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(); + }); +}); diff --git a/src/core/spec-inline-data.ts b/src/core/spec-inline-data.ts new file mode 100644 index 0000000..77d0116 --- /dev/null +++ b/src/core/spec-inline-data.ts @@ -0,0 +1,70 @@ +/** + * 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. + * + * 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. + */ + +import { isJsonObject } from './spec-config'; + +/** An array of row objects, or null when the value is not tabular inline data. */ +function asRows(value: unknown): Record[] | null { + if (Array.isArray(value) && value.length > 0 && value.every(isJsonObject)) { + return value; + } + return null; +} + +/** Rows from a `data` object's `values`, or null. */ +function rowsFromData(data: unknown): Record[] | null { + return isJsonObject(data) ? asRows(data.values) : null; +} + +/** The first `data.values` reachable from `node`, top level before nested. */ +function firstDataValues(node: unknown): Record[] | 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): Record[] | 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. + */ +export function inlineDataRows(spec: unknown): Record[] | null { + if (!isJsonObject(spec)) return null; + return firstDataValues(spec) ?? firstNamedDataset(spec); +} diff --git a/src/core/spec-insert.test.ts b/src/core/spec-insert.test.ts new file mode 100644 index 0000000..98ef76d --- /dev/null +++ b/src/core/spec-insert.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; +import { appendView, compositionArrays } from './spec-insert'; + +const layered = JSON.stringify( + { + data: { name: 'd' }, + hconcat: [{ layer: [{ mark: 'bar' }] }, { mark: 'point' }], + }, + null, + 2, +); + +describe('compositionArrays', () => { + it('finds every composition array with its path, including nested', () => { + const found = compositionArrays(layered); + const byKey = found.map((c) => ({ key: c.key, path: c.path })); + expect(byKey).toContainEqual({ key: 'hconcat', path: ['hconcat'] }); + expect(byKey).toContainEqual({ key: 'layer', path: ['hconcat', 0, 'layer'] }); + }); + + it('returns offsets that point inside the source text', () => { + const [first] = compositionArrays(layered); + expect(layered[first.offset]).toBe('['); // the array node starts at its bracket + }); + + it('is empty for a flat unit spec', () => { + expect(compositionArrays(JSON.stringify({ mark: 'bar' }))).toEqual([]); + }); +}); + +describe('appendView', () => { + it('adds a placeholder view to the array at the given path', () => { + const spec = { hconcat: [{ mark: 'bar' }] }; + const out = appendView(spec, ['hconcat'])!; + expect(out.hconcat).toEqual([{ mark: 'bar' }, { mark: 'point', encoding: {} }]); + expect(spec.hconcat).toHaveLength(1); // input untouched + }); + + it('reaches a nested composition array', () => { + const spec = { hconcat: [{ layer: [{ mark: 'bar' }] }] }; + const out = appendView(spec, ['hconcat', 0, 'layer'])!; + const layer = (out.hconcat as { layer: unknown[] }[])[0].layer; + expect(layer).toHaveLength(2); + }); + + it('returns null when the path is not an array', () => { + expect(appendView({ mark: 'bar' }, ['layer'])).toBeNull(); + }); +}); diff --git a/src/core/spec-insert.ts b/src/core/spec-insert.ts new file mode 100644 index 0000000..4b54b67 --- /dev/null +++ b/src/core/spec-insert.ts @@ -0,0 +1,72 @@ +/** + * Locating composition arrays and appending a view to one (docs/architecture/08 → + * editor augmentation). Powers the "+ Add view" CodeLens: an always-visible + * affordance over each `layer`/`hconcat`/`vconcat`/`concat` for adding a sibling + * view — the complement to the wrap refactors (which create a composition; this + * grows an existing one). + * + * Portable core. `compositionArrays` uses jsonc-parser to find each array and its + * path (so the CodeLens knows where to sit and what to grow); `appendView` is a + * plain immutable push at that path. JSON-text formatting is the editor's job. + */ + +import { parseTree, type Node } from 'jsonc-parser'; +import { isJsonObject, type JsonObject } from './spec-config'; +import { ARRAY_COMPOSITIONS, placeholderView } from './spec-transforms'; + +/** A spec path: object keys and array indices, from the root. */ +export type SpecPath = (string | number)[]; + +/** A composition array found in the spec text. */ +interface CompositionArray { + /** The operator key (`layer`, `hconcat`, …). */ + key: string; + /** Start offset of the array node, for placing the affordance. */ + offset: number; + /** Path to the array, for `appendView`. */ + path: SpecPath; +} + +/** Every composition array in the spec, with its path and start offset. */ +export function compositionArrays(text: string): CompositionArray[] { + const tree = parseTree(text); + if (!tree) return []; + const found: CompositionArray[] = []; + + const walk = (node: Node, path: SpecPath): void => { + if (node.type === 'object') { + for (const prop of node.children ?? []) { + const key: unknown = prop.children?.[0]?.value; + const value = prop.children?.[1]; + if (typeof key !== 'string' || !value) continue; + if (ARRAY_COMPOSITIONS.includes(key) && value.type === 'array') { + found.push({ key, offset: value.offset, path: [...path, key] }); + } + walk(value, [...path, key]); + } + } else if (node.type === 'array') { + (node.children ?? []).forEach((child, i) => walk(child, [...path, i])); + } + }; + + walk(tree, []); + return found; +} + +/** + * Append an empty placeholder view to the array at `path`. Returns a new spec, or + * null when the path does not lead to an array (the text changed since the path + * was computed). Input is not mutated. + */ +export function appendView(spec: JsonObject, path: SpecPath): JsonObject | null { + const next = JSON.parse(JSON.stringify(spec)) as JsonObject; + let node: unknown = next; + for (const segment of path) { + if (Array.isArray(node)) node = node[segment as number]; + else if (isJsonObject(node)) node = node[segment as string]; + else return null; + } + if (!Array.isArray(node)) return null; + node.push(placeholderView()); + return next; +} diff --git a/src/core/spec-transforms.test.ts b/src/core/spec-transforms.test.ts new file mode 100644 index 0000000..96512ad --- /dev/null +++ b/src/core/spec-transforms.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import { + unwrapSingleton, + wrapInConcat, + wrapInFacet, + wrapInLayer, + wrapInRepeat, +} from './spec-transforms'; + +const unit = () => ({ + $schema: 'https://vega.github.io/schema/vega-lite/v6.json', + data: { name: 'weather' }, + title: 'Rainfall', + width: 400, + height: 300, + mark: 'bar', + encoding: { + x: { field: 'date', type: 'temporal' }, + y: { field: 'precipitation', type: 'quantitative' }, + }, +}); + +describe('wrapInLayer', () => { + it('moves the view into layer[0] and adds an empty layer', () => { + const out = wrapInLayer(unit()); + expect(Array.isArray(out.layer)).toBe(true); + const layers = out.layer as Record[]; + expect(layers).toHaveLength(2); + expect(layers[0].mark).toBe('bar'); + expect(layers[0].encoding).toBeDefined(); + expect(layers[1]).toEqual({ mark: 'point', encoding: {} }); + }); + + it('keeps data, title, and the shared plotting size on the wrapper', () => { + const out = wrapInLayer(unit()); + expect(out.data).toEqual({ name: 'weather' }); + expect(out.title).toBe('Rainfall'); + expect(out.width).toBe(400); // layers share one plotting area + expect(out.height).toBe(300); + const layers = out.layer as Record[]; + expect(layers[0]).not.toHaveProperty('width'); + }); + + it('does not mutate the input', () => { + const spec = unit(); + wrapInLayer(spec); + expect(spec).not.toHaveProperty('layer'); + expect(spec.mark).toBe('bar'); + }); +}); + +describe('wrapInConcat', () => { + it('wraps into hconcat/vconcat with an empty sibling view', () => { + expect(wrapInConcat(unit(), 'h')).toHaveProperty('hconcat'); + const out = wrapInConcat(unit(), 'v'); + const views = out.vconcat as Record[]; + expect(views).toHaveLength(2); + expect(views[1]).toEqual({ mark: 'point', encoding: {} }); + }); + + it('pushes the size down to each concat view (they size independently)', () => { + const out = wrapInConcat(unit(), 'h'); + expect(out).not.toHaveProperty('width'); + const views = out.hconcat as Record[]; + expect(views[0].width).toBe(400); + expect(out.data).toEqual({ name: 'weather' }); // data still shared on top + }); +}); + +describe('wrapInFacet', () => { + it('lifts data to the wrapper and holds the view as spec', () => { + const out = wrapInFacet(unit(), 'weather', 'nominal'); + expect(out.facet).toEqual({ field: 'weather', type: 'nominal' }); + expect(out.data).toEqual({ name: 'weather' }); + const child = out.spec as Record; + expect(child.mark).toBe('bar'); + expect(child).not.toHaveProperty('data'); + }); +}); + +describe('wrapInRepeat', () => { + it('rewires the named channel to the repeat reference', () => { + const out = wrapInRepeat(unit(), ['precipitation', 'wind'], 'y'); + expect(out.repeat).toEqual(['precipitation', 'wind']); + const child = out.spec as { encoding: Record> }; + expect(child.encoding.y.field).toEqual({ repeat: 'repeat' }); + expect(child.encoding.x.field).toBe('date'); // other channels untouched + }); + + it('wraps as-is when the channel is absent or unspecified', () => { + const out = wrapInRepeat(unit(), ['a', 'b'], null); + const child = out.spec as { encoding: Record> }; + expect(child.encoding.y.field).toBe('precipitation'); + }); +}); + +describe('unwrapSingleton', () => { + it('collapses only a single-child array composition', () => { + expect(unwrapSingleton({ layer: [{ mark: 'bar' }] })).toEqual({ mark: 'bar' }); + expect(unwrapSingleton({ hconcat: [{ mark: 'bar' }] })).toEqual({ mark: 'bar' }); + expect(unwrapSingleton({ layer: [{ mark: 'bar' }, { mark: 'line' }] })).toBeNull(); + expect(unwrapSingleton(unit())).toBeNull(); + }); + + it('collapses the lone child up, child keys winning', () => { + const wrapped = { + data: { name: 'weather' }, + layer: [{ data: { name: 'other' }, mark: 'bar' }], + }; + const out = unwrapSingleton(wrapped); + expect(out).toEqual({ data: { name: 'other' }, mark: 'bar' }); + expect(out).not.toHaveProperty('layer'); + }); + + it('round-trips a freshly wrapped-then-trimmed layer back to the unit', () => { + const wrapped = wrapInLayer(unit()); + (wrapped.layer as unknown[]).pop(); // user deletes the placeholder layer + const out = unwrapSingleton(wrapped)!; + expect(out.mark).toBe('bar'); + expect(out.width).toBe(400); + expect(out).not.toHaveProperty('layer'); + }); + + it('returns null when there is nothing to collapse', () => { + expect(unwrapSingleton(unit())).toBeNull(); + }); +}); diff --git a/src/core/spec-transforms.ts b/src/core/spec-transforms.ts new file mode 100644 index 0000000..732c5f3 --- /dev/null +++ b/src/core/spec-transforms.ts @@ -0,0 +1,145 @@ +/** + * Structural spec transforms (docs/architecture/08 → editor augmentation). + * + * Pure object-in/object-out rewrites that wrap a Vega-Lite view in a composition + * operator — layer, hconcat, vconcat, facet, repeat — or collapse a single-child + * composition back to a unit. These are the structural edits that are awkward to + * make by hand in JSON and out of reach of the visual builder (which is + * single-view); the editor surfaces them as refactor actions + * (app/services/spec-transform-actions). JSON text handling (parse, format, undo) + * is the editor integration's job, exactly as for spec-config. + * + * Each wrap partitions the source spec's keys: the *shared* top-level keys (data, + * $schema, title, config, …) stay on the wrapper, and the *view* keys (mark, + * encoding, …) move into the new child. The partition differs per operator — + * layered children share the plotting area, so width/height/view stay on top; + * concat children are independent specs that size themselves, so those move down; + * facet/repeat lift the data to the wrapper and hold a single child `spec`. The + * result is the user's compact authored form, never vega-lite's normalized + * expansion. + */ + +import { isJsonObject, type JsonObject } from './spec-config'; + +/** Concat direction: horizontal or vertical. */ +type ConcatDir = 'h' | 'v'; + +/** + * The array-valued composition operators — the single source of this domain fact, + * shared with the cursor (`spec-cursor`) and insertion (`spec-insert`) walks. + */ +export const ARRAY_COMPOSITIONS: readonly string[] = ['layer', 'hconcat', 'vconcat', 'concat']; + +/** + * Keys that belong to the wrapper for every operator: pure top-level metadata + * plus the shared data source and styling. `data` stays on top because layered, + * concatenated, faceted, and repeated children all inherit the parent's data. + */ +const SHARED_TOP = [ + '$schema', + 'name', + 'description', + 'title', + 'config', + 'usermeta', + 'background', + 'padding', + 'autosize', + 'datasets', + 'data', + 'resolve', + 'params', +]; + +/** Layers share one plotting area, so the size/view also stay on the wrapper. */ +const LAYER_TOP = [...SHARED_TOP, 'width', 'height', 'view']; + +/** A fresh placeholder view for the empty slot a wrap (or add-view) opens up. */ +export const placeholderView = (): JsonObject => ({ mark: 'point', encoding: {} }); + +/** Split a spec's keys into those that stay on the wrapper and the rest. */ +function partition( + spec: JsonObject, + topKeys: readonly string[], +): { top: JsonObject; view: JsonObject } { + const topSet = new Set(topKeys); + const top: JsonObject = {}; + const view: JsonObject = {}; + for (const key of Object.keys(spec)) { + if (topSet.has(key)) top[key] = spec[key]; + else view[key] = spec[key]; + } + return { top, view }; +} + +/** Wrap the view in a two-layer composition (the view plus an empty layer). */ +export function wrapInLayer(spec: JsonObject): JsonObject { + const { top, view } = partition(spec, LAYER_TOP); + return { ...top, layer: [view, placeholderView()] }; +} + +/** Wrap the view in a horizontal or vertical concat (the view plus an empty view). */ +export function wrapInConcat(spec: JsonObject, dir: ConcatDir): JsonObject { + const key = dir === 'h' ? 'hconcat' : 'vconcat'; + const { top, view } = partition(spec, SHARED_TOP); + return { ...top, [key]: [view, placeholderView()] }; +} + +/** + * Wrap the view in a facet (small multiples across `field`). The caller resolves + * a sensible field + type from the bound dataset; the field is left editable. + */ +export function wrapInFacet(spec: JsonObject, field: string, type: string): JsonObject { + const { top, view } = partition(spec, SHARED_TOP); + return { ...top, facet: { field, type }, spec: view }; +} + +/** + * Wrap the view in a repeat (the same chart across `fields`). When `channel` is + * given and the view encodes it, that channel's field is rewired to + * `{ repeat: 'repeat' }` so the repeat actually varies the chart; otherwise the + * view is wrapped as-is for the caller to wire up. + */ +export function wrapInRepeat( + spec: JsonObject, + fields: string[], + channel: string | null, +): JsonObject { + const { top, view } = partition(spec, SHARED_TOP); + if (channel && isJsonObject(view.encoding)) { + const encoding = { ...view.encoding }; + if (isJsonObject(encoding[channel])) { + encoding[channel] = { ...encoding[channel], field: { repeat: 'repeat' } }; + view.encoding = encoding; + } + } + return { ...top, repeat: fields, spec: view }; +} + +/** + * The single array-composition key that holds exactly one child, or null — the + * gate for `unwrapSingleton`: a `layer`/`concat` whittled down to one member is + * the redundant structure worth collapsing. + */ +function unwrappableKey(spec: JsonObject): string | null { + for (const key of ARRAY_COMPOSITIONS) { + const value = spec[key]; + if (Array.isArray(value) && value.length === 1 && isJsonObject(value[0])) return key; + } + return null; +} + +/** + * Collapse a single-child `layer`/`concat` back into a unit: the lone child is + * merged up onto the wrapper, the child's keys winning on conflict (it is the + * more specific view). Returns null when there is no single-child composition to + * collapse — the inverse of the wraps above. + */ +export function unwrapSingleton(spec: JsonObject): JsonObject | null { + const key = unwrappableKey(spec); + if (!key) return null; + const child = (spec[key] as JsonObject[])[0]; + const rest: JsonObject = {}; + for (const k of Object.keys(spec)) if (k !== key) rest[k] = spec[k]; + return { ...rest, ...child }; +}