Editor: spec transforms (wrap/simplify/add-view) and dataset-aware hints

This commit is contained in:
2026-06-28 16:56:20 +03:00
parent 20e70bee0e
commit 31458114fb
20 changed files with 2498 additions and 7 deletions
+1 -1
View File
@@ -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`). |
@@ -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 |
@@ -0,0 +1,853 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Editor augmentation — Monaco interactivity sandbox</title>
<!--
Throwaway sandbox (companion to docs/architecture/08). NOT a product feature
and not maintained — Monaco is loaded from CDN so this file touches nothing
in the Astrolabe build. Open it directly in a browser (needs internet for the
CDN). It demonstrates, against a live Vega-Lite spec, every editor-augmentation
surface discussed for spec editing:
1. Code actions (the lightbulb / ⌘. ) — wrap the focused view in
layer / hconcat / vconcat; change a mark, type, or field value.
2. Context-menu + F1 command-palette actions — the same transforms.
3. CodeLens — inline " add view / add encoding" affordances.
4. Dataset-aware completion — column names + types the JSON schema can't know.
5. Hover — inferred type + sample values for a bound column.
6. Inlay hints — ghost type annotations beside each field.
7. Diagnostics → quick fix — unknown field gets a squiggle and a "did you
mean…" fix (open the spec with a deliberate typo to see it on load).
Sub-tree scoping: select a child view's JSON and the wrap targets just that
selection; with no selection it wraps the whole document. (In the app the
cursor's enclosing node is found automatically via jsonc-parser; here we keep
the sandbox dependency-free so it runs straight off the filesystem.)
This is a superset of what shipped: the unknown-field diagnostic + quick fix (7)
and the " add encoding" CodeLens (3) are options explored here but deliberately
NOT shipped — the shipped product carries no field diagnostic (it would
false-positive on derived/data-dependent fields). See architecture 08 §5.
-->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<style>
:root {
--bg: #0b0c0e;
--panel: #14161a;
--panel-2: #1b1e24;
--border: #2a2e36;
--text: #e6e8ec;
--muted: #9aa3af;
--accent: #6ea8fe;
--accent-soft: #243245;
--good: #5ad19a;
--warn: #e0b341;
}
body.light {
--bg: #f5f6f8;
--panel: #ffffff;
--panel-2: #f0f2f5;
--border: #d8dce2;
--text: #161a1f;
--muted: #5b6470;
--accent: #2f6fed;
--accent-soft: #e4ecfb;
--good: #128a5b;
--warn: #9a6b00;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
height: 100%;
}
body {
background: var(--bg);
color: var(--text);
font-family: "IBM Plex Sans", system-ui, sans-serif;
display: grid;
grid-template-rows: auto 1fr;
}
header {
display: flex;
align-items: center;
gap: 16px;
padding: 12px 18px;
border-bottom: 1px solid var(--border);
background: var(--panel);
}
header h1 {
font-size: 15px;
font-weight: 600;
margin: 0;
letter-spacing: 0.01em;
}
header .sub {
color: var(--muted);
font-size: 12.5px;
}
header .spacer {
flex: 1;
}
.control {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
color: var(--muted);
}
button,
select {
font-family: inherit;
font-size: 12.5px;
color: var(--text);
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 5px 10px;
cursor: pointer;
}
button:hover {
border-color: var(--accent);
}
main {
display: grid;
grid-template-columns: 1.55fr 1fr;
min-height: 0;
}
#editor {
min-width: 0;
border-right: 1px solid var(--border);
}
aside {
overflow-y: auto;
padding: 14px 16px 40px;
background: var(--panel);
}
aside h2 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
margin: 18px 0 8px;
}
.card {
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 11px 12px;
margin-bottom: 10px;
}
.card .name {
font-weight: 600;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.card .name .pill {
font-family: "IBM Plex Mono", monospace;
font-size: 10.5px;
font-weight: 500;
color: var(--accent);
background: var(--accent-soft);
border-radius: 5px;
padding: 2px 6px;
}
.card p {
font-size: 12.5px;
line-height: 1.5;
color: var(--muted);
margin: 7px 0 9px;
}
.card p code,
.how code {
font-family: "IBM Plex Mono", monospace;
font-size: 11.5px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
color: var(--text);
}
.card .try {
font-size: 12px;
padding: 4px 9px;
}
.intro {
font-size: 12.5px;
line-height: 1.55;
color: var(--muted);
margin: 4px 0 6px;
}
.toast {
position: fixed;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
background: var(--panel-2);
border: 1px solid var(--warn);
color: var(--text);
padding: 8px 14px;
border-radius: 8px;
font-size: 12.5px;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.toast.show {
opacity: 1;
}
</style>
</head>
<body>
<header>
<h1>Editor augmentation</h1>
<span class="sub">Monaco interactivity sandbox · companion to architecture 08</span>
<span class="spacer"></span>
<label class="control">
<input type="checkbox" id="inlay" checked />
Inlay hints
</label>
<label class="control">
Theme
<select id="theme">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</label>
<button id="reset">Reset spec</button>
</header>
<main>
<div id="editor"></div>
<aside>
<p class="intro">
A live Vega-Lite spec bound to a fake <code>weather</code> dataset
(<code>date</code>, <code>precipitation</code>, <code>temp_max</code>,
<code>temp_min</code>, <code>wind</code>, <code>weather</code>). Move the
cursor around and try each surface — every action edits the real document and
is undoable with <code>⌘/Ctrl+Z</code>.
</p>
<h2>Refactor & transform</h2>
<div class="card">
<div class="name"><span class="pill">⌘.</span> Lightbulb code actions</div>
<p>
The contextual refactor menu. Open it in the view and you'll see
<em>Wrap in layer / hconcat / vconcat</em>, plus value swaps when you're on a
<code>mark</code>, <code>type</code>, or <code>field</code> line. Select a
child view's JSON first to wrap just that part; with no selection it wraps the
whole document. This is the position-aware "give me ideas" surface.
</p>
<button class="try" data-act="quickfix">Open at cursor</button>
</div>
<div class="card">
<div class="name"><span class="pill">right-click / F1</span> Menu actions</div>
<p>
The same transforms as durable menu items — a discoverable home with the
lightbulb as the accelerator. Right-click the editor, or press
<code>F1</code> and type "wrap".
</p>
<button class="try" data-act="palette">Command palette</button>
</div>
<div class="card">
<div class="name"><span class="pill">inline</span> CodeLens</div>
<p>
Clickable affordances rendered above a line: <code> add layer</code> /
<code> add color encoding</code> over <code>"mark"</code>, and
<code> add view</code> over a composition array. Look just above the
<code>"mark"</code> line.
</p>
</div>
<h2>Beyond the schema</h2>
<div class="card">
<div class="name"><span class="pill">⌃Space</span> Dataset-aware completion</div>
<p>
In a <code>"field"</code> value, suggestions are the dataset's real columns
with their inferred types — something the Vega-Lite schema can't know. Also
augments <code>type</code>, <code>mark</code>, and <code>aggregate</code>
values. Click inside a field's quotes and press <code>⌃Space</code>.
</p>
</div>
<div class="card">
<div class="name"><span class="pill">hover</span> Field hover</div>
<p>
Hover a column name to see its inferred type and sample values pulled from
the bound dataset (merged with, not replacing, the schema's own hover).
</p>
</div>
<div class="card">
<div class="name"><span class="pill">ghost</span> Inlay hints</div>
<p>
Each <code>"field"</code> gets a faint type annotation beside it — annotation
without touching the text. Toggle it from the header.
</p>
</div>
<div class="card">
<div class="name"><span class="pill">squiggle</span> Diagnostics → quick fix</div>
<p>
A field not in the dataset gets a warning squiggle and a "Change to …" quick
fix. The starter spec ships one typo (<code>"wnd"</code>) so you can see it
immediately — open the lightbulb on that line.
</p>
</div>
<h2>Editor basics (for reference)</h2>
<div class="card">
<div class="name"><span class="pill">⇧⌥F</span> Format & misc</div>
<p>Format the document, then notice folding, multi-cursor, and the minimap all come free with Monaco.</p>
<button class="try" data-act="format">Format document</button>
</div>
</aside>
</main>
<div class="toast" id="toast"></div>
<script src="https://cdn.jsdelivr.net/npm/monaco-editor@0.54.0/min/vs/loader.js"></script>
<script>
// ---- Monaco CDN worker proxy (standard self-hosting-from-CDN snippet) ----
const CDN = "https://cdn.jsdelivr.net/npm/monaco-editor@0.54.0/min/";
self.MonacoEnvironment = {
getWorkerUrl: function () {
return (
"data:text/javascript;charset=utf-8," +
encodeURIComponent(
"self.MonacoEnvironment={baseUrl:'" +
CDN +
"'};importScripts('" +
CDN +
"vs/base/worker/workerMain.js');",
)
);
},
};
require.config({ paths: { vs: CDN + "vs" } });
// -------------------------- the fake dataset --------------------------
const COLUMNS = {
date: { type: "temporal", samples: ["2012-01-01", "2012-01-02", "2012-01-03"] },
precipitation: { type: "quantitative", samples: [0.0, 10.9, 0.8] },
temp_max: { type: "quantitative", samples: [12.8, 10.6, 11.7] },
temp_min: { type: "quantitative", samples: [5.0, 2.8, 7.2] },
wind: { type: "quantitative", samples: [4.7, 4.5, 2.3] },
weather: { type: "nominal", samples: ["drizzle", "rain", "sun"] },
};
const COLUMN_NAMES = Object.keys(COLUMNS);
const MARKS = ["bar", "line", "point", "area", "tick", "circle", "rect"];
const VL_TYPES = ["quantitative", "nominal", "ordinal", "temporal"];
const AGGREGATES = ["mean", "sum", "median", "min", "max", "count"];
const STARTER = JSON.stringify(
{
$schema: "https://vega.github.io/schema/vega-lite/v6.json",
data: { name: "weather" },
mark: "bar",
encoding: {
x: { field: "date", type: "temporal", timeUnit: "month" },
y: { field: "precipitation", type: "quantitative", aggregate: "mean" },
color: { field: "weather", type: "nominal" },
size: { field: "wnd", type: "quantitative" }, // deliberate typo → squiggle
},
},
null,
2,
);
require(["vs/editor/editor.main"], function () {
const editor = monaco.editor.create(document.getElementById("editor"), {
value: STARTER,
language: "json",
theme: "vs-dark",
automaticLayout: true,
fontFamily: "'IBM Plex Mono', ui-monospace, Menlo, monospace",
fontSize: 13,
tabSize: 2,
scrollBeyondLastLine: false,
minimap: { enabled: true },
quickSuggestions: { other: true, comments: false, strings: true },
suggestOnTriggerCharacters: true,
inlayHints: { enabled: "on" },
});
const model = editor.getModel();
// ---- Vega-Lite schema (same approach as the app's monaco-schema.ts:
// register the schema explicitly, no network schema-request service) so the
// schema's own validation / completion / hover work alongside the custom
// providers below. The spec's $schema URI is matched by fileMatch:['*'].
fetch("https://cdn.jsdelivr.net/npm/vega-lite@6.4.3/build/vega-lite-schema.json")
.then((r) => r.json())
.then((schema) => {
addMarkdownDescriptions(schema); // Monaco renders rich hovers only from markdownDescription
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
enableSchemaRequest: false,
schemas: [
{ uri: "https://vega.github.io/schema/vega-lite/v6.json", fileMatch: ["*"], schema },
],
});
})
.catch(() => toast("Couldn't load the Vega-Lite schema from CDN (offline?)."));
// ===================== helpers =====================
function toast(msg) {
const el = document.getElementById("toast");
el.textContent = msg;
el.classList.add("show");
setTimeout(() => el.classList.remove("show"), 1800);
}
// Copy each `description` to `markdownDescription` so Monaco hovers render
// the schema docs as markdown (plain `description` hovers as flat text).
function addMarkdownDescriptions(node) {
if (Array.isArray(node)) return node.forEach(addMarkdownDescriptions);
if (node && typeof node === "object") {
if (typeof node.description === "string" && node.markdownDescription === undefined)
node.markdownDescription = node.description;
for (const k of Object.keys(node)) addMarkdownDescriptions(node[k]);
}
}
function reindent(text, baseCol) {
if (!baseCol) return text;
const pad = " ".repeat(baseCol);
return text
.split("\n")
.map((l, i) => (i === 0 ? l : pad + l))
.join("\n");
}
// Move the unit-level props into the wrapper; keep shared props on top.
function wrapSpec(spec, kind) {
const top = {};
const inner = {};
const sharedForLayer = [
"$schema", "data", "width", "height", "title", "name", "description", "config", "resolve",
];
const sharedForConcat = ["$schema", "data", "title", "name", "description", "config"];
const shared = kind === "layer" ? sharedForLayer : sharedForConcat;
for (const k of Object.keys(spec)) {
if (shared.includes(k)) top[k] = spec[k];
else inner[k] = spec[k];
}
const placeholder = { mark: "point", encoding: {} };
top[kind] = [inner, placeholder];
return top;
}
// Compute (don't apply) the wrap edit. Targets the selection when there is
// one (so you can wrap a single child view), else the whole document.
// Returns null when the target text isn't a JSON object.
function planWrap(selection, kind) {
let range, srcText, baseCol;
if (selection && !selection.isEmpty()) {
range = selection;
srcText = model.getValueInRange(range);
baseCol = selection.startColumn - 1;
} else {
range = model.getFullModelRange();
srcText = model.getValue();
baseCol = 0;
}
let obj;
try {
obj = JSON.parse(srcText);
} catch {
return null;
}
if (!obj || typeof obj !== "object" || Array.isArray(obj)) return null;
return { range, text: reindent(JSON.stringify(wrapSpec(obj, kind), null, 2), baseCol) };
}
function applyWrap(kind) {
const plan = planWrap(editor.getSelection(), kind);
if (!plan) {
toast("Fix the JSON syntax first, then try again.");
return;
}
editor.pushUndoStop();
editor.executeEdits("wrap", [{ range: plan.range, text: plan.text }]);
editor.pushUndoStop();
editor.focus();
}
// The {range,value} of a "key": "value" string on a given line.
function stringValueRange(lineNumber, key) {
const line = model.getLineContent(lineNumber);
const re = new RegExp('("' + key + '"\\s*:\\s*")([^"]*)(")');
const m = re.exec(line);
if (!m) return null;
const startCol = m.index + m[1].length + 1; // 1-based col of value start
const endCol = startCol + m[2].length;
return {
range: new monaco.Range(lineNumber, startCol, lineNumber, endCol),
value: m[2],
};
}
function levenshtein(a, b) {
const dp = Array.from({ length: a.length + 1 }, (_, i) => [i]);
for (let j = 0; j <= b.length; j++) dp[0][j] = j;
for (let i = 1; i <= a.length; i++)
for (let j = 1; j <= b.length; j++)
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
);
return dp[a.length][b.length];
}
function closestColumn(name) {
let best = null;
let bestD = Infinity;
for (const c of COLUMN_NAMES) {
const d = levenshtein(name, c);
if (d < bestD) {
bestD = d;
best = c;
}
}
return bestD <= 3 ? best : null;
}
// ===================== diagnostics: unknown field =====================
function refreshMarkers() {
const markers = [];
const lineCount = model.getLineCount();
for (let ln = 1; ln <= lineCount; ln++) {
const found = stringValueRange(ln, "field");
if (found && !COLUMN_NAMES.includes(found.value)) {
const suggestion = closestColumn(found.value);
markers.push({
severity: monaco.MarkerSeverity.Warning,
message:
'"' + found.value + '" is not a column in the weather dataset' +
(suggestion ? '. Did you mean "' + suggestion + '"?' : "."),
startLineNumber: found.range.startLineNumber,
startColumn: found.range.startColumn,
endLineNumber: found.range.endLineNumber,
endColumn: found.range.endColumn,
code: "unknown-field",
});
}
}
monaco.editor.setModelMarkers(model, "astrolabe", markers);
}
editor.onDidChangeModelContent(refreshMarkers);
refreshMarkers();
// ===================== 1. code action provider =====================
monaco.languages.registerCodeActionProvider("json", {
provideCodeActions(model, range, context) {
const actions = [];
const pos = range.getStartPosition();
const line = model.getLineContent(pos.lineNumber);
// Wrap actions — available anywhere the document parses to an object.
for (const [kind, label] of [
["layer", "Wrap focused view in a layer"],
["hconcat", "Wrap focused view in horizontal concat"],
["vconcat", "Wrap focused view in vertical concat"],
]) {
const plan = planWrap(range, kind);
if (plan) {
actions.push({
title: label,
kind: "refactor.rewrite",
edit: {
edits: [
{
resource: model.uri,
versionId: model.getVersionId(),
textEdit: { range: plan.range, text: plan.text },
},
],
},
});
}
}
const replaceValue = (title, key, value, kindStr, preferred) => {
const v = stringValueRange(pos.lineNumber, key);
if (!v || v.value === value) return;
actions.push({
title,
kind: kindStr || "refactor.rewrite",
isPreferred: !!preferred,
edit: {
edits: [
{
resource: model.uri,
versionId: model.getVersionId(),
textEdit: { range: v.range, text: value },
},
],
},
});
};
if (/"mark"\s*:/.test(line))
MARKS.forEach((mk) => replaceValue("Change mark to “" + mk + "”", "mark", mk));
if (/"type"\s*:/.test(line))
VL_TYPES.forEach((t) => replaceValue("Set type: " + t, "type", t));
if (/"field"\s*:/.test(line))
COLUMN_NAMES.forEach((c) => replaceValue("Change field to “" + c + "”", "field", c));
// Quick fix tied to the unknown-field markers.
for (const m of context.markers) {
if (m.code !== "unknown-field") continue;
const bad = model.getValueInRange(m);
const fix = closestColumn(bad);
if (!fix) continue;
actions.push({
title: 'Change to "' + fix + '"',
kind: "quickfix",
isPreferred: true,
diagnostics: [m],
edit: {
edits: [
{
resource: model.uri,
versionId: model.getVersionId(),
textEdit: {
range: new monaco.Range(
m.startLineNumber,
m.startColumn,
m.endLineNumber,
m.endColumn,
),
text: fix,
},
},
],
},
});
}
return { actions, dispose() {} };
},
});
// ===================== 2. context-menu / F1 actions =====================
editor.addAction({
id: "demo.wrap.layer",
label: "Wrap Focused View in a Layer",
contextMenuGroupId: "astrolabe",
contextMenuOrder: 1,
run: () => applyWrap("layer"),
});
editor.addAction({
id: "demo.wrap.hconcat",
label: "Wrap Focused View in Horizontal Concat",
contextMenuGroupId: "astrolabe",
contextMenuOrder: 2,
run: () => applyWrap("hconcat"),
});
editor.addAction({
id: "demo.wrap.vconcat",
label: "Wrap Focused View in Vertical Concat",
contextMenuGroupId: "astrolabe",
contextMenuOrder: 3,
run: () => applyWrap("vconcat"),
});
editor.addAction({
id: "demo.add.color",
label: "Add Color Encoding",
contextMenuGroupId: "astrolabe",
contextMenuOrder: 4,
run: () => addColorEncoding(),
});
function addColorEncoding() {
let spec;
try {
spec = JSON.parse(model.getValue());
} catch {
toast("Fix the JSON syntax first, then try again.");
return;
}
spec.encoding = spec.encoding || {};
spec.encoding.color = { field: "weather", type: "nominal" };
editor.pushUndoStop();
editor.executeEdits("add-color", [
{ range: model.getFullModelRange(), text: JSON.stringify(spec, null, 2) },
]);
editor.pushUndoStop();
}
// ===================== 3. CodeLens =====================
const lensWrapLayer = editor.addCommand(0, () => applyWrap("layer"));
const lensAddColor = editor.addCommand(0, () => addColorEncoding());
const lensAddView = editor.addCommand(0, (_ctx, kind) => applyWrap(kind || "hconcat"));
monaco.languages.registerCodeLensProvider("json", {
provideCodeLenses(model) {
const lenses = [];
const lineCount = model.getLineCount();
for (let ln = 1; ln <= lineCount; ln++) {
const line = model.getLineContent(ln);
if (/"mark"\s*:/.test(line)) {
const range = new monaco.Range(ln, 1, ln, 1);
lenses.push({ range, command: { id: lensWrapLayer, title: " add layer" } });
lenses.push({ range, command: { id: lensAddColor, title: " add color encoding" } });
}
if (/"(layer|hconcat|vconcat|concat)"\s*:\s*\[/.test(line)) {
lenses.push({
range: new monaco.Range(ln, 1, ln, 1),
command: { id: lensAddView, title: " add view", arguments: ["hconcat"] },
});
}
}
return { lenses, dispose() {} };
},
resolveCodeLens(_model, lens) {
return lens;
},
});
// ===================== 4. completion provider =====================
monaco.languages.registerCompletionItemProvider("json", {
triggerCharacters: ['"', ":", " "],
provideCompletionItems(model, position) {
const before = model
.getValueInRange(new monaco.Range(position.lineNumber, 1, position.lineNumber, position.column));
const word = model.getWordUntilPosition(position);
const range = new monaco.Range(
position.lineNumber,
word.startColumn,
position.lineNumber,
word.endColumn,
);
const md = (s) => ({ value: s });
let items = [];
if (/"field"\s*:\s*"[^"]*$/.test(before)) {
items = COLUMN_NAMES.map((c) => ({
label: c,
kind: monaco.languages.CompletionItemKind.Field,
detail: COLUMNS[c].type + " · from dataset “weather”",
documentation: md("Sample: " + COLUMNS[c].samples.join(", ")),
insertText: c,
range,
}));
} else if (/"type"\s*:\s*"[^"]*$/.test(before)) {
items = VL_TYPES.map((t) => ({
label: t,
kind: monaco.languages.CompletionItemKind.EnumMember,
insertText: t,
range,
}));
} else if (/"mark"\s*:\s*"[^"]*$/.test(before)) {
items = MARKS.map((mk) => ({
label: mk,
kind: monaco.languages.CompletionItemKind.EnumMember,
insertText: mk,
range,
}));
} else if (/"aggregate"\s*:\s*"[^"]*$/.test(before)) {
items = AGGREGATES.map((a) => ({
label: a,
kind: monaco.languages.CompletionItemKind.Function,
insertText: a,
range,
}));
}
return { suggestions: items };
},
});
// ===================== 5. hover provider =====================
monaco.languages.registerHoverProvider("json", {
provideHover(model, position) {
const w = model.getWordAtPosition(position);
if (!w) return null;
const name = w.word;
if (COLUMNS[name]) {
const col = COLUMNS[name];
return {
range: new monaco.Range(position.lineNumber, w.startColumn, position.lineNumber, w.endColumn),
contents: [
{ value: "**" + name + "** · `" + col.type + "`" },
{ value: "Sample values: " + col.samples.join(", ") },
{ value: "_from the bound dataset “weather”_" },
],
};
}
if (MARKS.includes(name)) {
return {
range: new monaco.Range(position.lineNumber, w.startColumn, position.lineNumber, w.endColumn),
contents: [{ value: "**mark · " + name + "**" }, { value: "A Vega-Lite mark type." }],
};
}
return null;
},
});
// ===================== 6. inlay hints =====================
monaco.languages.registerInlayHintsProvider("json", {
provideInlayHints(model, range) {
const hints = [];
for (let ln = range.startLineNumber; ln <= range.endLineNumber; ln++) {
const v = stringValueRange(ln, "field");
if (v && COLUMNS[v.value]) {
hints.push({
position: { lineNumber: ln, column: v.range.endColumn + 1 },
label: ": " + COLUMNS[v.value].type,
kind: monaco.languages.InlayHintKind.Type,
paddingLeft: true,
});
}
}
return { hints, dispose() {} };
},
});
// ===================== UI wiring =====================
document.getElementById("theme").addEventListener("change", (e) => {
const dark = e.target.value === "dark";
monaco.editor.setTheme(dark ? "vs-dark" : "vs");
document.body.classList.toggle("light", !dark);
});
document.getElementById("inlay").addEventListener("change", (e) => {
editor.updateOptions({ inlayHints: { enabled: e.target.checked ? "on" : "off" } });
});
document.getElementById("reset").addEventListener("click", () => {
model.setValue(STARTER);
refreshMarkers();
});
document.querySelectorAll(".try").forEach((btn) => {
btn.addEventListener("click", () => {
editor.focus();
const act = btn.dataset.act;
if (act === "palette") editor.trigger("demo", "editor.action.quickCommand", null);
else if (act === "quickfix") {
// park the cursor on the "mark" line so the lightbulb has something to show
const text = model.getValue();
const idx = text.split("\n").findIndex((l) => /"mark"\s*:/.test(l));
if (idx >= 0) editor.setPosition({ lineNumber: idx + 1, column: 5 });
editor.trigger("demo", "editor.action.quickFix", null);
} else if (act === "format") editor.getAction("editor.action.formatDocument").run();
});
});
});
</script>
</body>
</html>