mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Editor: spec transforms (wrap/simplify/add-view) and dataset-aware hints
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user