mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
515 lines
38 KiB
Markdown
515 lines
38 KiB
Markdown
# 08 · Borrowed Techniques from vega/editor
|
||
|
||
> The official Vega-Lite editor ([vega/editor](https://github.com/vega/editor)) solves the
|
||
> exact "edit a Vega-Lite spec as JSON, validate it, render it live" problem Astrolabe sits
|
||
> on top of — minus the snippet/dataset library. This doc distills the techniques worth
|
||
> borrowing and the gotchas worth avoiding, so we don't rediscover them from scratch in
|
||
> M1/M2.
|
||
>
|
||
> It is a **reference**, not a contract. The behavioral record is still [`docs/spec/`](../spec/);
|
||
> the patterns are still docs [01](01-state-and-stores.md)–[07](07-naming-and-relationships.md).
|
||
> This doc is the bridge: "here is how the canonical implementation does the editor/renderer
|
||
> plumbing, and here is what we keep vs. improve."
|
||
|
||
## Source of these findings
|
||
|
||
A read-only clone of vega/editor lives at `/Users/oleh/code/reference/vega-editor` (shallow
|
||
clone of `main`, HEAD `4fdbb59`). Re-clone with
|
||
`git clone --depth 1 https://github.com/vega/editor`. Citations below are `file:line` into
|
||
that tree.
|
||
|
||
## Stack delta (read this first — it changes how directly we can borrow)
|
||
|
||
| | vega/editor | Astrolabe |
|
||
| ----------------- | -------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
|
||
| UI framework | **React** | **React** (moved off Preact before build start) |
|
||
| State | Redux-ish single `State` in React context (`useState`) | Zustand **stores** — _not_ Redux |
|
||
| Monaco | `@monaco-editor/react` + `@monaco-editor/loader` (CDN-loaded Monaco, **workers auto-wired**) | **raw `monaco-editor`** via Vite (**we must wire workers ourselves**) |
|
||
| Rendering | **hand-rolled** `vegaLite.compile` → `vega.parse` → `new vega.View().runAsync()` | **`vegaEmbed()`** (wraps that same pipeline) |
|
||
| Schema validation | Monaco JSON worker **+** standalone `ajv ^8` (two independent layers) | same two-layer model planned |
|
||
|
||
Because both apps are now React, vega/editor's **component lifecycle patterns port more or
|
||
less directly** — the friction is only in (a) state (their Redux-flat-state → our Zustand
|
||
stores) and (b) Monaco worker wiring (their CDN loader → our explicit Vite workers).
|
||
|
||
---
|
||
|
||
## Decision · Monaco integration (self-hosted, raw API)
|
||
|
||
> **Decided.** Astrolabe uses **raw `monaco-editor` from npm, bundled and self-hosted**, with
|
||
> workers wired explicitly via Vite `?worker` — **not** vega/editor's
|
||
> `@monaco-editor/react` + `@monaco-editor/loader` (CDN) setup. Two independent axes:
|
||
|
||
**Axis A — sourcing: self-hosted/bundled, not CDN. (Forced by Astrolabe's values.)**
|
||
vega/editor's `@monaco-editor/loader` fetches Monaco's AMD bundle from a CDN at runtime. For
|
||
us that breaks three things at once: (1) **offline** — the CDN bundle is outside Vite's module
|
||
graph, so `vite-plugin-pwa`/Workbox never precaches it and offline silently fails; bundled npm
|
||
assets are hashed files in `dist/` that Workbox precaches automatically; (2) **privacy** — a
|
||
third-party fetch on load contradicts SOUL's "the only outbound requests are user-created
|
||
URL-dataset fetches"; (3) **determinism** — npm + `package-lock` is integrity-pinned and
|
||
reproducible, a runtime CDN resolve is not. This axis is not a close call; vega/editor's CDN
|
||
choice is right _for an online hosted tool_ and wrong for an offline, installable, private app.
|
||
|
||
**Axis B — React integration: raw API, not `@monaco-editor/react`. (A lean, not forced.)**
|
||
The wrapper helps with the easy 80% (mount a JSON editor, lifecycle) and adds nothing to the
|
||
load-bearing 20% this app needs:
|
||
|
||
- **Workers** are still ours — the wrapper never manages `MonacoEnvironment` (see §1 gotcha).
|
||
- The **M2 schema service** (`jsonDefaults.setDiagnosticsOptions`, `fileMatch`) is namespace-level;
|
||
you reach _through_ the wrapper via `onMount`, so it saves nothing there.
|
||
- Its headline **`value`/`onChange` controlled-input model is a hazard**: driving Monaco's
|
||
content from React state causes cursor jumps and undo-stack churn, against §10's "typing
|
||
stays fluid" — you end up using it uncontrolled, i.e. the raw pattern anyway.
|
||
- Its **CDN-by-default** is a standing footgun (works in dev online, fails offline in prod
|
||
unless you remember `loader.config({ monaco })`).
|
||
|
||
Against that, raw costs **one testable `useMonacoEditor` hook** (~50–80 lines: create in
|
||
`useEffect`, `dispose` on unmount, push value, subscribe to `onDidChangeModelContent`, resize).
|
||
That's the **same imperative-teardown discipline already adopted for `vega-embed`** in doc 05
|
||
(`view.finalize()`), and consistent with already using raw `vegaEmbed()` over a React chart
|
||
wrapper — "thin integration layers we own" (SOUL). Lock-in is low either way, so the final
|
||
raw-vs-wrapper call is confirmable at the Monaco spike; what is **not** up for revisiting is
|
||
self-hosting.
|
||
|
||
**Accepted cost:** the explicit worker wiring (§1) is inherent to self-hosting — it is the
|
||
price of offline, paid in any non-CDN setup, and the wrapper would not remove it.
|
||
|
||
### Entry point: `edcore.main`, never `editor.api` (trim languages, not features)
|
||
|
||
Self-hosting raw Monaco forces a choice of ESM entry point, and the granularity matters:
|
||
|
||
| Import | What you get | Use? |
|
||
| --------------------------- | ------------------------------------------------------------------- | -------------------------------------------------------------------------------------- |
|
||
| `monaco-editor` (barrel) | All features **+ every basic language** (sql, abap, solidity, …) | ❌ language bloat (~20 dead chunks) |
|
||
| `esm/vs/editor/editor.api` | The API surface only — **zero feature contributions** | ❌ a text box: no folding, suggest widget, `Cmd+Backspace`, find, bracket colorization |
|
||
| `esm/vs/editor/edcore.main` | `editor.all` (all 59 feature contributions) + API, **no languages** | ✅ full editor UX, JSON-only weight |
|
||
|
||
Import **`edcore.main`** and add only the JSON language service
|
||
(`esm/vs/language/json/monaco.contribution`). `edcore.main` ships no `.d.ts` of its own —
|
||
add an ambient `declare module … { export * from '…/editor.api'; }` so types (and Monaco's
|
||
global `MonacoEnvironment` augmentation) resolve. Two editor options worth setting because
|
||
they bite Vega-Lite specs specifically: `showFoldingControls: 'always'` (fold arrows always
|
||
visible), and `quickSuggestions: { strings: true }` (VL enum values like `"bar"` live inside
|
||
JSON strings, where Monaco disables auto-suggest by default).
|
||
|
||
> Reaching for `editor.api` to "drop unused languages" silently strips every editor feature —
|
||
> the languages live elsewhere. This is the concrete case behind AGENTS.md's **"trim content,
|
||
> not capability"** rule: cut the unwanted _content_, keep the _behavior_, and verify the
|
||
> behavior survived by exercising the editor, not by a green build.
|
||
|
||
---
|
||
|
||
> **The single biggest surprise:** vega/editor does **not** use `vega-embed` for its live
|
||
> preview. It builds the compile→parse→View pipeline by hand; `vega-embed` is imported only
|
||
> for types and the exported standalone-HTML snippet. This is _good news_ — `vega-embed` is
|
||
> exactly the wrapper they wrote by hand, so we get it for free. But their hand-rolled
|
||
> version (`src/components/renderer/renderer.tsx`) is the best available documentation of the
|
||
> lifecycle/cleanup discipline `vega-embed` still expects from us.
|
||
|
||
---
|
||
|
||
## 1 · Monaco + Vega-Lite schema wiring (M2 — highest from-scratch risk)
|
||
|
||
All of vega/editor's Monaco setup is one file: `src/utils/monaco.ts`.
|
||
|
||
**What to borrow:**
|
||
|
||
- **Bundle the schema; never fetch it.** They `import vegaLiteSchema from 'vega-lite/vega-lite-schema.json'`,
|
||
resolved by a Vite alias to the package's `build/` output (`monaco.ts:7-8`, `vite.config.ts`).
|
||
The schema version is pinned to the installed `vega-lite` — offline-safe, version-locked,
|
||
no runtime network call. Astrolabe should do the same.
|
||
- **Attach via the JSON language service**, once, globally:
|
||
`monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ schemas, validate:true, ... })`
|
||
(`monaco.ts:51-57`).
|
||
- **`markdownDescription` patch** (`monaco.ts:12-13`, `utils/markdownProps.ts`): recursively
|
||
copy every schema `description` → `markdownDescription` before registering. Monaco renders
|
||
rich hover docs only from `markdownDescription`; without this, hovers are plain text. Do it
|
||
once at setup.
|
||
- **Replace the built-in JSON formatter** with `json-stringify-pretty-compact` via
|
||
`registerDocumentFormattingEditProvider('json', …)` (`monaco.ts:60-61,71-80`) for Vega's
|
||
compact array-on-one-line style.
|
||
- **Editor options worth copying** (`spec-editor/renderer.tsx:263-274`): `folding:true`,
|
||
`minimap.enabled:false`, `scrollBeyondLastLine:false`, `wordWrap:'on'`,
|
||
`quickSuggestions:true` (this is what makes schema completions appear without an explicit
|
||
trigger), `stickyScroll.enabled:false`.
|
||
|
||
**Gotchas / where we improve:**
|
||
|
||
- ⚠️ **Workers are on us.** vega/editor never configures Monaco workers — the CDN loader does.
|
||
With raw `monaco-editor` + Vite we **must** set `self.MonacoEnvironment.getWorker` to return
|
||
the `json.worker` for label `'json'` and `editor.worker` otherwise (via `?worker` imports):
|
||
```ts
|
||
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
|
||
self.MonacoEnvironment = {
|
||
getWorker: (_id, label) => (label === 'json' ? new JsonWorker() : new EditorWorker()),
|
||
};
|
||
```
|
||
The `json.worker` runs schema validation + autocomplete. **No worker ⇒ no squiggles, no
|
||
completions.** Upside: dropping the CDN loader makes `monaco` synchronously importable — no
|
||
`await loader.init()` dance, just call `setDiagnosticsOptions(...)` at module load.
|
||
- ⚠️ **`$schema`-based binding vs `fileMatch`.** They register schemas under versioned `uri`s
|
||
(`.../vega-lite/v6.json`) and bind by matching the doc's `$schema` value — **no `fileMatch`**
|
||
(`monaco.ts:15-46`). Consequence: a spec with **no `$schema` gets zero validation/autocomplete.**
|
||
Astrolabe should prefer `fileMatch` against our model URIs so validation works regardless of
|
||
whether the user wrote a `$schema` line.
|
||
- ⚠️ **Set `enableSchemaRequest:false`** for our offline-first app. They set it `true`
|
||
(`monaco.ts:54`), which lets the worker network-fetch any unbundled `$schema` URL — failing
|
||
network calls for an offline app. Register all schema versions locally instead.
|
||
- The schema is multi-MB; register it **once globally**, never per-model.
|
||
|
||
## 2 · Live preview with `vega-embed` (M1 lifecycle, M2 fit-mode)
|
||
|
||
This is doc [05](05-rendering-theming-preview.md)'s territory; these are the concrete details
|
||
vega/editor's hand-rolled renderer (`src/components/renderer/renderer.tsx`) reveals.
|
||
|
||
**What to borrow:**
|
||
|
||
- **Theme = a `vega-themes` config object merged into the spec config.** There is no automatic
|
||
light/dark sync in vega/editor — theme is an explicit choice baked in at compile
|
||
(`config-editor/config-editor-header.tsx:5-37`). For Astrolabe: pass the chosen `theme`/`config`
|
||
to `vegaEmbed`, and when our theme changes, re-embed with the new config.
|
||
- **`"width":"container"` / `"height":"container"` is how VL responsiveness works** — it
|
||
compiles `width`/`height` to signals that re-read `containerSize()` **only on a
|
||
`window:resize` event** (`renderer.tsx:78-90` detects container sizing). Two things a
|
||
from-scratch impl _will_ get wrong (we did): (1) `view.resize().runAsync()` does **not**
|
||
re-measure — it re-runs layout with the stale size; (2) a pane drag fires no window resize, so
|
||
nothing re-fits on its own. The fix is exactly vega/editor's
|
||
`window.dispatchEvent(new Event('resize'))` (`renderer.tsx:101-122`) — **not a hack, the
|
||
actual mechanism** — driven by a `ResizeObserver` on the pane. It also leaves a non-container
|
||
dimension natural for free. Full write-up in doc [05](05-rendering-theming-preview.md) §8.
|
||
- **Reuse the view for cheap changes.** They rebuild the `View` only on spec change; renderer
|
||
(svg/canvas) and tooltip toggles re-`initialize()` the existing view (`renderer.tsx:367-371`).
|
||
- **Capture warnings separately from errors** via a buffering logger (see §4's `LocalLogger`).
|
||
|
||
**Gotchas / where we improve:**
|
||
|
||
- ⚠️ **Finalize before re-embed, or leak.** Every spec change must `view.finalize()` the old
|
||
view _and_ clear the container before mounting the new one (`renderer.tsx:218-226`). `vegaEmbed`
|
||
returns `{ view, finalize }` — call `finalize()` before the next embed and on unmount. This is
|
||
already a Do-rule in doc 05; vega/editor confirms how easy it is to leak otherwise.
|
||
- ⚠️ **Race on rapid edits.** `runAsync` is async; a stale render can resolve after a newer one
|
||
mounts. vega/editor mitigates only with debounce. **We should add a render-generation token**
|
||
and ignore stale resolves (an improvement over the reference).
|
||
- ⚠️ **Wrap `runAsync` in try/catch and finalize on failure** — Vega won't catch runtime errors
|
||
for you, and a half-initialized view leaks if you don't finalize (`renderer.tsx:247-259`).
|
||
- The **CSP-safe expression interpreter** (`vega-interpreter` + `vega.parse(..., {ast:true})`)
|
||
matters only under a strict no-`eval` CSP. A local offline app doesn't need it — keep it
|
||
opt-in.
|
||
|
||
## 3 · Two-tier validation & error surfacing (M2, spec §03E)
|
||
|
||
vega/editor runs **two independent schema-validation systems** with no reconciliation, and
|
||
sorts errors into two tiers. Both are worth copying.
|
||
|
||
**The two layers:**
|
||
|
||
1. **Monaco JSON worker** → inline **squiggles, hovers, autocomplete** in the editor.
|
||
2. **`ajv ^8`** (`src/utils/validate.ts`) → runs at parse time, feeds the **error/log pane**.
|
||
It does _not_ create editor markers.
|
||
|
||
**The two error tiers (keep them separate):**
|
||
|
||
- **Fatal / blocking** — thrown exceptions: JSON syntax error, VL compile error, Vega runtime
|
||
error. These set a single `error` and suppress the chart.
|
||
- **Advisory** — ajv schema-validation findings and `$schema` version mismatch. These are a
|
||
warnings list and do **not** block rendering. (Vega-Lite emits many benign warnings; treating
|
||
ajv output as fatal would wrongly hide specs that render fine.)
|
||
|
||
The orchestration is `app.tsx:188-291`: `parseJSONCOrThrow` → `$schema` semver check (warn) →
|
||
`validateVegaLite` (ajv, warn) → `vegaLite.compile` (throw=fatal) → render (`renderer.tsx`,
|
||
throw=fatal).
|
||
|
||
**ajv setup specifics that _will_ bite a from-scratch impl** (`validate.ts:9-17`):
|
||
|
||
- `new Ajv({ strict: false })` — the VL/Vega schemas fail ajv strict-mode at **compile** time
|
||
otherwise.
|
||
- The VL schema is **draft-06** → must `ajv.addMetaSchema(json-schema-draft-06.json)` (ajv 8
|
||
defaults to draft-07/2020) or `compile` throws.
|
||
- Register a no-op `color-hex` format (`ajv.addFormat('color-hex', () => true)`) plus
|
||
`addFormats(ajv)`; the schema references formats ajv-formats doesn't cover.
|
||
- **Compile the validator once at module load and cache it** — the schema is huge; compiling
|
||
per keystroke is a perf killer.
|
||
|
||
**Where we improve:** ajv errors are shown as JSON-pointer text (e.g. `/encoding/x`) with **no
|
||
editor position** — vega/editor does not map them to markers. Only JSON _syntax_ errors get a
|
||
line/col (via jsonc-parser's visitor, `utils/jsonc-parser.ts:3-17`). If our §03E wants inline
|
||
ajv markers, we map `instancePath` → editor offsets ourselves via jsonc-parser's node tree —
|
||
something the reference does _not_ do.
|
||
|
||
## 4 · Data flow & debouncing (M1/M2 — translate to Zustand stores)
|
||
|
||
vega/editor keeps **`editorString` (the text) as the single source of truth**; the parsed spec
|
||
and compiled Vega spec are _derived_ and recomputed by a subscriber when text/mode/config change
|
||
(`app.tsx:338-365`). Errors don't clobber the last-good derived specs.
|
||
|
||
**The Zustand-store translation (this is the shape to build):**
|
||
|
||
```
|
||
text (store field, debounced writer on editor change)
|
||
└─▶ parsedSpec (derived: JSONC parse + collect syntax/diagnostic errors)
|
||
└─▶ renderInput (derived: prepareSpecForRender — refs, fit-mode)
|
||
└─▶ effect: deep-equal guard → vegaEmbed(); finalize previous view
|
||
```
|
||
|
||
**What to borrow:**
|
||
|
||
- **Debounce only at edit→state**, not state→render. vega/editor debounces the editor at
|
||
**1200 ms** (`spec-editor/renderer.tsx:66`) and guards the render with a `deepEqual` prop
|
||
diff (`renderer.tsx:340-349`). (1200 ms is _their_ number; tune ours — our settings expose a
|
||
render-debounce preference.)
|
||
- **A manual-parse escape hatch** (Ctrl/Cmd+S re-parses without waiting) maps to a future
|
||
live-vs-manual preview toggle (`renderer.tsx:89-111`).
|
||
- **`LocalLogger` pattern** (`utils/logger.ts`): a logger that buffers `errors/warns/infos/debugs`
|
||
into arrays instead of writing to console. This lets a **pure** `src/core` compile/validate
|
||
step _return_ structured diagnostics with zero browser coupling — e.g.
|
||
`validateSpec(spec) → { errors, warns }`. Ideal core-first fit.
|
||
- **`json-stringify-pretty-compact`** for the format action and prettify-on-load — much nicer
|
||
than `JSON.stringify(…, null, 2)` for VL specs.
|
||
|
||
**Persistence note:** vega/editor snapshots its whole state to localStorage on _every_ change,
|
||
stripping non-serializable fields (`view`, `runtime`, editor refs) and restoring via
|
||
`{ ...DEFAULT_STATE, ...parsed }` (`context/app-context.tsx`). Our **debounced auto-save to
|
||
IndexedDB** (doc 01/02) is the better pattern — but the "strip non-serializable, restore with
|
||
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,
|
||
data-aware hints, and expression intelligence — the edits and feedback that are awkward in
|
||
raw JSON and out of reach of the single-view visual builder. All transform and analysis
|
||
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. (Surfaced as the
|
||
toolbar **Compose** menu — named to read distinctly from a data `transform`, below.)
|
||
- `spec-data-transforms` — the data-pipeline counterpart: `transformSiteAt` resolves the view
|
||
the cursor is in and its `transform[]` range/count (for the CodeLens), `transformPlacementAt`
|
||
classifies a step slot (for the completion) — both tagged `shared` when the pipeline sits on a
|
||
composition parent, `view` on a unit. `DATA_TRANSFORMS` is the field-typed step catalog (filter,
|
||
calculate, aggregate, bin, timeUnit, window, joinaggregate, fold, lookup); each builder takes the
|
||
columns in scope and seeds each `${n:default}` tab stop with a type-appropriate one. A test
|
||
round-trips every snippet through `snippetToPlain` to assert it's valid JSON.
|
||
- `spec-params` — the parameter counterpart: `paramSiteAt` resolves both homes a parameter can take
|
||
(the **root** spec for a variable widget, the **nearest enclosing unit** for a selection —
|
||
grammar-confirmed against the bundled schema: a nested unit's `params[]` takes selections only),
|
||
and `paramPlacementAt` classifies a `params[]` slot (root → both families, nested → selections only)
|
||
for the completion. `PARAMS` is the catalog (slider, dropdown, radio, checkbox; point, interval); a
|
||
slider's bounds seed from the numeric field's `numericExtent`, a point selection's field from a
|
||
categorical column. A test validates every seeded entry against the bundled Vega-Lite schema.
|
||
- `spec-snippet` — the snippet-insertion text math both scaffolds share: `snippetToPlain`
|
||
(tab stops → default text, so catalogs and edits round-trip through `JSON.parse` in tests),
|
||
`arrayAffixes`/`appendEntryEdit` (the comma affixing that keeps a spliced array valid), and
|
||
`createArrayPropertyEdit` (the inline `"key": [ … ]` first-property insertion and its indent
|
||
math). Pure `(text, site) → { offset, snippet }` — a bug here writes invalid JSON into the
|
||
user's editor, so the edits are table-tested applied-and-reparsed, off the Monaco glue.
|
||
- `spec-data` — the Vega-Lite data model: classify a `data` block (`classifyData`, mirroring
|
||
`isNamedData`), the library reference name (`libraryRefName`), and the data binding in scope
|
||
at a cursor path (`dataBindingAtPath`, honoring a view's data inheritance from its parent).
|
||
- `spec-cursor` — `findViewRange` (cursor offset → the enclosing view's byte range),
|
||
`pathAtOffset` (cursor → JSON path), and `valueKeyAtOffset`/`stringValueAtOffset` (the JSON
|
||
context at the cursor), over `jsonc-parser`.
|
||
- `spec-fields` — field names a spec's transforms introduce (their `as`), scoped to a
|
||
cursor's ancestor chain (`derivedFieldNamesAtPath`).
|
||
- `spec-inline-data` — the rows a specific `data` binding carries for profiling
|
||
(`rowsForDataBinding`: inline `values`, or a self-defined `datasets` entry).
|
||
- `spec-insert` — the composition the cursor is in (`compositionTargetAt`), inserting a view
|
||
at an index (`insertView`) and reordering siblings (`moveView` swaps a neighbour, `moveViewTo`
|
||
slides to any index), plus `elementOffset` to re-find a view after the edit. It also owns the
|
||
shared `SpecPath` walkers (`valueAtPath`/`arrayAtPath`/`isPrefixPath`) — a module navigating a
|
||
path reuses these rather than re-inlining the array/object descent.
|
||
- `spec-view-tree` — the whole composition as a recursive tree (`viewTree`), each node carrying
|
||
its operator, orientation and byte range. Read at once (vs. `spec-insert`'s one-array edits) to
|
||
drive the composition wireframe — a schematic of the multi-view structure in a preview-toolbar
|
||
disclosure (`CompositionWireframe`); clicking a box reveals that view's range in the editor via
|
||
`AppStore.requestRevealView`, and on the draft it is **drag-editable** (reorder + restructure).
|
||
Interaction contract in [arch 10](10-interaction-and-feedback.md).
|
||
- `spec-restructure` — the path-targeted cross-container moves behind the wireframe's drag.
|
||
`wrapViews(target, source, axis, side)` pairs the dragged source beside the drop target in a new
|
||
concat (placed where the target was, the source removed), enforcing three invariants:
|
||
**flatten** a bare same-orientation concat nested directly in a concat (so a with-axis drop reads
|
||
as a plain _insert_, not redundant nesting), **collapse** the source's emptied container (unwrap a
|
||
one-child, drop a zero-child, recursing up the chain), and **data-pin** a source's inherited
|
||
`data` before it changes ancestor (`dataBindingAtPath`, so it never silently rebinds). Degenerate
|
||
drops — onto itself, its own ancestor/descendant, or the root — return null. `wrapContainer` is the
|
||
complement for a frame-margin drop: it stacks the source against the _whole_ container rather than
|
||
beside one view — the root included, lifting spec-level metadata onto the wrapper via
|
||
`spec-transforms.concatRootBeside` — pulling a view out into a new full-span row/column. The same
|
||
flatten/collapse cleanup runs after. `simplifyStructure` collapses redundant single-child
|
||
compositions recursively (a `{hconcat:[v]}` is just `v`; `facet`/`repeat` hold one child by design
|
||
and are left alone) — the wireframe's Simplify, returning null when nothing is redundant.
|
||
- `expr-validate` — one Vega expression, parsed with Vega's own `parseExpression` (no divergent
|
||
grammar): `validateExpression` (valid + parser message), `referencedFields` (its `datum.<field>`
|
||
references), and `activeCall` (the enclosing call + which argument the cursor is in, for signature
|
||
help).
|
||
- `spec-expressions` — the expressions embedded in a spec's JSON strings (`EXPRESSION_KEYS` =
|
||
`calculate`/`filter`/`expr`/`test`; only string values, so object predicates are skipped).
|
||
`expressionStringsIn` locates each (byte span + key) to drive markers; `firstExpressionError`
|
||
names the first malformed one (key + parser message + 1-based line) so a failed render can
|
||
attribute itself.
|
||
- `vega-expr-catalog` — the expression language's function/constant **names derived from
|
||
`vega-expression`'s own registry** (zero drift; a test asserts the curated set ⊆ derived), plus
|
||
curated parameter signatures for the commonly-typed functions (what the registry can't supply).
|
||
|
||
**Services (app, store-aware via `getState`):** `spec-transform-actions` (the
|
||
wrap/simplify/add-view operations and their surfaces), `spec-transform-scaffold` (the
|
||
data-transform scaffold — a CodeLens plus a completion), `spec-param-scaffold` (the parameter
|
||
scaffold — likewise a CodeLens plus a completion), `spec-dataset-hints` (data-column completion, hover,
|
||
inlay providers), `spec-expression-hints` (expression completion, signature help, hover, and
|
||
the diagnostic markers), `active-dataset` (`dataInfoAt(text, offset)` — the columns/types/stats
|
||
plus derived fields the draft sees at the cursor). `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.
|
||
- **Cursor-aware CodeLenses share one skeleton.** The composition lens
|
||
(`spec-transform-actions`), the transform scaffold, and the parameter scaffold are the same
|
||
per-editor shape: a draft-gated provider that re-resolves the site under the cursor and refreshes
|
||
only when a keyed summary of it changes. That skeleton is `installCursorLens(editor, { resolve,
|
||
keyOf, lensesOf })` (`services/editor-cursor-lens`) — a new cursor lens supplies those three and its
|
||
own commands, nothing else. The tab-through snippet splice and the completion-slot plumbing both
|
||
scaffolds perform are shared the same way via `editor-snippet` — the Monaco glue over
|
||
`core/spec-snippet`'s tested edit math.
|
||
- **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.
|
||
- **Transform actions share an applier, never re-inline the skeleton.** Every `run*` action is
|
||
parse → `build(spec)` → `writeBack` (toast on null). That prologue lives in a shared applier per
|
||
family — `resolveTarget` (scoped), `applyArrayEdit` (one array), `applyWholeSpecEdit` (whole-spec
|
||
drag/simplify) — so a new action passes its core call and differs only in scope and feedback. A
|
||
fresh `run*` reuses the matching applier rather than copying the model/parse/writeBack lines.
|
||
- **Wireframe restructuring is a core op + the editor's undo.** Every drag resolves to `moveViewTo`
|
||
(reorder within one container), `wrapViews` (pair, cross-container move, insert), or `wrapContainer`
|
||
(pull a view out around a container); the Simplify prompt resolves to `simplifyStructure`. The
|
||
wireframe only _requests_ each (`AppStore.requestComposeMove`/`requestComposeWrap`/
|
||
`requestComposeWrapContainer`/`requestComposeSimplify`) and `SpecEditor` applies it through the same
|
||
`executeEdits` + `pushUndoStop` path as the other transforms, so a drag is one ⌘Z and the editor
|
||
stays the single text source. `wrapViews` covers wrap and insert with one operation because it
|
||
flattens bare same-orientation nesting afterward; the drag's zone interaction model lives in
|
||
[arch 10](10-interaction-and-feedback.md).
|
||
- **Composition CodeLens is cursor-scoped.** It follows the view the cursor sits in —
|
||
`+ Add view above/below` at the view's edges and `↑/↓ Move` to reorder among its siblings —
|
||
rather than one fixed button per composition array; an empty composition shows a single `+ Add view`,
|
||
and the F1 palette mirrors all four for the keyboard. The provider reads `editor.getPosition()`
|
||
and refreshes via an `onDidChange` emitter fired on cursor moves (keyed to the enclosing view,
|
||
so typing inside one view doesn't churn the lenses). After an edit the cursor follows the
|
||
affected view (`elementOffset`), so a repeated click keeps acting on it instead of the
|
||
neighbour that slid into place. Cursor-scoping does not strand a load-bearing action
|
||
([arch 10](10-interaction-and-feedback.md) — revealed actions): editing a composition puts
|
||
the cursor in a view exactly when add/reorder is wanted, and the palette is the ever-present
|
||
path for the keyboard.
|
||
- **Field source for hints (view-scoped).** `dataInfoAt(text, offset)` resolves the data
|
||
binding of the cursor's **nearest enclosing view** (`dataBindingAtPath` — a child inherits a
|
||
parent's data unless it declares its own), then its columns: a named **library dataset**
|
||
(matched case-insensitively, like the renderer), else the binding's **inline rows** profiled
|
||
on the fly (`rowsForDataBinding` + `core/profile`) — a "ghost dataset" with nothing stored.
|
||
Derived fields come from that view's and its ancestors' transforms only
|
||
(`derivedFieldNamesAtPath`), so a sibling view's `calculate` does not leak in. Profiling is
|
||
memoized per (draft text, enclosing view), so the inlay provider's many per-line queries
|
||
profile once. A composed spec whose views bind different datasets therefore gets the right
|
||
columns per view. 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.
|
||
- **Expression intelligence is one service; its markers are per-editor.** `spec-expression-hints`
|
||
registers the expression completion/signature-help/hover **once for `json`** (like the other
|
||
providers), but the marker pass — validating every expression string and squiggling the invalid
|
||
ones with `setModelMarkers` (the app's only editor markers besides the JSON worker's, under the
|
||
`vega-expr` owner) — is **per editor**, since it writes to one model, and recomputes debounced on
|
||
edit and on a draft↔published toggle. All expression concerns (completion, hover, markers) live
|
||
here; `spec-dataset-hints` owns only data-column hints, so neither is a grab-bag.
|
||
- **Completion replace-ranges come from a self-parsed partial, never `getWordUntilPosition`.**
|
||
Monaco's JSON `wordPattern` counts `.` and `(` as word characters, so the model's "word" after
|
||
`datum.` or `fn(` spans the whole `datum.`/`fn(` token; used as a completion item's range it both
|
||
mis-targets the edit and filters every suggestion out (none start with `datum.`). A provider
|
||
completing inside a string must build the replace range from the partial it parses itself — a rule
|
||
the transform scaffold inherits (it parses the trailing element word off the line). This
|
||
`new Range(line, col − partialLen, line, col)` construction is now at three sites
|
||
(`spec-expression-hints`, `spec-dataset-hints`, `spec-transform-scaffold`); a shared
|
||
`replaceRange(position, partialLength)` helper is earned and should be extracted on the next touch.
|
||
- **Data-transform scaffolding is a CodeLens (discoverable) plus a completion (accelerator), on
|
||
the one home the schema leaves bare.** A Vega-Lite data `transform` has three possible homes,
|
||
confirmed against the bundled schema (the `transform` array is on 16 spec types, i.e. every view
|
||
node): a `transform[]` **step**; the **inline** field props on an encoding channel
|
||
(`bin`/`timeUnit`/`aggregate`/`sort`); and a **new** pipeline on a bare view. We scaffold the step
|
||
home only, on the same "add only what the schema lacks" rule as `spec-dataset-hints`: the schema
|
||
already completes the inline channel keys and their enum values, and the `transform` key itself —
|
||
but never a ready, field-typed `{ "filter": … }`. The **CodeLens is the discoverable surface**
|
||
(a completion is invisible until provoked and competes silently with the schema's suggest items):
|
||
cursor-scoped like the composition CodeLens, it shows `+ Add transform` on a view with no pipeline
|
||
and per-step `+ filter`/`+ aggregate`/… on the array, each clicking through Monaco's snippet
|
||
engine so the field-typed tab stops survive. The completion is the type-to-filter accelerator on
|
||
the same catalog. A step's `scope` (`shared` when the array is on a composition parent, so it
|
||
feeds every child) is surfaced so the placement is not a surprise, and comma affixing keeps the
|
||
array valid whether the slot is empty, between elements, or appended after one without a trailing comma.
|
||
- **Parameter scaffolding splits by family, because `params` has two homes.** Unlike a `transform`
|
||
(on every view node), a **variable** widget (slider/dropdown/radio/checkbox, bound via `bind`) is only
|
||
legal in the **root** `params[]` — a document-global input any view's `filter` can read — while a
|
||
**selection** (point/interval, via `select`) attaches to the **unit** whose marks it reads. So the
|
||
scaffold does not reuse `transformSiteAt`: `paramSiteAt` resolves both homes, and the CodeLens is
|
||
cursor-scoped to match — variable widgets show at the top level (or a single-view spec, where the
|
||
root _is_ the unit, so both families land on one line), selections on the unit the cursor is in, and a
|
||
nested unit hides the variable widgets it cannot hold. The completion offers both families in the root
|
||
array, selections only in a nested unit's. Defaults are data-seeded where the data allows (a slider's
|
||
`min`/`max` from the numeric field's extent, a point's `fields` from a categorical column) and
|
||
structured placeholders otherwise (a dropdown's options).
|
||
- **Code-action menu icons are kind-derived** (a wrench for the `refactor.*` kinds) — Monaco's
|
||
`CodeAction` carries no icon field. Custom iconography lives only where it is supported:
|
||
CodeLens titles (`$(codicon)`), completion-item kinds, and glyph-margin decorations.
|
||
|
||
`jsonc-parser` is a direct dependency (Monaco bundles its own copy internally but does not
|
||
re-export it). A standalone `editor-augmentation-demo.html` loads Monaco from a CDN to
|
||
exercise these provider surfaces in isolation.
|
||
|
||
## Borrow list (where each lands)
|
||
|
||
| Technique | Lands in | Milestone |
|
||
| ----------------------------------------------------------------------------------------- | -------------------------------------- | --------- |
|
||
| Bundle VL schema from package `build/`; `setDiagnosticsOptions` | `src/app/infrastructure/` Monaco setup | M2 |
|
||
| `markdownDescription` patch + compact formatter | Monaco setup | M2 |
|
||
| Explicit Vite worker wiring (`MonacoEnvironment.getWorker`) | Monaco setup | M2 |
|
||
| `fileMatch` schema binding (improvement over `$schema`-only) | Monaco setup | M2 |
|
||
| jsonc-parser tolerant parse + line/col syntax errors | `src/core/` | M1/M2 |
|
||
| ajv wrapper (`strict:false`, draft-06, color-hex, compile-once) → structured diagnostics | `src/core/` | M2 |
|
||
| `LocalLogger`-style buffered diagnostics from pure compile | `src/core/` | M2 |
|
||
| Fatal-vs-advisory two-tier error model | rendering/store contract | M1/M2 |
|
||
| `"container"` sizing + `ResizeObserver` → synthetic `window:resize` (not `view.resize()`) | `chart-renderer` + LivePreview | M2 |
|
||
| `finalize()`-before-reembed + **render-generation guard** | LivePreview | M1 |
|
||
| theme = `vega-themes` config merged into `vegaEmbed` | preview + settings | M5 |
|
||
| `json-stringify-pretty-compact` format action | editor | M2 |
|
||
|
||
## Where we deliberately do better than the reference
|
||
|
||
- **Wire Monaco workers explicitly** (they sidestep it via the CDN loader).
|
||
- **Map ajv errors to editor positions** via jsonc-parser offsets (they show pointer text only).
|
||
- **Render-generation guard** against stale async renders (they rely on debounce alone).
|
||
- **`fileMatch`-based schema binding** so validation works without a `$schema` line.
|
||
- **Debounced auto-save to IndexedDB** rather than write-the-whole-state-on-every-change.
|
||
|
||
## Key files in the reference (for deeper reads)
|
||
|
||
- `src/utils/monaco.ts` — all Monaco/schema wiring
|
||
- `src/utils/markdownProps.ts` — the `markdownDescription` patch
|
||
- `src/utils/validate.ts` — ajv setup + cached validators
|
||
- `src/utils/jsonc-parser.ts` — tolerant parse + line/col syntax errors
|
||
- `src/utils/logger.ts` — `LocalLogger` / `DispatchingLogger`
|
||
- `src/components/renderer/renderer.tsx` — the hand-rolled View lifecycle (finalize, sizing, errors)
|
||
- `src/components/app.tsx:188-365` — parse → $schema check → ajv → compile → render orchestration
|
||
- `src/components/error-pane/renderer.tsx` — error/log display
|
||
- `src/constants/default-state.ts` — the full app-state shape
|
||
- `src/components/input-panel/spec-editor/renderer.tsx` — editor component, 1200ms debounce, $schema→mode detection
|