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:
@@ -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. |
|
| 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. |
|
| 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. |
|
| 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). |
|
| 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_. |
|
| 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`). |
|
| 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)
|
## Borrow list (where each lands)
|
||||||
|
|
||||||
| Technique | Lands in | Milestone |
|
| 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>
|
||||||
Generated
+7
@@ -22,6 +22,7 @@
|
|||||||
"@fontsource/space-mono": "^5.2.9",
|
"@fontsource/space-mono": "^5.2.9",
|
||||||
"@fontsource/spectral": "^5.2.8",
|
"@fontsource/spectral": "^5.2.8",
|
||||||
"json-stringify-pretty-compact": "^4.0.0",
|
"json-stringify-pretty-compact": "^4.0.0",
|
||||||
|
"jsonc-parser": "^3.3.1",
|
||||||
"marked": "^18.0.5",
|
"marked": "^18.0.5",
|
||||||
"monaco-editor": "^0.54.0",
|
"monaco-editor": "^0.54.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
@@ -6148,6 +6149,12 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/jsonfile": {
|
||||||
"version": "6.2.1",
|
"version": "6.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
"@fontsource/space-mono": "^5.2.9",
|
"@fontsource/space-mono": "^5.2.9",
|
||||||
"@fontsource/spectral": "^5.2.8",
|
"@fontsource/spectral": "^5.2.8",
|
||||||
"json-stringify-pretty-compact": "^4.0.0",
|
"json-stringify-pretty-compact": "^4.0.0",
|
||||||
|
"jsonc-parser": "^3.3.1",
|
||||||
"marked": "^18.0.5",
|
"marked": "^18.0.5",
|
||||||
"monaco-editor": "^0.54.0",
|
"monaco-editor": "^0.54.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ import {
|
|||||||
runExtractConfigToTheme,
|
runExtractConfigToTheme,
|
||||||
runMergeChartTheme,
|
runMergeChartTheme,
|
||||||
} from '../services/spec-config-actions';
|
} 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 { useAppStore } from '../stores/AppStore';
|
||||||
import { confirm } from '../stores/ConfirmStore';
|
import { confirm } from '../stores/ConfirmStore';
|
||||||
import { useDatasetStore } from '../stores/DatasetStore';
|
import { useDatasetStore } from '../stores/DatasetStore';
|
||||||
@@ -149,6 +157,11 @@ function EditorSettings() {
|
|||||||
configureVegaLiteJson();
|
configureVegaLiteJson();
|
||||||
// Register the compact JSON formatter once (Format Document + format-on-paste, §03A).
|
// Register the compact JSON formatter once (Format Document + format-on-paste, §03A).
|
||||||
configureJsonFormatter();
|
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:
|
/** The two spec↔config operations, surfaced as an overflow menu (council:
|
||||||
* Carbon menu-buttons — overflow for additional options under space
|
* Carbon menu-buttons — overflow for additional options under space
|
||||||
@@ -174,6 +187,25 @@ const CONFIG_ACTIONS = [
|
|||||||
|
|
||||||
type ConfigActionId = (typeof CONFIG_ACTIONS)[number]['value'];
|
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({
|
function EditorToolbar({
|
||||||
editorRef,
|
editorRef,
|
||||||
}: {
|
}: {
|
||||||
@@ -228,6 +260,13 @@ function EditorToolbar({
|
|||||||
else runExtractConfigToTheme(editor);
|
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 handleRevert = async () => {
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: 'Revert draft',
|
title: 'Revert draft',
|
||||||
@@ -284,6 +323,16 @@ function EditorToolbar({
|
|||||||
<span className={styles.actionLabel}>Extract to Dataset</span>
|
<span className={styles.actionLabel}>Extract to Dataset</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
<SelectControl
|
||||||
|
id="editor-transform-actions"
|
||||||
|
label="Spec transform actions"
|
||||||
|
heading="Transform"
|
||||||
|
options={TRANSFORM_ACTIONS}
|
||||||
|
onSelect={handleTransformAction}
|
||||||
|
triggerContent="Transform"
|
||||||
|
triggerTitle="Structural transforms — wrap the focused view in a composition, or simplify one"
|
||||||
|
disabled={activeId === null || editorView === 'published'}
|
||||||
|
/>
|
||||||
<SelectControl
|
<SelectControl
|
||||||
id="editor-config-actions"
|
id="editor-config-actions"
|
||||||
label="Spec config actions"
|
label="Spec config actions"
|
||||||
@@ -376,6 +425,15 @@ export function SpecEditor() {
|
|||||||
// above, so the draft buffer stays in sync like any other edit.
|
// above, so the draft buffer stays in sync like any other edit.
|
||||||
const configActionsSub = installSpecConfigActions(editor);
|
const configActionsSub = installSpecConfigActions(editor);
|
||||||
|
|
||||||
|
// Structural-transform actions in the F1 palette (the lightbulb is registered
|
||||||
|
// once, globally, above; the toolbar Transform menu is the home). Per editor,
|
||||||
|
// disposed below like the config actions.
|
||||||
|
const transformActionsSub = installSpecTransformActions(editor);
|
||||||
|
|
||||||
|
// "+ Add view" CodeLens over composition arrays — per editor, because its
|
||||||
|
// command needs this editor's handle to apply the edit.
|
||||||
|
const codeLensSub = installSpecTransformCodeLens(editor);
|
||||||
|
|
||||||
// Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 →
|
// Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 →
|
||||||
// "bind listeners in exactly one place"), which publishes before the
|
// "bind listeners in exactly one place"), which publishes before the
|
||||||
// interactive-context gate so it works while the editor has focus. Monaco
|
// interactive-context gate so it works while the editor has focus. Monaco
|
||||||
@@ -385,6 +443,8 @@ export function SpecEditor() {
|
|||||||
sub.dispose();
|
sub.dispose();
|
||||||
pasteSub.dispose();
|
pasteSub.dispose();
|
||||||
configActionsSub.dispose();
|
configActionsSub.dispose();
|
||||||
|
transformActionsSub.dispose();
|
||||||
|
codeLensSub.dispose();
|
||||||
editor.dispose();
|
editor.dispose();
|
||||||
editorRef.current = null;
|
editorRef.current = null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* The data context of the active draft (docs/architecture/08 → editor
|
||||||
|
* augmentation): the columns/types/stats the spec can reference, and the fields
|
||||||
|
* the editor hints offer. One resolver, shared by the transform actions
|
||||||
|
* (facet/repeat field defaults) and the dataset-aware hints
|
||||||
|
* (completion/hover/inlay).
|
||||||
|
*
|
||||||
|
* Source, in order: a **named library dataset** the draft references (its stored
|
||||||
|
* profile), else the spec's **inline data** profiled on the fly (the "ghost
|
||||||
|
* dataset" — `core/spec-inline-data` + `core/profile`, nothing stored). On top of
|
||||||
|
* either, the spec's **derived** fields (transform `as`, `core/spec-fields`) are
|
||||||
|
* added, so a `calculate` output is offered alongside the data columns.
|
||||||
|
*
|
||||||
|
* The result is memoized by draft text: providers fire often (every keystroke
|
||||||
|
* completes, inlay refreshes on scroll), and profiling inline data each time would
|
||||||
|
* be wasteful. App layer — reads stores via `getState`, outside React.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Dataset } from '@core/dataset';
|
||||||
|
import { type ColumnStats, profileData } from '@core/profile';
|
||||||
|
import { derivedFieldNames } from '@core/spec-fields';
|
||||||
|
import { inlineDataRows } from '@core/spec-inline-data';
|
||||||
|
import { extractDatasetRefs } from '@core/spec-refs';
|
||||||
|
import type { ColumnType } from '@core/type-inference';
|
||||||
|
import { useDatasetStore } from '../stores/DatasetStore';
|
||||||
|
import { useSnippetStore } from '../stores/SnippetStore';
|
||||||
|
|
||||||
|
/** A field offered to the editor hints. */
|
||||||
|
export interface FieldHint {
|
||||||
|
name: string;
|
||||||
|
/** Source columns carry an inferred type; a spec-derived field's is unknown. */
|
||||||
|
type: ColumnType | null;
|
||||||
|
/** True when introduced by a spec transform rather than the data. */
|
||||||
|
derived: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Everything the hints need about the active draft's data. */
|
||||||
|
export interface DataInfo {
|
||||||
|
/** The library dataset's name, or null for inline data (the ghost dataset). */
|
||||||
|
name: string | null;
|
||||||
|
columnTypes: ReadonlyArray<{ name: string; type: ColumnType }>;
|
||||||
|
columnStats: ReadonlyArray<ColumnStats>;
|
||||||
|
/** Source columns plus derived fields, de-duplicated (source wins). */
|
||||||
|
fields: ReadonlyArray<FieldHint>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ColumnStats> = [];
|
||||||
|
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<FieldHint> {
|
||||||
|
return dataInfo().fields;
|
||||||
|
}
|
||||||
@@ -40,11 +40,16 @@ import { useCustomThemeStore } from '../stores/CustomThemeStore';
|
|||||||
import { notify } from '../stores/NotificationStore';
|
import { notify } from '../stores/NotificationStore';
|
||||||
import { selectActiveSnippet, useSnippetStore } from '../stores/SnippetStore';
|
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<string, unknown> | 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<string, unknown> | null {
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(model.getValue());
|
parsed = JSON.parse(text);
|
||||||
} catch {
|
} catch {
|
||||||
notify({
|
notify({
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
@@ -57,7 +62,7 @@ function parseSpecObject(model: monaco.editor.ITextModel): Record<string, unknow
|
|||||||
notify({
|
notify({
|
||||||
kind: 'error',
|
kind: 'error',
|
||||||
title: 'Spec is not a JSON object',
|
title: 'Spec is not a JSON object',
|
||||||
message: 'Config actions need a top-level { … } Vega-Lite spec.',
|
message: 'This action needs a top-level { … } Vega-Lite spec.',
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -85,7 +90,7 @@ function replaceDocument(
|
|||||||
export function runMergeChartTheme(editor: monaco.editor.IStandaloneCodeEditor): void {
|
export function runMergeChartTheme(editor: monaco.editor.IStandaloneCodeEditor): void {
|
||||||
const model = editor.getModel();
|
const model = editor.getModel();
|
||||||
if (!model) return;
|
if (!model) return;
|
||||||
const spec = parseSpecObject(model);
|
const spec = parseSpecObject(model.getValue());
|
||||||
if (!spec) return;
|
if (!spec) return;
|
||||||
|
|
||||||
const { chartTheme, uiTheme } = useAppStore.getState();
|
const { chartTheme, uiTheme } = useAppStore.getState();
|
||||||
@@ -122,7 +127,7 @@ function extractableConfig(editor: monaco.editor.IStandaloneCodeEditor): {
|
|||||||
} | null {
|
} | null {
|
||||||
const model = editor.getModel();
|
const model = editor.getModel();
|
||||||
if (!model) return null;
|
if (!model) return null;
|
||||||
const spec = parseSpecObject(model);
|
const spec = parseSpecObject(model.getValue());
|
||||||
if (!spec) return null;
|
if (!spec) return null;
|
||||||
|
|
||||||
const { spec: rest, config } = extractConfigFromSpec(spec);
|
const { spec: rest, config } = extractConfigFromSpec(spec);
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* Dataset-aware editor hints (docs/architecture/08 → editor augmentation) — the
|
||||||
|
* three things the Vega-Lite JSON schema *can't* know, because they depend on the
|
||||||
|
* user's data and this spec:
|
||||||
|
*
|
||||||
|
* - **Completion** — in a `field` / `groupby` value, the bound dataset's real
|
||||||
|
* column names (plus the spec's derived fields). The schema only knows `field`
|
||||||
|
* takes a string; it can't list your columns. Enum values (`type`, `mark`, …)
|
||||||
|
* are left to the schema — we add only what it lacks, no second source.
|
||||||
|
* - **Hover** — a column's inferred type + cardinality/range (from the stored
|
||||||
|
* profile); over a `calculate`/`filter`/`expr` string, a live validity check
|
||||||
|
* via core/expr-validate. Monaco merges these with the schema's own hovers.
|
||||||
|
* - **Inlay hints** — a faint `: <type>` 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() {} };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<WrapKind, string> = {
|
||||||
|
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() {} };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string>, 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<string>): void {
|
||||||
|
if (transform === null || typeof transform !== 'object') return;
|
||||||
|
const t = transform as Record<string, unknown>;
|
||||||
|
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<string, unknown>).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<string>();
|
||||||
|
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<string, unknown>;
|
||||||
|
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];
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, unknown>[] | 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<string, unknown>[] | null {
|
||||||
|
return isJsonObject(data) ? asRows(data.values) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The first `data.values` reachable from `node`, top level before nested. */
|
||||||
|
function firstDataValues(node: unknown): Record<string, unknown>[] | 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<string, unknown>): Record<string, unknown>[] | 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<string, unknown>[] | null {
|
||||||
|
if (!isJsonObject(spec)) return null;
|
||||||
|
return firstDataValues(spec) ?? firstNamedDataset(spec);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown>[];
|
||||||
|
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<string, unknown>[];
|
||||||
|
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<string, unknown>[];
|
||||||
|
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<string, unknown>[];
|
||||||
|
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<string, unknown>;
|
||||||
|
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<string, Record<string, unknown>> };
|
||||||
|
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<string, Record<string, unknown>> };
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user