mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
854 lines
32 KiB
HTML
854 lines
32 KiB
HTML
<!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>
|