mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
552900dac5
|
|||
|
fb4b42a1a7
|
|||
|
e42a535726
|
|||
|
c5e4c4c76d
|
|||
|
87b1b98c12
|
|||
|
da6a675982
|
|||
|
9b2618cac6
|
|||
|
a8696f7195
|
|||
|
c019660692
|
|||
|
26b383ff33
|
|||
|
216797ff9b
|
|||
|
57604a80a6
|
|||
|
345ecef5a3
|
|||
|
4d1825b5ff
|
|||
|
01df58acf1
|
|||
|
8aa05e503d
|
|||
|
6656811b8e
|
|||
|
8cad80f738
|
|||
|
a75ea5b59e
|
|||
|
e69430834a
|
|||
|
31458114fb
|
|||
|
20e70bee0e
|
|||
|
fd5c51a761
|
|||
|
0c592fa81e
|
|||
|
64977f961b
|
|||
|
fff7ae98f2
|
|||
|
43d3b480d9
|
@@ -12,7 +12,7 @@ project's spec (`docs/spec/`) and architecture playbook (`docs/architecture/`).
|
||||
This skill is also executed by a **clean-context subagent** at session wrap-up (see
|
||||
CLAUDE.md → Session wrap-up protocol). When running as that subagent: you deliberately
|
||||
have no session context — judge the diff against the written contracts only, and return
|
||||
the summary (rule #15) as your final message so the session agent can relay it. If a
|
||||
the summary (rule #18) as your final message so the session agent can relay it. If a
|
||||
change looks deliberate but its rationale is recorded nowhere, that absence is itself a
|
||||
finding.
|
||||
|
||||
@@ -37,8 +37,11 @@ Review all changes in scope. If changes span multiple patterns below, apply all
|
||||
3. **Fix directly; don't ask first.** When you find an issue covered by these instructions,
|
||||
fix it in place rather than reporting it and waiting. Ask the user only when the fix is
|
||||
genuinely ambiguous or several valid approaches exist with real trade-offs. When guidelines
|
||||
conflict, prefer in this order: **SOUL.md philosophy > `docs/spec/` behavioral contract >
|
||||
`docs/architecture/` patterns > local cleanup**. These instructions are not strictly
|
||||
conflict, prefer in this order: **SOUL.md philosophy > `docs/spec/` behavioral record >
|
||||
`docs/architecture/` patterns > local cleanup**. (The spec is descriptive — the code
|
||||
leads. A spec/code mismatch is fixed by updating the stale spec section, not by
|
||||
reverting the code; only flag the code when it contradicts recorded _rationale_, not
|
||||
merely an unrewritten section.) These instructions are not strictly
|
||||
prohibitive — if a guideline has a valid reason to be bypassed, mention it in the summary.
|
||||
|
||||
### Code Quality
|
||||
@@ -155,7 +158,11 @@ role`) or the rule it demonstrates. - **Positional sub-section cross-refs.** Cit
|
||||
|
||||
14. **User-facing copy**: keep user-visible strings centralized and written for users (sentence
|
||||
case, active voice, no "please", no exclamation marks in errors). If/when an i18n layer
|
||||
exists, route strings through it instead of hardcoding.
|
||||
exists, route strings through it instead of hardcoding. **Product claims** (landing,
|
||||
About, onboarding, value props) follow `arch 10 §10`: claim only what we can certify, no
|
||||
absolutes (never/always/fully/everything), no durability the platform doesn't back ("saved",
|
||||
not "permanent"), and state a posture once per surface — reduce uncertain promises, keep the
|
||||
real ones.
|
||||
|
||||
15. **Chart-builder guidance reasons over role, not raw type** (`src/core/chart-builder.ts`):
|
||||
a `builderWarnings` rule (or any measure/dimension decision) must ask the post-transform
|
||||
@@ -167,9 +174,26 @@ role`) or the rule it demonstrates. - **Positional sub-section cross-refs.** Cit
|
||||
high-precision: prefer structural/data-driven hints; lean on the intent front door + smart
|
||||
defaults for positive guidance rather than enumerating bad combinations.
|
||||
|
||||
16. **Editor transform-actions reuse an applier, not an inlined skeleton**
|
||||
(`src/app/services/spec-transform-actions.ts`): a new `run*` action shaped
|
||||
parse → `build(spec)` → `writeBack` (info toast on null) calls the matching shared
|
||||
applier — `resolveTarget` (scoped), `applyArrayEdit` (one array), or `applyWholeSpecEdit`
|
||||
(whole-spec drag/simplify) — never re-inlining the model/parse/writeBack prologue. The
|
||||
family has grown by copy-paste twice (eng-council; arch 08).
|
||||
|
||||
17. **Spec tracks the surfaces it describes** (`docs/spec/`): a diff that **adds, removes,
|
||||
moves, or renames a user-facing surface** — a feature, message, control, or affordance —
|
||||
updates the `docs/spec/` section describing it (adding a section for new behavior), not
|
||||
only the `docs/architecture/` pattern doc. The spec is the behavioral record and the code
|
||||
leads; an arch-doc-only update leaves the spec describing a product that no longer exists.
|
||||
An arch-only update once left spec §03E mandating an editor-pane error message after it had
|
||||
moved to the preview (eng-council); a run of feature commits (2026-06-25 → 06-30: the
|
||||
/learn/ section, the composition wireframe, editor scaffolds) once landed with zero spec
|
||||
coverage (eng-council, 2026-07).
|
||||
|
||||
### Output
|
||||
|
||||
16. **Summary**: respond with a summary of changes — choices made due to these instructions,
|
||||
18. **Summary**: respond with a summary of changes — choices made due to these instructions,
|
||||
choices where multiple approaches existed, and non-obvious architectural assumptions the
|
||||
user should know but might not spot in the diff. If the summary mentions an observation you
|
||||
chose not to fix (rule #8), confirm a `// TODO:` breadcrumb was placed at the code site.
|
||||
@@ -188,7 +212,7 @@ role`) or the rule it demonstrates. - **Positional sub-section cross-refs.** Cit
|
||||
|
||||
Update relevant docs if the feature is significant:
|
||||
|
||||
- **`docs/spec/`** — if product behavior changed (this is a contract; change deliberately).
|
||||
- **`docs/spec/`** — if product behavior changed (descriptive record; update it to match what shipped — see #17).
|
||||
- **`docs/architecture/`** — if a new pattern, navigation map, or decision rule emerged.
|
||||
- **`docs/IMPLEMENTATION-PLAN.md`** — mark milestone progress.
|
||||
Use the `/doc-update` skill for session-discovered gaps. The list is not exclusive.
|
||||
@@ -208,7 +232,7 @@ If `package.json` changed:
|
||||
### Alignment Check
|
||||
|
||||
- **SOUL.md** — philosophy (must not violate without good reason).
|
||||
- **`docs/spec/`** — behavioral contract.
|
||||
- **`docs/spec/`** — behavioral record.
|
||||
- **`docs/architecture/`** — the relevant pattern doc.
|
||||
|
||||
---
|
||||
@@ -227,7 +251,7 @@ Usually not required unless the bug revealed incorrect docs, or the fix changes
|
||||
|
||||
### Alignment Check
|
||||
|
||||
- **SOUL.md** philosophy; **`docs/spec/`** behavioral contract; **`docs/architecture/`** patterns.
|
||||
- **SOUL.md** philosophy; **`docs/spec/`** behavioral record; **`docs/architecture/`** patterns.
|
||||
|
||||
---
|
||||
|
||||
@@ -268,10 +292,10 @@ Update JSDoc/inline comments if signatures or behavior changed.
|
||||
|
||||
## Reference Documents
|
||||
|
||||
| Document | Purpose |
|
||||
| ------------------------------------------------------------------- | ----------------------------------------- |
|
||||
| [SOUL.md](../../../SOUL.md) | Project philosophy and core values |
|
||||
| [AGENTS.md](../../../AGENTS.md) | AI onboarding and project context |
|
||||
| [docs/spec/](../../../docs/spec/) | Behavioral contract — _what_ the app does |
|
||||
| [docs/architecture/](../../../docs/architecture/00-overview.md) | Architecture playbook — _how_ it's built |
|
||||
| [docs/IMPLEMENTATION-PLAN.md](../../../docs/IMPLEMENTATION-PLAN.md) | Milestone sequence and scope |
|
||||
| Document | Purpose |
|
||||
| ------------------------------------------------------------------- | ---------------------------------------- |
|
||||
| [SOUL.md](../../../SOUL.md) | Project philosophy and core values |
|
||||
| [AGENTS.md](../../../AGENTS.md) | AI onboarding and project context |
|
||||
| [docs/spec/](../../../docs/spec/) | Behavioral record — _what_ the app does |
|
||||
| [docs/architecture/](../../../docs/architecture/00-overview.md) | Architecture playbook — _how_ it's built |
|
||||
| [docs/IMPLEMENTATION-PLAN.md](../../../docs/IMPLEMENTATION-PLAN.md) | Milestone sequence and scope |
|
||||
|
||||
@@ -30,9 +30,10 @@ For documentation organization, see **[CLAUDE.md](../../../CLAUDE.md)** and the
|
||||
|
||||
## The three documentation layers (know which one a gap belongs to)
|
||||
|
||||
- **`docs/spec/`** — the _what_: behavioral contract (what the app does, acceptance points).
|
||||
This is a **contract**. Only change it when product behavior genuinely changes, and do so
|
||||
deliberately — never as a casual "fill a doc gap" edit. A how-detail does NOT belong here.
|
||||
- **`docs/spec/`** — the _what_: behavioral record (what the app does, acceptance points).
|
||||
The code leads; the spec is kept rewritten to match what shipped. If the session added or
|
||||
changed user-facing behavior, name the spec section that describes it — or write it — as
|
||||
part of this pass. A how-detail does NOT belong here.
|
||||
- **`docs/architecture/`** — the _how_: the patterns behind each layer (state, persistence,
|
||||
modals, routing, rendering, inference, relationships). Most navigation maps and decision
|
||||
rules land here.
|
||||
@@ -59,19 +60,19 @@ not do Y"). If you can't state it concisely, it may be too implementation-specif
|
||||
|
||||
Map each gap to the right document:
|
||||
|
||||
| Gap type | Target document |
|
||||
| ------------------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| Product behavior, capabilities, acceptance points | `docs/spec/` (the relevant 00–10 section) — **contract; change deliberately** |
|
||||
| State / Zustand stores | `docs/architecture/01-state-and-stores.md` |
|
||||
| Persistence, IndexedDB, localStorage, migrations | `docs/architecture/02-persistence.md` |
|
||||
| Modals, dialog lifecycle | `docs/architecture/03-modal-system.md` |
|
||||
| URL routing, keyboard/events | `docs/architecture/04-routing-and-events.md` |
|
||||
| Rendering, theming, vega-embed, preview | `docs/architecture/05-rendering-theming-preview.md` |
|
||||
| Type inference, dataset profiling | `docs/architecture/06-type-inference.md` |
|
||||
| Names, snippet↔dataset links, rename propagation | `docs/architecture/07-naming-and-relationships.md` |
|
||||
| Milestone scope, build order | `docs/IMPLEMENTATION-PLAN.md` |
|
||||
| Project philosophy / identity | `SOUL.md` |
|
||||
| Onboarding, conventions, stack | `AGENTS.md` / `CLAUDE.md` |
|
||||
| Gap type | Target document |
|
||||
| ------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| Product behavior, capabilities, acceptance points | `docs/spec/` (the relevant 00–10 section) — **record; keep it matching the code** |
|
||||
| State / Zustand stores | `docs/architecture/01-state-and-stores.md` |
|
||||
| Persistence, IndexedDB, localStorage, migrations | `docs/architecture/02-persistence.md` |
|
||||
| Modals, dialog lifecycle | `docs/architecture/03-modal-system.md` |
|
||||
| URL routing, keyboard/events | `docs/architecture/04-routing-and-events.md` |
|
||||
| Rendering, theming, vega-embed, preview | `docs/architecture/05-rendering-theming-preview.md` |
|
||||
| Type inference, dataset profiling | `docs/architecture/06-type-inference.md` |
|
||||
| Names, snippet↔dataset links, rename propagation | `docs/architecture/07-naming-and-relationships.md` |
|
||||
| Milestone scope, build order | `docs/IMPLEMENTATION-PLAN.md` |
|
||||
| Project philosophy / identity | `SOUL.md` |
|
||||
| Onboarding, conventions, stack | `AGENTS.md` / `CLAUDE.md` |
|
||||
|
||||
If a gap fits no existing document, consider a new section in the closest one; prefer
|
||||
extending over creating. A brand-new architecture topic can become `docs/architecture/08-*.md`
|
||||
|
||||
@@ -14,3 +14,7 @@ coverage
|
||||
|
||||
# M1.5 visual verification screenshots (local only)
|
||||
.m15-screenshots/
|
||||
|
||||
# Generated per-lesson page shells (learn/<slug>/index.html) — produced from the
|
||||
# lesson .md frontmatter by scripts/learn-pages.ts on every dev/build.
|
||||
learn/*/
|
||||
|
||||
@@ -11,9 +11,10 @@ a local library of **snippets** (saved Vega-Lite specs), edits each as JSON with
|
||||
validation and a live chart preview, and reuses **datasets** across many snippets. Fully
|
||||
local, offline-capable, no account.
|
||||
|
||||
It is a **spec-driven rebuild** on an architecture adapted from its sibling project Syto.
|
||||
The authoritative behavioral contract is **`docs/spec/`** (sections 00–10). Implement _to
|
||||
the spec_; do not port legacy code.
|
||||
It is a rebuild on an architecture adapted from its sibling project Syto. **`docs/spec/`**
|
||||
(sections 00–10) is the behavioral record: the code leads and the spec is kept rewritten
|
||||
to match — a user-facing change isn't done until its spec section describes it. Do not
|
||||
port legacy code.
|
||||
|
||||
### Technical Stack
|
||||
|
||||
@@ -74,7 +75,7 @@ src/
|
||||
│ └── infrastructure/ # IndexedDB, localStorage, Monaco, settings adapters
|
||||
styles/ # Global CSS (tokens, base)
|
||||
docs/
|
||||
├── spec/ # Authoritative behavioral specification (00–10) — the WHAT
|
||||
├── spec/ # Behavioral specification (00–10) — the WHAT (record; code leads)
|
||||
├── architecture/ # Architecture playbook (00–11) — the HOW (self-contained)
|
||||
│ └── visual-specimen.html # Standalone token sandbox + reusable-primitive catalog
|
||||
├── exploration/ # Point-in-time records (research, reviews, scope memos) — not maintained
|
||||
@@ -116,8 +117,10 @@ npm run format # Prettier
|
||||
trimming IBM Plex to the latin subsets dropped Cyrillic — capability for an
|
||||
internationally-usable app. We ship every script subset and precache them for offline;
|
||||
`unicode-range` means the browser only downloads what a glyph needs anyway.)
|
||||
- **Spec is the contract** — when in doubt, read `docs/spec/`. If the spec is wrong or
|
||||
silent, raise it; change the spec deliberately rather than drifting from it.
|
||||
- **Spec follows code** — `docs/spec/` is the descriptive record of behavior; the code
|
||||
leads. When in doubt about existing behavior, read the spec. When you ship user-facing
|
||||
behavior, update the matching spec section in the same session; if spec and app
|
||||
disagree, the spec is stale — rewrite it deliberately, never drift silently.
|
||||
- **Core-first** — for each feature, build the pure `src/core/` logic with tests before UI.
|
||||
- **Session wrap-up** — when the user signals the session is wrapping, run the review
|
||||
pass before any commit: `/doc-update` in-session first (flush unrecorded rationale),
|
||||
|
||||
@@ -5,8 +5,9 @@ See @AGENTS.md for project overview, architecture rules, and the AI developer pr
|
||||
## Documentation Index
|
||||
|
||||
- **[SOUL.md](SOUL.md)** — project philosophy and identity. _Read first._
|
||||
- **[docs/spec/](docs/spec/)** — authoritative behavioral specification (sections 00–10):
|
||||
the **what**. This is the contract; implement to it.
|
||||
- **[docs/spec/](docs/spec/)** — behavioral specification (sections 00–10): the **what**.
|
||||
Descriptive, kept current with the code: the code leads; on conflict amend the spec,
|
||||
never drift silently. User-facing behavior ships with its spec section.
|
||||
- **[docs/architecture/](docs/architecture/00-overview.md)** — architecture playbook
|
||||
(00–11): the **how** (state, persistence, modals, routing, rendering, inference,
|
||||
relationships, vega-editor techniques, visual design, interaction & feedback, learning
|
||||
@@ -31,8 +32,9 @@ See @AGENTS.md for project overview, architecture rules, and the AI developer pr
|
||||
|
||||
## Quick Orientation
|
||||
|
||||
- Astrolabe is a **spec-driven rebuild** — the behavior is fixed in `docs/spec/`; the
|
||||
architecture is adapted from Syto. Implement to the spec; don't port legacy code.
|
||||
- Astrolabe is a **spec-recorded rebuild** — the architecture is adapted from Syto, and
|
||||
`docs/spec/` records the behavior as built. Build deliberately, record in the spec;
|
||||
don't port legacy code.
|
||||
- **`src/core/` is portable and tested hardest.** Browser specifics live in
|
||||
`src/app/infrastructure/`. UI is React + Zustand.
|
||||
- **Editor is Monaco**, charts render via **vega-embed**, storage is **IndexedDB**.
|
||||
@@ -70,7 +72,11 @@ run the review pass **before** anything is committed:
|
||||
sweeps stay a deliberate act).
|
||||
|
||||
Run the subagents **sequentially**, not in parallel — both may edit the working tree.
|
||||
Relay each report back to the user. Arbitrate findings that needed session context:
|
||||
either accept them, or overrule them **and** record the missing rationale where the
|
||||
reviewer looked for it — an overruled finding without a writing-down will recur. Then
|
||||
commit only when invited.
|
||||
Relay each report back to the user, then arbitrate each finding to one of three ends —
|
||||
**accept and fix**, **accept but defer**, or **overrule** — and **write down the two you
|
||||
don't act on now**, because a subagent's report is ephemeral and chat is not a record. A
|
||||
deferred finding gets a `// TODO:` at the relevant code site (or a line in the closest doc);
|
||||
an overruled one records the missing rationale where the reviewer looked. Out of scope for
|
||||
_this session_ is not out of scope for the _project_: with a single maintainer there is no
|
||||
"someone else's problem", so an unrecorded deferral or beyond-scope note recurs as work
|
||||
handed to your future self. Then commit only when invited.
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
A browser-based **snippet manager for [Vega-Lite](https://vega.github.io/vega-lite/)
|
||||
visualizations**. Author chart specs as JSON, watch them render live, and keep a personal,
|
||||
searchable library — fully local, offline-capable, no account.
|
||||
searchable library — local, offline-capable, no account.
|
||||
|
||||
> Astrolabe is a **spec-driven rebuild** on an architecture adapted from its sibling
|
||||
> project Syto. The authoritative behavioral contract lives in [`docs/spec/`](docs/spec/).
|
||||
> Astrolabe is a rebuild on an architecture adapted from its sibling project Syto. The
|
||||
> behavioral record lives in [`docs/spec/`](docs/spec/) — the code leads; the spec is
|
||||
> kept rewritten to match.
|
||||
> See [SOUL.md](SOUL.md) for the philosophy and [docs/IMPLEMENTATION-PLAN.md](docs/IMPLEMENTATION-PLAN.md)
|
||||
> for the build sequence.
|
||||
|
||||
@@ -23,10 +24,30 @@ npm run typecheck
|
||||
npm test # Vitest
|
||||
```
|
||||
|
||||
## What it does
|
||||
|
||||
- **Editor + live preview** — write Vega-Lite specs as JSON in Monaco with schema-aware
|
||||
validation and autocomplete; the chart re-renders as you type.
|
||||
- **Snippet library** — save, search, tag, and organize specs. Each snippet carries a stable
|
||||
published version plus a separate editable draft, so you can tinker without losing a
|
||||
known-good copy.
|
||||
- **Reusable datasets** — store data once (inline, or fetched once from a URL and snapshotted)
|
||||
and reference it by name from many snippets.
|
||||
- **Chart Builder** — a no-JSON on-ramp that generates a spec from field, mark, and encoding
|
||||
choices. The JSON stays the source of truth and is always editable.
|
||||
- **Theming & fonts** — custom chart themes with a visual Theme Builder, a curated font roster,
|
||||
and your own uploaded font faces.
|
||||
- **Local-first** — your library lives in the browser (IndexedDB); offline-capable and installable
|
||||
(PWA), with import/export for backup and transfer. No account, no server, no AI — with no
|
||||
backend to send it to, your library stays on your device, so it's safe for confidential work
|
||||
from the first chart.
|
||||
|
||||
## Status
|
||||
|
||||
**M0 — Skeleton.** Toolchain green (typecheck, tests, build, PWA). The three-pane shell
|
||||
renders; features land milestone by milestone per the implementation plan (MVP at end of M1).
|
||||
Active development, **pre-1.0** and not yet publicly released — well past the initial milestones
|
||||
and usable day to day; the first public release will be `1.0.0`. Behavior is specified in
|
||||
[`docs/spec/`](docs/spec/) and the architecture patterns in
|
||||
[`docs/architecture/`](docs/architecture/00-overview.md).
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -28,8 +28,10 @@ data lives in one place and the specs stay lean.
|
||||
### 1. Local-Only by Default
|
||||
|
||||
Everything runs in the browser. Snippets, datasets, and settings never leave the machine.
|
||||
No accounts, no uploads, no tracking. The only outbound requests are user-created
|
||||
URL-dataset fetches.
|
||||
No accounts, no uploads, no tracking, and the app contacts no third party on its own —
|
||||
including an AI model. The only outbound requests are user-created URL-dataset fetches.
|
||||
Because your work stays on the machine, confidential and work data are safe in Astrolabe
|
||||
from the first chart.
|
||||
|
||||
### 2. Vega-Lite Native, Not Vega-Lite Hidden
|
||||
|
||||
@@ -71,15 +73,18 @@ clever.
|
||||
- **Not a collaboration platform.** No multi-user, no sync, no comments. Import/export
|
||||
moves data between machines.
|
||||
- **Not a server app.** No backend, no rendering service, no account system.
|
||||
- **Not an AI tool.** No model authors, edits, or critiques charts, and nothing is sent to
|
||||
one. The chart builder's recommendations are deterministic and rule-based, computed
|
||||
locally — chosen over an LLM so results are explainable and nothing leaves the machine.
|
||||
|
||||
## Technical Philosophy
|
||||
|
||||
### Spec-Driven, Clean Implementation
|
||||
### Spec-Recorded, Clean Implementation
|
||||
|
||||
The behavioral contract lives in `docs/spec/`. Astrolabe is a deliberate rebuild on a
|
||||
robust architecture (adapted from Syto): we implement _to the spec_, not by porting old
|
||||
code. When the spec and convenience conflict, the spec wins or the spec changes — never
|
||||
silent drift.
|
||||
The behavioral record lives in `docs/spec/`. Astrolabe is a deliberate rebuild on a
|
||||
robust architecture (adapted from Syto): behavior is designed deliberately and recorded
|
||||
in the spec, not ported from old code. The code leads; the spec is rewritten to match
|
||||
what ships — never silent drift.
|
||||
|
||||
### Portable Core, Thin Browser Shell
|
||||
|
||||
|
||||
+67
-480
@@ -1,8 +1,13 @@
|
||||
# Astrolabe — Incremental Implementation Plan
|
||||
|
||||
> A spec-driven rebuild of Astrolabe on Syto's architecture. The authoritative
|
||||
> behavioral contract is `docs/spec/` (sections 00–10). This document sequences
|
||||
> the build into the **quickest path to a usable MVP**, then layers the rest.
|
||||
> A rebuild of Astrolabe on Syto's architecture. The behavioral record is
|
||||
> `docs/spec/` (sections 00–10) — the code leads, and the spec is kept
|
||||
> rewritten to match.
|
||||
>
|
||||
> **The M0–M6 build is complete** — the milestone map below is the record. Remaining
|
||||
> work is post-M6 enhancement, tracked in the **live backlog** and owned in detail by
|
||||
> the two scope docs it points to. Per-milestone build notes aren't kept here; the git
|
||||
> history and the spec/architecture docs hold them.
|
||||
>
|
||||
> **Method per milestone:** build core-first (portable, pure, tested) → wire UI →
|
||||
> cover with tests → manual smoke check against the spec's acceptance points.
|
||||
@@ -12,7 +17,7 @@
|
||||
|
||||
## Architectural ground rules
|
||||
|
||||
These are decided and apply to every milestone. The **how** behind each is written up
|
||||
These are decided and apply to all work. The **how** behind each is written up
|
||||
self-containedly in [`docs/architecture/`](architecture/00-overview.md) — read the matching
|
||||
doc before implementing.
|
||||
|
||||
@@ -42,499 +47,79 @@ doc before implementing.
|
||||
|
||||
## Milestone map
|
||||
|
||||
| # | Milestone | Outcome | Spec |
|
||||
| -------- | --------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- |
|
||||
| **M0** | Skeleton ✅ | Repo builds, tests run, empty shell renders | — |
|
||||
| **M1** | **MVP core loop** | Author a Vega-Lite snippet, see it render live, it persists | §02, §03A–C, §04, §09A |
|
||||
| **M1.5** | Visual design foundation ✅ | Apply the design language: tokens, IBM Plex, restyled M1 surfaces, chart theme | [arch 09](architecture/09-visual-design.md) |
|
||||
| **M2** | Editor robustness ✅ | Draft/Published, validation, schema autocomplete, fit modes | §03D–E, §04, §07(editor) |
|
||||
| **M3** | Datasets ✅ | Named reusable data + reference resolution in preview | §05, §03F, §09B |
|
||||
| **M4** | Chart Builder ✅ | No-JSON chart composition from a dataset | §06 |
|
||||
| **M5** | Settings + Import/Export ✅ | Preferences + workspace backup/transfer | §07, §08, §09C |
|
||||
| **M6** | Shell polish ✅ | Resize/toggle panes, routing, shortcuts, toasts, a11y, offline | §01, §10 |
|
||||
The whole sequence is shipped. This table is the record; build notes for each live in the
|
||||
git history and the spec/architecture docs.
|
||||
|
||||
| # | Milestone | Outcome | Spec |
|
||||
| -------- | -------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------- |
|
||||
| **M0** | Skeleton ✅ | Repo builds, tests run, empty shell renders | — |
|
||||
| **M1** | MVP core loop ✅ | Author a Vega-Lite snippet, see it render live, it persists | §02, §03A–C, §04, §09A |
|
||||
| **M1.5** | Visual design foundation ✅ | Apply the design language: tokens, IBM Plex, restyled M1 surfaces, chart theme | [arch 09](architecture/09-visual-design.md) |
|
||||
| **M2** | Editor robustness ✅ | Draft/Published, validation, schema autocomplete, fit modes | §03D–E, §04, §07(editor) |
|
||||
| **M3** | Datasets ✅ | Named reusable data + reference resolution in preview | §05, §03F, §09B |
|
||||
| **M4** | Chart Builder ✅ | No-JSON chart composition from a dataset | §06 |
|
||||
| **M4.5** | Snippet-library consolidation ✅ | Metadata panel (rename/comment/links), Duplicate | §02 |
|
||||
| **M5** | Settings + Import/Export ✅ | Preferences + workspace backup/transfer | §07, §08, §09C |
|
||||
| **M6** | Shell polish ✅ | Resize/toggle panes, routing, shortcuts, toasts, a11y, offline | §01, §10 |
|
||||
|
||||
**MVP boundary = end of M1** (a genuinely usable single-user chart authoring loop).
|
||||
M1.5 makes it _look right_; M2 makes it _robust_; M3–M6 make it _complete_.
|
||||
Ship/dogfood after M1, iterate.
|
||||
M1.5 made it _look right_; M2 made it _robust_; M3–M6 made it _complete_.
|
||||
|
||||
---
|
||||
|
||||
## M0 · Skeleton ✅ (done)
|
||||
|
||||
Vite + React + Zustand + TypeScript + Vitest (happy-dom) + vite-plugin-pwa.
|
||||
`src/core` ↔ `src/app` split, `useAppStore`, design tokens, three-pane placeholder
|
||||
shell, first core module (`format-detection`) with tests.
|
||||
|
||||
**Verified:** `npm run typecheck`, `npm test`, `npm run build` (PWA SW generated).
|
||||
|
||||
---
|
||||
|
||||
## M1 · MVP core loop → _the quickest usable Astrolabe_
|
||||
|
||||
**Goal:** select/create a snippet, edit its spec JSON, watch a live Vega-Lite
|
||||
preview, and have it survive reload. Single source kind: inline-data specs only
|
||||
(datasets come in M3). No draft/published yet — edits save directly.
|
||||
|
||||
**Core (`src/core/`)**
|
||||
|
||||
- `snippet.ts` — the Snippet type (spec §09A) + factory (`createSnippet`,
|
||||
default sample bar-chart template, auto-generated date/time name).
|
||||
- `rendering.ts` — `prepareSpecForRender(spec, { fitMode })` skeleton; in M1 it's
|
||||
near pass-through (reference resolution is a no-op until M3, fit-mode is M2).
|
||||
Establish the "transform a copy, never mutate stored spec" contract now.
|
||||
|
||||
**Infrastructure (`src/app/infrastructure/`)**
|
||||
|
||||
- `idb.ts` — thin IndexedDB wrapper (open, get/put/delete/getAll by store).
|
||||
_(see [Architecture 02 · Persistence](architecture/02-persistence.md))_
|
||||
- `snippet-store.ts` — persist snippets (object store `snippets`).
|
||||
|
||||
**App**
|
||||
|
||||
- `stores/SnippetStore.ts` — `useSnippetStore` with `snippets`, `activeSnippetId`,
|
||||
selector-derived `activeSnippet`; load-on-startup; create/select/delete/update actions
|
||||
(debounced auto-save of edits, spec §03B). Seed one sample snippet on first run.
|
||||
_(Superseded later: an empty library now shows the onboarding canvas instead of a
|
||||
placeholder seed — spec §02 → First-Run & Empty Workspace.)_
|
||||
- `components/SnippetLibrary.tsx` — list + "Create New" pinned item + select/delete.
|
||||
- `components/SpecEditor.tsx` — Monaco JSON editor bound to active snippet's spec;
|
||||
debounced write-back to the store. (Worker wiring via Vite `?worker` imports —
|
||||
mine vega-editor's Monaco setup.)
|
||||
- `components/LivePreview.tsx` — render current spec via `vega-embed` (actions:
|
||||
false), debounced; clean empty pane when no/blank spec; basic error text.
|
||||
- Fill the three panes in `App.tsx` with these.
|
||||
|
||||
**Tests (core-first)**
|
||||
|
||||
- `snippet.test.ts` — factory defaults, sample template validity, unique naming.
|
||||
- `rendering.test.ts` — copy-not-mutate invariant; pass-through shape.
|
||||
- A store test for create/select/delete/auto-save reducer logic (logic extracted
|
||||
from the component so it's testable without DOM).
|
||||
|
||||
**Manual checks**
|
||||
|
||||
- Fresh load shows the onboarding canvas (welcome + Create + live example gallery);
|
||||
Create or an example's Add lands you in the editor with a rendered chart. _(M1 originally
|
||||
seeded a sample snippet; replaced by the onboarding canvas — spec §02.)_
|
||||
- Type in the editor → preview updates after the debounce; bad JSON → editor keeps
|
||||
working, preview shows an error, recovers when fixed.
|
||||
- Reload → snippets and selection persist.
|
||||
|
||||
---
|
||||
|
||||
## M1.5 · Visual design foundation ✅ (done) → _make the MVP look like itself_
|
||||
|
||||
**Goal:** apply our design language so the running MVP looks deliberate, and every
|
||||
later milestone builds on settled tokens instead of placeholders. The expensive part
|
||||
(the design decisions) is already done — this milestone is _application_, not
|
||||
invention. See [Architecture 09 · Visual Design Language](architecture/09-visual-design.md)
|
||||
and the companion `visual-specimen.html`.
|
||||
|
||||
**Styles**
|
||||
|
||||
- Port the settled specimen tokens into `styles/tokens.css` (IBM-Plex type scale,
|
||||
8px-based spacing, role-based color, square chrome, motion); light + dark themes
|
||||
via `[data-theme]`.
|
||||
- Self-host **IBM Plex Sans + Mono** in `styles/base.css` via `@fontsource`
|
||||
(offline/PWA — never a CDN).
|
||||
|
||||
**App**
|
||||
|
||||
- Restyle the four M1 surfaces against the tokens: App shell, SnippetLibrary,
|
||||
SpecEditor (Monaco theme follows `[data-theme]`), LivePreview. Tokens only — no
|
||||
raw hexes, no hardcoded hues in components.
|
||||
- Establish the reusable component conventions (buttons, fields, list rows, status,
|
||||
focus ring) that M2–M6 reuse.
|
||||
- **Header theme toggle** (pulled forward from M5): a one-click light⇄dark control,
|
||||
persisted via the `ui.theme` settings key (a minimal forward-compatible
|
||||
`settings-store` adapter the full M5 UserSettings store will absorb). Hydrated
|
||||
before first paint (no FOUC). Justified: the theme system was already complete,
|
||||
so dogfooding dark mode through M2–M4 beat waiting for the full settings UI.
|
||||
|
||||
**Core**
|
||||
|
||||
- Align `src/core/vega-themes.ts`: chart `Config` per theme + a categorical
|
||||
`range.category` palette (clone `carbon-design-system/carbon-charts` for the
|
||||
sequence — see Architecture 09 §8).
|
||||
|
||||
**Tests**
|
||||
|
||||
- Light: the design is mostly visual — a token/theme smoke check, trust the eye.
|
||||
|
||||
**Manual checks**
|
||||
|
||||
- The real app looks deliberate in both themes; theme flip repaints UI + chart.
|
||||
- Keyboard focus ring visible; text/UI contrast passes AA in light and dark.
|
||||
- No placeholder styling remains on the M1 surfaces.
|
||||
|
||||
**Verified:** `typecheck` + `test` (incl. `vega-themes.test.ts`) +
|
||||
`build` (Plex woff2, all script subsets, bundled & precached via the PWA
|
||||
`globPatterns`). Both themes screenshotted via the real
|
||||
app (chrome + Monaco + chart all repaint on theme flip); focus ring visible.
|
||||
Notes from the build-out: the placeholder `'experimental'` theme was renamed to
|
||||
`'dark'` (the settled name); the swappable `[data-accent]` layer landed with
|
||||
deep teal as the robust default (no switcher UI until M5); Monaco's `fontFamily` is
|
||||
set to Plex Mono explicitly since it can't read the CSS token.
|
||||
|
||||
---
|
||||
|
||||
## M2 · Editor robustness ✅ (done)
|
||||
|
||||
**Goal:** the editor becomes trustworthy — draft vs published, schema-aware
|
||||
assistance, and the fit-mode rendering contract.
|
||||
|
||||
**Core**
|
||||
|
||||
- `rendering.ts` — implement **fit-mode** transform (Original/Width/Height/Full →
|
||||
Vega-Lite `"container"`), recursing into layered/concat/child specs (spec §04
|
||||
Rendering Contract, step 2).
|
||||
- `vega-lite-schema.ts` — provide the Vega-Lite JSON schema for Monaco's
|
||||
validation/autocomplete. _Delivered early in M1.5 as
|
||||
`infrastructure/monaco-schema.ts` (bundled schema, offline, `markdownDescription`
|
||||
hover docs); no further work needed in M2._
|
||||
|
||||
**App**
|
||||
|
||||
- Snippet gains `spec` (published) + `draftSpec` (working) per §09A; editing
|
||||
touches `draftSpec` only.
|
||||
- `SpecEditor` header: Draft/Published toggle; **Publish** (promotes draft, recomputes
|
||||
dataset refs — refs land in M3) + **Revert** (confirm dialog).
|
||||
- Library list item: draft-vs-published **status indicator**.
|
||||
- Monaco wired with the Vega-Lite schema → squiggles + autocomplete; inline error
|
||||
surface in the editor pane (§03E).
|
||||
- Preview **Fit control** (4 modes), persisted (`previewFitMode`).
|
||||
|
||||
**Tests**
|
||||
|
||||
- Fit-mode transforms for each mode incl. nested specs; copy-not-mutate.
|
||||
- Draft/publish/revert reducer logic; "has unpublished changes" derivation.
|
||||
|
||||
**Manual checks**
|
||||
|
||||
- Edit draft, see status flip to "draft"; Publish → status clears; Revert →
|
||||
draft restored with confirmation.
|
||||
- Invalid spec shows inline error; autocomplete suggests Vega-Lite properties.
|
||||
- Each fit mode resizes the chart as specified; choice survives reload.
|
||||
|
||||
**Verified:** `typecheck` + `test` (`rendering` fit-mode incl.
|
||||
nested layer/concat/facet specs, `SnippetStore` draft/publish/revert/editorView,
|
||||
`settings-store` `previewFitMode` round-trip) + `build` (PWA, 41 precache
|
||||
entries) + `eslint` clean. Implementation notes: editing now writes the
|
||||
**draft** only (`commitDraft` no longer touches `spec`); `publish`/`revert` live
|
||||
in `SnippetStore`, with a `bufferEpoch` counter so programmatic buffer reloads
|
||||
(select/create/revert) refresh Monaco without fighting the cursor mid-typing.
|
||||
The Draft/Published view is a store-level `editorView`; the published view is
|
||||
read-only and the preview renders whichever version is shown (`selectShownText`).
|
||||
The editor (§03E) and preview (§04) share one render error via a small
|
||||
`PreviewStore`. `previewFitMode` was pulled into `AppStore` + the settings
|
||||
adapter, hydrated/persisted by a new `orchestration/preferences.ts` mirroring the
|
||||
theme slice. Publish/Revert **success toasts** stay deferred to M6 (TODO
|
||||
breadcrumbs at the call sites), matching the existing delete-toast convention.
|
||||
|
||||
Fit-mode rendering needed a layout fix: vega-embed brands the embed host with its
|
||||
own `.vega-embed { display: inline-block }` (injected at runtime, wins the
|
||||
cascade), which shrink-wrapped the host so `width: "container"` collapsed (Height
|
||||
survived only via the old `min-height: 100%`). Fix: embed into a static-class
|
||||
inner host (React never reconciles its className, so Vega's runtime classes
|
||||
survive) inside a React-owned frame that carries the fit-sizing class via
|
||||
two-class selectors that out-specify `.vega-embed`. All four fit modes
|
||||
user-verified in the running app.
|
||||
|
||||
---
|
||||
|
||||
## M3 · Datasets ✅ (done)
|
||||
|
||||
**Goal:** named, reusable data that snippets reference by name; preview resolves
|
||||
the reference.
|
||||
|
||||
**Core**
|
||||
|
||||
- `profiling.ts` — row/column counts, column names, **per-column type inference**
|
||||
(number/text/date/boolean). _(see [Architecture 06 · Type Inference](architecture/06-type-inference.md))_
|
||||
- `rendering.ts` — implement **dataset reference resolution** (§04 Rendering
|
||||
Contract, step 1): `{data:{name}}` → inline values / raw text+format / URL+format,
|
||||
recursing into sub-specs; "dataset not found" error.
|
||||
- `dataset.ts` — Dataset type (§09B); name uniqueness helpers; rename-propagation
|
||||
into referencing specs. _(see [Architecture 07 · Naming & Relationships](architecture/07-naming-and-relationships.md))_
|
||||
|
||||
**Infrastructure**
|
||||
|
||||
- `dataset-store.ts` — separate high-capacity IndexedDB store (§09E).
|
||||
|
||||
**App**
|
||||
|
||||
- `stores/DatasetStore.ts` + Datasets **modal** (list/detail panes, create form,
|
||||
edit, delete, copy-reference) via the modal registry/coordinator.
|
||||
- Snippet `datasetRefs` maintained on publish; library shows dataset icon +
|
||||
Linked Datasets; dataset detail shows Linked Snippets (bidirectional name link, §09F).
|
||||
- **Extract-to-Dataset** flow from the editor (§03F).
|
||||
- URL datasets fetched once on add and **snapshotted** locally (profiled like inline;
|
||||
render from the snapshot, refreshable on demand — see §05 / §04 rendering contract).
|
||||
|
||||
**Tests**
|
||||
|
||||
- Reference resolution per source/format incl. nested; not-found error.
|
||||
- Profiling/type inference across mixed columns, nulls, booleans.
|
||||
- Rename propagation; name-uniqueness + import-style auto-suffix.
|
||||
|
||||
**Manual checks**
|
||||
|
||||
- Create a dataset, reference it by name in a snippet → preview renders.
|
||||
- Extract inline data → spec rewritten to a reference, dataset appears, links show both ways.
|
||||
- Delete/rename a referenced dataset behaves per spec.
|
||||
|
||||
---
|
||||
|
||||
## M4 · Chart Builder ✅ (done)
|
||||
|
||||
**Goal:** no-JSON chart composition from a dataset → a new snippet.
|
||||
|
||||
> **Enhancement push (post-M4).** The forward plan now lives in
|
||||
> [`docs/exploration/chart-builder-enhancement-scope.md`](exploration/chart-builder-enhancement-scope.md) — it merges
|
||||
> the Tier-B backlog ([`chart-builder-research.md`](exploration/chart-builder-research.md) §8) with the
|
||||
> Lyra interaction review ([`lyra-review.md`](exploration/lyra-review.md)) and sets a **Tier-C** target.
|
||||
> Shipped beyond the Tier-B floor so far: per-channel aggregate/bin/`timeUnit`, sort/stack;
|
||||
> **actionable hints** (one-click warning fixes); and a builder UX/perf batch (near-fullscreen
|
||||
> modal, canvas preview + canvas max-dimension guard, data-aware default pre-population). See
|
||||
> the scope doc §4 for the sequenced plan and current status.
|
||||
|
||||
**Core**
|
||||
|
||||
- `chart-builder.ts` — pure spec assembler: (mark ∈ Bar/Line/Point/Area/Circle) +
|
||||
channels (X/Y/Color/Size) with field types (Quantitative/Nominal/Ordinal/Temporal)
|
||||
- optional width/height → complete Vega-Lite spec with tooltips + named data ref
|
||||
(§06 Output). Field-type defaults from inferred column type.
|
||||
|
||||
**App**
|
||||
|
||||
- Chart Builder **modal** (config pane + live preview pane), launched from a
|
||||
selected dataset; default pre-population (first col→X, second→Y); validation
|
||||
(≥1 channel); Create Snippet → new linked snippet becomes active.
|
||||
|
||||
**Tests**
|
||||
|
||||
- Spec assembly: mark/channel/type permutations, unmapped channels omitted,
|
||||
width/height inclusion, field-type derivation, validation gate.
|
||||
|
||||
**Manual checks**
|
||||
|
||||
- Build a bar chart from a dataset in a few clicks; preview live-updates;
|
||||
Create → new snippet opens and renders.
|
||||
|
||||
**Council** — **FT Visual Vocabulary** + **Datawrapper** are now **seated** (chart-choice
|
||||
canon): this is where Astrolabe stops being a pass-through JSON editor and starts making
|
||||
chart-shaped suggestions/defaults, so "_which chart, and why_" becomes a decision the app
|
||||
owns — the one thing Carbon's data-viz styling doesn't cover. Rather than a styling-only
|
||||
seating, we ran a full **research-first** pass (FT + Datawrapper + the formal engines
|
||||
**Draco** and **Voyager**), recorded in [`docs/exploration/chart-builder-research.md`](exploration/chart-builder-research.md),
|
||||
and chose the **Tier B "smart + guarded"** design: smart default mark for the data shape,
|
||||
valid-type-locked field-type menus, Size-channel discipline, and non-blocking guidance.
|
||||
The convergent rules and citations live in that doc; the spec (§06) was amended to match.
|
||||
|
||||
**Verified:** `typecheck` + `test` green — the pure `chart-builder.ts` assembler
|
||||
(`chart-builder.test.ts`: mark/channel/type permutations, unmapped-channel omission,
|
||||
validation gate). The Chart Builder modal is wired through the registry, launched from a
|
||||
selected dataset (`DatasetsModal` → `openModal('chartBuilder')`). Data-aware
|
||||
cardinality/extent profiling + chart warnings landed (A3/A4).
|
||||
|
||||
---
|
||||
|
||||
## M4.5 · Snippet-library consolidation ✅ (done)
|
||||
|
||||
**Why out of band:** a spec-vs-implementation audit after M4 found §02 features that no
|
||||
later milestone owned — the **Selected-Snippet Metadata Panel** (inline name + comment
|
||||
editing, timestamps, linked datasets) and the **Duplicate** operation. Without them a
|
||||
snippet could only ever carry its auto-generated date-time name (no rename, no annotation,
|
||||
no copy), a sharp edge for a _snippet manager_. Closed before M5 since the spec text
|
||||
already existed and the work was core-first and cheap.
|
||||
|
||||
**Core**
|
||||
|
||||
- `snippet.ts` — `duplicateSnippet(source, {now,id})`: independent copy carrying both spec
|
||||
versions, comment, tags, and dataset refs; "(copy)" name; fresh identity/timestamps;
|
||||
cloned mutable members.
|
||||
|
||||
**App**
|
||||
|
||||
- `SnippetStore` — `renameSnippet`, `setComment` (both advance `modified` per §02 → Sort,
|
||||
no editor-buffer touch), `duplicateActiveSnippet` (flushes the live buffer first, prepends
|
||||
the copy, makes it active).
|
||||
- `SnippetLibrary` — the metadata panel below the list: Name + Comment auto-save (debounced
|
||||
while typing, flushed on blur), read-only Created/Modified, Linked Datasets list, and
|
||||
Duplicate / Delete. Duplicate raises a success toast (the copy isn't self-evident, unlike
|
||||
Create); §02-compliant.
|
||||
|
||||
**Also fixed (§03C divergence):** the preview debounced _every_ change, so a snippet
|
||||
load / Draft↔Published switch incurred a 300 ms blank instead of the spec's **immediate**
|
||||
render. `LivePreview` now renders immediately on `bufferEpoch`/`editorView` change and
|
||||
debounces only keystroke (`shownText`-only) changes.
|
||||
|
||||
**Deferred at the time to M5/M6 (per §02):** Search, Sort controls + persistence, two
|
||||
distinct empty-state messages, Storage Monitor — all now delivered in M6.
|
||||
|
||||
**Verified:** `typecheck` + `test` (`snippet` duplicate factory,
|
||||
`SnippetStore` rename/comment/duplicate, a `SnippetLibrary` render test guarding the
|
||||
auto-save effect against a render loop) + `eslint` clean + `build` (PWA, 41 precache
|
||||
entries).
|
||||
|
||||
---
|
||||
|
||||
## M5 · Settings + Import/Export ✅ (done)
|
||||
|
||||
**Goal:** preferences and whole-workspace backup/transfer.
|
||||
|
||||
**Core**
|
||||
|
||||
- `settings.ts` — UserSettings shape + defaults + load-with-fallback (§07, §09C);
|
||||
unknown/missing values fall back silently.
|
||||
- `import-normalize.ts` — accept envelope / bare array / single snippet / foreign
|
||||
shapes; field mapping (`content`→spec, `draft`→draftSpec, `createdAt`→created);
|
||||
tag `"imported"`; merge rules (append, id-collision reassign, dataset-name
|
||||
auto-suffix, datasets-before-snippets) (§08).
|
||||
- `export-envelope.ts` — build the `{version, exportedAt, exportedBy, snippets, datasets}` envelope.
|
||||
|
||||
**Infrastructure**
|
||||
|
||||
- `settings-store.ts` (localStorage): **extend** the minimal M1.5 adapter (which
|
||||
already persists `ui.theme`) to the full UserSettings record; `ux-prefs` for sort
|
||||
- panel layout (§09D).
|
||||
|
||||
**App**
|
||||
|
||||
- **Distributed settings** — not a modal (design review, see spec §07 + arch 10). Each
|
||||
cluster is a per-pane disclosure popover that applies **live**: Editor settings in the
|
||||
editor toolbar, render debounce in the preview, date format in the library; theme stays
|
||||
the header toggle. A shared `SettingsPopover` primitive (gear + non-modal popover, APG
|
||||
disclosure) backs all three. Wire render-debounce + editor options + date-format through.
|
||||
- Header **Import**/**Export** (direct file dialog / download, no modal).
|
||||
- Date formatting util (smart/iso/custom) used by the library list + metadata panel.
|
||||
|
||||
**Tests**
|
||||
|
||||
- Import normalization across all accepted shapes; merge/collision/rename logic;
|
||||
quota-overage messaging path. Envelope round-trip (export→import idempotence).
|
||||
- Settings load-with-fallback for partial/unknown records.
|
||||
|
||||
**Manual checks**
|
||||
|
||||
- Change theme/debounce/date-format → takes effect; Cancel reverts; Reset confirms.
|
||||
- Export → reimport into a populated workspace merges without overwrite; renames reported.
|
||||
|
||||
**Verified:** `typecheck` + `test` green — `import-normalize` (accepted shapes + merge/
|
||||
collision/rename), `export-envelope` (round-trip), `settings` (load-with-fallback),
|
||||
`UserSettingsStore`, `ux-prefs`. Distributed per-pane settings shipped as `SettingsPopover`
|
||||
disclosures (editor toolbar, preview, library) applying live; header Import/Export wired
|
||||
through `services/transfer.ts` (→ `normalizeImport` / envelope build), no modal.
|
||||
|
||||
---
|
||||
|
||||
## M6 · Shell polish & non-functional ✅ (done)
|
||||
|
||||
**Goal:** the workspace feels finished and meets §10.
|
||||
|
||||
**Scope note — desktop/tablet, not phone.** Astrolabe is a desktop and (at best)
|
||||
tablet tool; phones are out of scope. So the touch/installable surface we target is
|
||||
**iPad add-to-home-screen**, not iPhone — which is why the one PNG icon is a 180×180
|
||||
`apple-touch-icon` and we don't chase phone-specific viewport/layout work.
|
||||
|
||||
- **Panes:** ~~drag-resize handles with min widths; widths persist~~ ✅ (pulled
|
||||
forward after M2). ~~Per-pane show/hide **toggle strip** + visibility persist +
|
||||
proportional redistribution on hide (§01A, §09D)~~ ✅.
|
||||
- **Routing:** ~~URL hash view-state (`#snippet-<id>`, `#datasets/...`) with Back/Forward;
|
||||
restore on load (§01E)~~ ✅.
|
||||
- **Shortcuts:** ~~Cmd/Ctrl+Shift+N / +K / +S / +, / Esc via a single key router
|
||||
(§01D)~~ ✅.
|
||||
- **Toasts:** ~~success/error/warning/info, stacking, auto-dismiss, reduced-motion;
|
||||
appear/disappear with a brief fade (§01F)~~ ✅ (fade-out two-phase dismiss landed last).
|
||||
- **Library search / sort / empty states** (§02, §09D): ~~live search across
|
||||
name/comment/draft spec; Sort by Modified/Created/Name/Size with a disclosure +
|
||||
flip-on-reselect, persisted to `astrolabe:ux-prefs`; the two distinct empty states~~ ✅
|
||||
(council-guided — see [arch 10](architecture/10-interaction-and-feedback.md)).
|
||||
- **Storage monitor** for the snippet tier ~~(§02)~~ ✅ (`role="meter"` fill bar,
|
||||
escalating ok/warning/critical at 0.8/0.95).
|
||||
- **Live-preview busy indicator** (§04, §10): ~~non-blocking overlay + `aria-busy` for
|
||||
renders past ~1s~~ ✅.
|
||||
- **Import atomicity** (§08): ~~roll back on storage-quota failure so no partial import is
|
||||
committed; actionable error~~ ✅ (effective at the service boundary; true cross-record
|
||||
IDB-transaction atomicity would require exposing a raw transaction from `db.ts` — see
|
||||
[arch 02](architecture/02-persistence.md)).
|
||||
- **A11y:** modal focus trap + return, labelled icon buttons, contrast in both themes (§10) — ✅ in place.
|
||||
- **About** and **Support** modals ~~(§01)~~ ✅. The project solicits nothing for
|
||||
itself — **Support** offers feedback to the author and redirects donations to Ukraine's
|
||||
defense (`savelife.in.ua`, the Come Back Alive foundation), where the author forwards any
|
||||
project donations anyway.
|
||||
- **Offline/installable:** the manifest ships a full SVG icon set (favicon / maskable /
|
||||
monochrome) + a 180×180 `apple-touch-icon.png` (iPad add-to-home-screen) + `theme_color`,
|
||||
and the SW precaches the shell — the app is **installable**. ✅ Manual verification in a
|
||||
running/installed app passed (checklist: [manual-verification.md](manual-verification.md)).
|
||||
- **Council** — ~~seat **web.dev** for the PWA/offline/storage surfaces none of the seated
|
||||
members cover: the service-worker **update-available** prompt (`registerType: 'prompt'`),
|
||||
storage **persistence** (`navigator.storage.persist()`), and the quota **estimate**
|
||||
(`StorageManager.estimate()`)~~ ✅ seated + backfilled (update-prompt toast + `persist()`
|
||||
request; estimate already wired). ~~**⏳ Remaining gap:** manifest ships no icons → not yet
|
||||
installable~~ ✅ SVG icon set added + wired (favicon / maskable / monochrome). See
|
||||
[`/council`](../.claude/skills/council/SKILL.md) and
|
||||
[arch 10](architecture/10-interaction-and-feedback.md).
|
||||
|
||||
**Manual checks:** ✅ keyboard-only run-through; reload restores view from URL;
|
||||
offline reload works; install as standalone; reduced-motion honored — all verified in a
|
||||
running/installed app, plus visual verification of the library/monitor/modals/busy-indicator
|
||||
surfaces.
|
||||
## Live backlog
|
||||
|
||||
The M0–M6 sequence is done; what's left is post-M6 enhancement. The detail — rationale,
|
||||
citations, status logs — lives in the two scope docs below, which are the source of truth.
|
||||
This is the at-a-glance list; keep it in sync with them.
|
||||
|
||||
**Next (flagged for build):**
|
||||
|
||||
- **Multi-view data model** ([`multi-view-data-model-scope.md`](exploration/multi-view-data-model-scope.md)) —
|
||||
durable composition support across the data-facing features. **Complete** (M1–M5): the
|
||||
Vega-Lite-fidelity reference classifier (`core/spec-data`), the view-scoped editor data
|
||||
context, per-view data inspection, view-scoped Extract (inline + self-defined `datasets`),
|
||||
and live/interactive inspection. The durable contract is recorded in `docs/architecture`
|
||||
05 (live inspection) and 07 (reference detection + extraction, §3.1–3.2).
|
||||
- **Chart Builder · 3B starter examples** ([`chart-builder-enhancement-scope.md`](exploration/chart-builder-enhancement-scope.md) §3) —
|
||||
a small set of curated starters, one per covered FT intent. Reshaped by 3C: a
|
||||
builder-openable starter must reference a dataset, so it ships paired sample datasets (or is
|
||||
reframed) — final shape decided at build time. Distinct from the inline-data onboarding
|
||||
gallery (`core/examples.ts` → `Onboarding.tsx`), which is Monaco-only.
|
||||
- **Chart theming · Color-panel swatch reorder** ([`chart-theming-scope.md`](exploration/chart-theming-scope.md) §5) —
|
||||
the last remaining slice-4b control: reorder a materialized scheme's swatches.
|
||||
|
||||
**Deferred / gated (have a home; not committed):**
|
||||
|
||||
- **Chart Builder · Phase 4** (gated until after Phase 3) — `theta`/pie, faceting (small
|
||||
multiples), light styling/scale override panels, builder undo/redo, dataset lookup/join. Each
|
||||
must clear the promotion test: a control enters the builder only when it is **both common and
|
||||
awkward in JSON**.
|
||||
- **Chart Builder** — field-chip drag-and-drop (click/keyboard-first shipped; drag deferred);
|
||||
calculated-field autocomplete popup (Monaco-style completion for expressions).
|
||||
- **Chart theming** — Google Fonts opt-in CDN tier (keyless catalog, opt-in only); theme↔font
|
||||
pairing metadata (a suggestion nicety); built-in expressive preset gallery ("Editorial",
|
||||
"Terminal", "Sketch").
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting, do-as-you-go
|
||||
|
||||
- **Build to the design language:** the foundation lands in M1.5; from M2 on, every
|
||||
new component uses the [Architecture 09](architecture/09-visual-design.md) tokens
|
||||
and conventions — no placeholder styling, no raw hues. Staying on it is the
|
||||
do-as-you-go part.
|
||||
- **Build to the design language:** the foundation landed in M1.5; every new component uses the
|
||||
[Architecture 09](architecture/09-visual-design.md) tokens and conventions — no placeholder
|
||||
styling, no raw hues. Staying on it is the do-as-you-go part.
|
||||
- **i18n** (optional, deferred): if translation is wanted, split a portable i18n
|
||||
registry (no React) from the app-layer bindings, mirroring the `core` ↔ `app`
|
||||
boundary. M1–M6 can ship English-only with date formatting locale-aware (§10).
|
||||
Don't retrofit later if avoidable — keep user-facing strings centralized from M1.
|
||||
boundary. The app ships English-only with date formatting locale-aware (§10).
|
||||
Keep user-facing strings centralized so a later retrofit stays cheap.
|
||||
- **Versioning:** simplified semver `0.x.y`, `package.json` → `__APP_VERSION__`
|
||||
(already wired). **Not yet released publicly** — the working version stays pre-1.0 through
|
||||
M0–M6; the **first public release will be `1.0.0`**, cut on the maintainer's signal (don't
|
||||
auto-bump in the meantime).
|
||||
(already wired). **Not yet released publicly** — the working version stays pre-1.0; the
|
||||
**first public release will be `1.0.0`**, cut on the maintainer's signal (don't auto-bump in
|
||||
the meantime).
|
||||
- **Docs trio:** keep `SOUL.md` / `AGENTS.md` / `CLAUDE.md` current as the app grows.
|
||||
|
||||
---
|
||||
|
||||
## Open items (carried from the exploration memos)
|
||||
|
||||
Deferred features whose reasoning lives in `docs/exploration/`; pulled here so the backlog
|
||||
is in the maintained plan, not the archive.
|
||||
|
||||
**Chart theming** (`exploration/chart-theming-scope.md`):
|
||||
|
||||
- **Google Fonts opt-in CDN tier** — keyless catalog, opt-in only.
|
||||
- **Theme↔font pairing metadata** — a suggestion nicety.
|
||||
- **Built-in expressive theme preset gallery** — e.g. "Editorial", "Terminal", "Sketch".
|
||||
- **Color panel swatch reorder** — the remaining slice-4b control (reorder a materialized
|
||||
scheme's swatches).
|
||||
|
||||
**Chart Builder** (`exploration/chart-builder-enhancement-scope.md`):
|
||||
|
||||
- ~~**Open-in-builder (3C, strict hydration)**~~ ✅ shipped (2026-06-18): an editor-toolbar
|
||||
**Open in builder** action reopens a builder-representable snippet to **edit it in place**
|
||||
(`parseChartSpec` is the strict inverse of `buildChartSpec`, gated by re-assemble +
|
||||
deep-compare). Only dataset-referencing specs hydrate (the builder's data model). See the
|
||||
scope doc status log.
|
||||
- **Builder starter examples (3B)** — a small set of curated starters, one per covered FT
|
||||
intent, **authored in the builder dialect so they reopen via 3C**. Reshaped by 3C's
|
||||
data-model edge: a builder-openable starter must reference a dataset, so 3B ships paired
|
||||
sample datasets (or is reframed) — decide its shape now that the gate is live. Distinct from
|
||||
the shipped onboarding gallery (`core/examples.ts` → `Onboarding.tsx`), which seeds an
|
||||
inline-data snippet straight into the editor (Monaco-only, never the builder).
|
||||
- ~~**Transform-aware data inspector** — show resolved post-transform rows~~ ✅ shipped: an
|
||||
Input | Resolved data inspector below the Live Preview and Chart Builder charts, with a
|
||||
draggable height divider (spec §04; arch 05 → "data inspector rides the boundary").
|
||||
- **Field-chip drag-and-drop** — click/keyboard-first shipped; drag deferred.
|
||||
- **Calculated-field autocomplete popup** — Monaco-style completion for expressions.
|
||||
|
||||
---
|
||||
|
||||
## Architecture reference
|
||||
|
||||
The **how** behind each milestone is documented self-containedly in
|
||||
@@ -551,3 +136,5 @@ The **how** behind each milestone is documented self-containedly in
|
||||
| Unique names + import auto-suffix, snippet↔dataset links, rename propagation | [07 · Naming & Relationships](architecture/07-naming-and-relationships.md) |
|
||||
| Monaco setup, Vega-Lite schema service, editor patterns mined from vega/editor | [08 · Vega Editor Techniques](architecture/08-vega-editor-techniques.md) |
|
||||
| Design language: tokens, type, spacing, color roles, components, themes | [09 · Visual Design Language](architecture/09-visual-design.md) |
|
||||
| Interaction & feedback: toasts, busy states, empty states, council resolutions | [10 · Interaction & Feedback](architecture/10-interaction-and-feedback.md) |
|
||||
| Learning section `/learn/`: markdown lessons, before/after spec progressions | [11 · Learning Section](architecture/11-learning-section.md) |
|
||||
|
||||
@@ -22,7 +22,9 @@ about user-facing widgets. At that overlap, one rule keeps them from drifting:
|
||||
and never contradict it.** A playbook bullet may _name_ the behavior in one clause and cite
|
||||
the spec, then spend its words on the _how_ (the role, the keys, the focus move) and the
|
||||
_why_ (the council/canon citation). When a playbook bullet and the spec disagree, the
|
||||
**spec wins** and the bullet is the bug. Restatement is the leak: two docs describing the
|
||||
**spec wins** and the bullet is the bug — both describe the shipped code, so if the spec
|
||||
section is itself stale, rewrite it to match the app, then cite it (the code leads; the
|
||||
spec records). Restatement is the leak: two docs describing the
|
||||
same behavior in their own words drift into contradiction; one cites the other instead.
|
||||
|
||||
## How to use this playbook
|
||||
@@ -44,7 +46,7 @@ about user-facing widgets. At that overlap, one rule keeps them from drifting:
|
||||
| 05 | [Rendering, Theming & Preview](05-rendering-theming-preview.md) | vega-embed integration (`actions:false`, `view.finalize()`); field-name escaping; theme→config mapping; debounced non-blocking renderer; resilient error display. |
|
||||
| 06 | [Type Inference & Profiling](06-type-inference.md) | Pure, portable column-type inference (number/text/date/boolean) and the dataset profile shape. |
|
||||
| 07 | [Naming & Relationships](07-naming-and-relationships.md) | Unique-name enforcement + import auto-suffix; the bidirectional snippet↔dataset name link; rename propagation into specs. |
|
||||
| 08 | [vega/editor Techniques](08-vega-editor-techniques.md) | Reference brief: borrowable Monaco-schema wiring, vega-embed lifecycle, two-tier validation, and data-flow/debounce techniques distilled from the official Vega-Lite editor — plus where we do better. |
|
||||
| 08 | [vega/editor Techniques](08-vega-editor-techniques.md) | Reference brief: borrowable Monaco-schema wiring, vega-embed lifecycle, two-tier validation, and data-flow/debounce techniques distilled from the official Vega-Lite editor — plus where we do better, and our editor-augmentation layer (structural transforms + data-aware hints). |
|
||||
| 09 | [Visual Design Language](09-visual-design.md) | The _visual_ contract: principles inspired by IBM/Carbon, deliberate divergences (square chrome, free color/theming), the token system (Plex type, 8px spacing, role-based color, motion), component conventions, and where to mine the Carbon/IBM source repos for more. Companion: [`visual-specimen.html`](visual-specimen.html). |
|
||||
| 10 | [Interaction & Feedback](10-interaction-and-feedback.md) | The _interaction_ contract: the feedback-channel decision table, latency/feedback budgets, the non-happy-path triad, the recovery & data-safety contract, the keyboard/focus contract, and the resolved widget patterns (window splitter, toolbar, segmented controls, selectable lists, search, sort, empty states, modals). Cites `spec/` for behavior; owns the _how_. |
|
||||
| 11 | [Learning Section](11-learning-section.md) | The `/learn/` deep-dive: a marketing-surface Vite entry reusing core + the landing chart embed; markdown-authored lessons (`import.meta.glob`) parsed into an ordered block model; the authoring/engine split (pure parser in core; `marked` only in `src/learn`). |
|
||||
|
||||
@@ -236,6 +236,13 @@ feature owns — which modal is open, the runtime theme, transient render flags.
|
||||
> that's the signal to extract a feature store. A bloated central store is the
|
||||
> thing this split exists to prevent.
|
||||
|
||||
> Rule: a store earns its place by **decoupling** producers from consumers — a fact
|
||||
> belongs in one when more than one component reads it, or when many sites produce it for
|
||||
> one surface to consume (the imperative `notify()` / `confirm()` overlay stores). When a
|
||||
> single component is both the only producer and the only consumer, the fact is that
|
||||
> component's **local `useState`**, not a store — a store there decouples nothing, and is
|
||||
> the shape to fold back.
|
||||
|
||||
---
|
||||
|
||||
## 4. Actions: Mutations Live in the Store, Not Components
|
||||
|
||||
@@ -284,8 +284,8 @@ export interface UserSettings {
|
||||
formatting: { dateFormat: 'smart' | 'iso' | 'custom'; customDateFormat: string };
|
||||
}
|
||||
|
||||
// Defaults must match the authoritative spec §07 table exactly — that is the
|
||||
// contract; this is just where it's encoded.
|
||||
// The spec §07 table records these defaults — keep the two matching; this is
|
||||
// just where they're encoded.
|
||||
const DEFAULTS: UserSettings = {
|
||||
version: CURRENT_SETTINGS_VERSION,
|
||||
editor: {
|
||||
|
||||
@@ -75,7 +75,10 @@ export interface ModalConfig {
|
||||
component: ComponentType<any>; // the body rendered inside the shell
|
||||
|
||||
/** Initialize transient modal state when it opens. `arg` carries an
|
||||
* optional sub-target (e.g. a dataset id for chartBuilder/extract). */
|
||||
* optional sub-target (e.g. a dataset id for chartBuilder/datasets). OMIT
|
||||
* when a service seeds the store *before* `openModal` — Extract is seeded by
|
||||
* `services/extract-action` from the editor cursor, and an `init` here would
|
||||
* re-read and clobber that view-scoped capture. */
|
||||
init?: (arg?: string) => void;
|
||||
|
||||
/** Serializable snapshot of in-progress edits, used to detect unsaved
|
||||
|
||||
@@ -42,26 +42,31 @@ above it is data; everything below it is a Vega `View` we own and must tear down
|
||||
|
||||
### The data inspector rides the boundary too
|
||||
|
||||
The data inspector (the Live Preview and Chart Builder panel showing the chart's input
|
||||
vs. resolved rows — spec §04) reads runtime rows through the handle, never the raw view:
|
||||
`RenderHandle.inspectData()` returns the input + resolved tables (`{ input, resolved }`,
|
||||
or `null` when no chart is up), wrapping the view exactly like `toImageURL`. It works in
|
||||
two layers:
|
||||
The data inspector (the Live Preview and Chart Builder panel showing each drawn table's
|
||||
input vs. resolved rows — spec §04) reads runtime rows through the handle, never the raw
|
||||
view: `RenderHandle.inspectData()` returns the inspectable tables (`{ tables }`, or `null`
|
||||
when no chart is up), wrapping the view exactly like `toImageURL`. It works in two layers:
|
||||
|
||||
- **Enumerate + pick (`view.getState` + `core/result-data`).** A compiled Vega dataflow
|
||||
holds many named datasets; Vega-Lite names them by convention — `source_<n>` per parsed
|
||||
source, `data_<n>` per transform stage. The pure `pickSourceDataset` / `pickResultDataset`
|
||||
choose the **most-upstream source** (the input) and **most-downstream output** (what the
|
||||
marks draw), skipping dataflow internals (`marks`, `root`, layout, selection `*_store`s).
|
||||
A spec with no transforms resolves both to the same table. The picking is pure (in `core`,
|
||||
unit-tested); only the enumeration touches the view.
|
||||
- **Read lazily.** `getState` serializes the datasets it lists, so it is **only called while
|
||||
the panel is open** — a collapsed inspector costs nothing, which is why the panel reads on
|
||||
demand rather than on every render.
|
||||
|
||||
Limitation: one name per direction can't represent a multi-view spec (layer/concat/facet
|
||||
produce several `data_<n>`); the most-downstream/upstream ones are returned, and a full
|
||||
dataset selector is left as a future option.
|
||||
- **Enumerate from the compiled spec (`core/inspect-views`).** A composed spec draws
|
||||
several tables; `inspectableViews` walks the compiled Vega spec — the marks tree's
|
||||
`from.data` (what each mark draws) and `data[].source` (the lineage, the documented Vega
|
||||
format) — to list, in document order, one entry per **distinct drawn table** with its
|
||||
`resolved` (post-transform, what the marks draw) and `input` (most-upstream source) ends.
|
||||
Enumerating by drawn table, not by authored view, is forced by Vega-Lite desugaring (a
|
||||
`point: true` line compiles to two layers — a compiled table can't be traced back to one
|
||||
authored view). Selection `*_store`s and `facet_domain*` layout tables aren't drawn, so
|
||||
they fall out for free. The walk is pure (in `core`, unit-tested); the boundary reads each
|
||||
table's rows via `view.data(name)`.
|
||||
- **Read lazily.** Reading serializes rows, so it happens **only while the panel is open** —
|
||||
a collapsed inspector costs nothing, which is why the panel reads on demand rather than on
|
||||
every render. (A multi-view spec yields several tables; the panel's `SelectControl` picker
|
||||
chooses which to show — labels never expose Vega's compiler names, see arch 10.)
|
||||
- **Stay live under interaction.** A selection that _filters_ a downstream view recomputes
|
||||
that view's compiled table in place (no re-embed), so the open panel re-reads to track it —
|
||||
"what am I visualizing now". `RenderHandle.onDataChange` attaches a debounced
|
||||
`view.addDataListener` to each drawn table; a highlight selection (a `condition` encoding)
|
||||
changes no data, so it never fires. Always live, no toggle — gated on the panel being open
|
||||
like the read itself, and re-subscribed per settled render so it tracks the current handle.
|
||||
|
||||
---
|
||||
|
||||
@@ -533,11 +538,12 @@ blank mid-edit.
|
||||
### A second preview surface: the Chart Builder
|
||||
|
||||
The editor's `LivePreview` is **bound to the snippet editor** — it reads `SnippetStore`
|
||||
(shown spec), `AppStore` (fit mode/theme), and `PreviewStore` (shared error). The
|
||||
**Chart Builder modal** needs a preview of a _different_ spec source (its config), so it
|
||||
does **not** reuse `LivePreview`; it runs its own small debounced render over the same
|
||||
`chart-renderer.renderSpec` + `prepareSpecForRender`, with **local** error state (never the
|
||||
shared `PreviewStore`, which would cross-talk with the editor). Two preview surfaces, one
|
||||
(shown spec) and `AppStore` (fit mode/theme), and keeps its render status (`error`/`busy`) in
|
||||
its own local state. The **Chart Builder modal** needs a preview of a _different_ spec source
|
||||
(its config), so it does **not** reuse `LivePreview`; it runs its own small debounced render
|
||||
over the same `chart-renderer.renderSpec` + `prepareSpecForRender`, with its own **local**
|
||||
error state. Each preview surface owns its render status locally — no shared store to
|
||||
cross-talk. Two preview surfaces, one
|
||||
renderer service. Builder flow: `chart-builder.ts` (pure spec assembler) → `ChartBuilderStore`
|
||||
(config + create) → `ChartBuilderModal`'s `BuilderPreview`. Reach for a reusable preview
|
||||
component only if a _third_ surface appears.
|
||||
@@ -598,13 +604,13 @@ the existing view is re-measured via a `ResizeObserver`-driven event — see §8
|
||||
|
||||
A spec that cannot be rendered must produce a **readable** message in the preview
|
||||
area and recover on its own once the spec is valid again. Errors arise at three
|
||||
stages, all funneled to one error field the preview reads:
|
||||
stages, all funneled to the one error state the preview owns:
|
||||
|
||||
| Stage | Failure | Surfaced as |
|
||||
| -------------------------------- | -------------------------------------- | ---------------------- |
|
||||
| Parse | Invalid JSON | "Invalid JSON: …" |
|
||||
| Prepare (`prepareSpecForRender`) | Referenced dataset missing/unfetchable | "Dataset not found: …" |
|
||||
| Embed (`vega-embed`) | Vega-Lite compile / data error | "Rendering error: …" |
|
||||
| Stage | Failure | Surfaced as |
|
||||
| -------------------------------- | -------------------------------------- | --------------------------------- |
|
||||
| Parse | Invalid JSON | `Invalid JSON · …` |
|
||||
| Prepare (`prepareSpecForRender`) | Referenced dataset missing/unfetchable | `Dataset "x" not found · …` |
|
||||
| Embed (`vega-embed`) | Vega-Lite compile, or a bad expression | `Line N · …` / `Render error · …` |
|
||||
|
||||
```ts
|
||||
// inside render(), driven by the debounced renderer
|
||||
@@ -615,7 +621,7 @@ async function render(): Promise<void> {
|
||||
if (!text) {
|
||||
current?.destroy();
|
||||
current = null;
|
||||
usePreviewStore.getState().setError(null);
|
||||
setError(null); // `error`/`busy` are the pane's local state, not a store
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -623,7 +629,7 @@ async function render(): Promise<void> {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (e) {
|
||||
usePreviewStore.getState().setError(`Invalid JSON: ${(e as Error).message}`);
|
||||
setError(`Invalid JSON · ${(e as Error).message}`);
|
||||
return; // keep the last good chart underneath the error, or show the message
|
||||
}
|
||||
|
||||
@@ -633,14 +639,9 @@ async function render(): Promise<void> {
|
||||
const config = chartConfigFor(uiTheme);
|
||||
current?.destroy();
|
||||
current = await renderSpec(node, prepared, config);
|
||||
usePreviewStore.getState().setError(null); // success clears any prior error
|
||||
setError(null); // success clears any prior error
|
||||
} catch (e) {
|
||||
usePreviewStore
|
||||
.getState()
|
||||
.setError(
|
||||
`Rendering error: ${(e as Error).message}. ` +
|
||||
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
|
||||
);
|
||||
setError(`Render error · ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -655,10 +656,10 @@ manual retry, no reload.
|
||||
- **Do** treat empty/blank spec text as "render nothing" — finalize the current
|
||||
view, clear the error, show a clean empty pane.
|
||||
- **Do** clear the error state on every successful render.
|
||||
- **Do** make messages legible and actionable (the underlying reason plus a hint
|
||||
to check JSON/Vega-Lite validity), never a raw stack trace dump.
|
||||
- **Do** distinguish the failing stage in the message (Invalid JSON vs Dataset
|
||||
not found vs Rendering error).
|
||||
- **Do** make messages legible and actionable — lead with the location or the failing
|
||||
stage, then the underlying reason — never a raw stack trace dump.
|
||||
- **Do** distinguish the failing stage in the message (invalid JSON vs missing dataset
|
||||
vs a bad expression or render error).
|
||||
- **Don't** show a broken/partial chart — replace the chart area with the
|
||||
message.
|
||||
- **Don't** require a manual "retry"; validity restores the chart on its own.
|
||||
@@ -724,5 +725,5 @@ bookkeeping. Gate the observer to responsive modes (Original needs no re-fit).
|
||||
| Field names | `escapeVegaField` on every data-derived `field:` | `src/core/rendering.ts` |
|
||||
| Debounce | Inline timer; `0` on buffer-load/view-switch, `renderDebounce` on keystroke (§5) | `LivePreview.tsx` (service not yet extracted) |
|
||||
| Spec prep | `prepareSpecForRender` (pure, on a copy) | `src/core/rendering.ts` (see _Live Preview_) |
|
||||
| Errors | One error field, cleared on success, empty = nothing | `PreviewStore.error` |
|
||||
| Errors | One error field, cleared on success, empty = nothing | `LivePreview` local state (`error`/`busy`) |
|
||||
| Container fit | Inner host + frame (out-specify `.vega-embed`); resize via synthetic `window:resize` | §8 (`LivePreview` + `chart-renderer`) |
|
||||
|
||||
@@ -135,70 +135,50 @@ without waiting for a publish. Recomputation runs only on a _valid_ spec —
|
||||
auto-save is debounced and parse-gated (spec §03B) — so a transiently-invalid
|
||||
draft never disturbs the links.
|
||||
|
||||
### 3.1 Extracting referenced names from a spec (pure — `src/core/spec-refs.ts`)
|
||||
### 3.1 What counts as a reference, and extracting them (pure — `src/core/spec-data.ts`, `spec-refs.ts`)
|
||||
|
||||
A Vega-Lite spec can reference named data in several places: the top-level
|
||||
`data`, per-layer `data`, `data` inside `spec`/`facet`/`hconcat`/`vconcat`, and a
|
||||
lookup transform's `from.data`. A spec may also define its OWN inline datasets via
|
||||
a top-level `datasets` map — those are self-defined, not library references.
|
||||
Rather than enumerate Vega-Lite's grammar, we walk the spec recursively and
|
||||
collect every `{ data: { name } }` — but **prune two keys**: never recurse into a
|
||||
`data` object's payload (its `values`/rows) or the top-level `datasets` map,
|
||||
because those hold user data, not nested specs. Without the prune, a data _row_
|
||||
carrying a field literally named `data: { name: "x" }` is misread as a reference.
|
||||
This is pure, deterministic, and the most heavily unit-tested function here.
|
||||
A library reference is exactly Vega-Lite **named data**: a `data` block with a
|
||||
string `name` and no `values`, `url`, or generator key (`sequence`/`sphere`/
|
||||
`graticule`), whose name the spec does not define for itself via a top-level
|
||||
`datasets` map. A `name` riding on inline `values` or a `url` is Vega-Lite's
|
||||
runtime-rebind label — not a dependency — and self-defined `datasets` names
|
||||
resolve natively; both are left untouched. This mirrors Vega-Lite's own
|
||||
`isNamedData`, so Astrolabe **extends** Vega-Lite rather than diverging: every
|
||||
native data form keeps working, and only true references are tracked and resolved.
|
||||
|
||||
That classification lives in **`core/spec-data`** (`classifyData`,
|
||||
`libraryRefName`) — the single predicate that reference extraction, rename
|
||||
(`spec-refs`), and render-time resolution (`rendering`) all route through, so they
|
||||
cannot disagree on what is a dependency.
|
||||
|
||||
```ts
|
||||
// src/core/spec-refs.ts
|
||||
// src/core/spec-data.ts — the shared classifier (mirrors vega-lite/src/data.ts)
|
||||
|
||||
type Json = unknown;
|
||||
|
||||
/** Collects every library dataset name referenced by `{ data: { name } }`, excluding self-defined ones. */
|
||||
export function extractDatasetRefs(spec: Json): string[] {
|
||||
const root = typeof spec === 'string' ? safeParse(spec) : spec;
|
||||
const selfDefined = selfDefinedNames(root); // names from the spec's own top-level `datasets`
|
||||
const names = new Set<string>();
|
||||
|
||||
const walk = (node: Json): void => {
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) walk(item);
|
||||
return;
|
||||
}
|
||||
if (node && typeof node === 'object') {
|
||||
const obj = node as Record<string, Json>;
|
||||
const data = obj.data as Record<string, Json> | undefined;
|
||||
if (data && typeof data === 'object' && typeof data.name === 'string') {
|
||||
if (!selfDefined.has(data.name)) names.add(data.name);
|
||||
}
|
||||
// Prune: a `data` payload and the `datasets` map hold user data, not refs.
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (key === 'data' || key === 'datasets') continue;
|
||||
walk(obj[key]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
return [...names];
|
||||
}
|
||||
|
||||
function safeParse(s: string): Json {
|
||||
try {
|
||||
return JSON.parse(s);
|
||||
} catch {
|
||||
return null; // an unparseable draft simply has no resolvable refs
|
||||
}
|
||||
/** The library name a `data` block references, or null for native VL data / self-defined names. */
|
||||
export function libraryRefName(data: unknown, selfDefined: ReadonlySet<string>): string | null {
|
||||
if (classifyData(data) !== 'named') return null; // url | values | generator → not a reference
|
||||
const { name } = data as { name: string };
|
||||
return selfDefined.has(name) ? null : name;
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// src/core/spec-refs.ts — thin wrapper, run on every draft change and on publish
|
||||
References appear in several places — top-level `data`, per-layer `data`, `data`
|
||||
inside `spec`/`facet`/`hconcat`/`vconcat`, and a lookup transform's `from.data`.
|
||||
Rather than enumerate the grammar, the walk recurses the spec but **prunes two
|
||||
keys**: a `data` object's payload (its `values`/rows) and the top-level `datasets`
|
||||
map hold user data, not nested specs. Without the prune, a data _row_ carrying a
|
||||
field literally named `data: { name: "x" }` is misread as a reference.
|
||||
|
||||
/** The list stored on snippet.datasetRefs. Sorted + de-duped for stable diffs. */
|
||||
export function recomputeDatasetRefs(spec: Json): string[] {
|
||||
return extractDatasetRefs(spec).sort();
|
||||
}
|
||||
```
|
||||
That walk is **one shared pair** in `core/spec-data`, not re-implemented per pass:
|
||||
`forEachDataBinding(spec, visit)` (read-only, `visit` returns `true` to stop early)
|
||||
and `mapDataBindings(spec, mapData)` (returns a copy). Both compute the spec's
|
||||
self-defined names once and hand them to the callback, so a caller writes only its
|
||||
own rule — _what is a reference_ (`libraryRefName`) or _what to rewrite_ — never the
|
||||
walk. `extractDatasetRefs` collects through `forEachDataBinding`; `renameDatasetInSpec`
|
||||
and `promoteSelfDefinedDataset` (§3.2) rewrite through `mapDataBindings`.
|
||||
|
||||
`recomputeDatasetRefs` is the thin sorted/de-duped wrapper stored on
|
||||
`snippet.datasetRefs`, run on every draft change and on publish.
|
||||
|
||||
> A `spec` may be an object or a string (see the Data Model). Normalize once,
|
||||
> at the boundary, so the recursive walk never has to care.
|
||||
@@ -207,27 +187,57 @@ export function recomputeDatasetRefs(spec: Json): string[] {
|
||||
|
||||
- Treat `extractDatasetRefs` as the single source of truth for "what does this
|
||||
spec reference". The reverse-lookup and rename paths both depend on it
|
||||
agreeing with what the renderer actually resolves.
|
||||
agreeing with what the renderer actually resolves — which holds because all of
|
||||
them classify through the same `core/spec-data` predicate.
|
||||
- Recompute and store `datasetRefs` on **every draft change and on publish** —
|
||||
but only through the parse-gated, debounced auto-save (`commitDraft`) and the
|
||||
programmatic extract/revert rewrites, never on raw keystrokes. That keeps the
|
||||
links in step with the edited draft while never recomputing from a
|
||||
transiently-invalid spec.
|
||||
- Prune the **same two keys** (`data`, `datasets`) in all three ref walks —
|
||||
extraction here, `renameDatasetInSpec`, and the renderer's `resolveDatasetRefs`
|
||||
(`src/core/rendering.ts`). They must agree on what counts as a reference; if one
|
||||
descends into data payloads and another doesn't, extraction and rendering
|
||||
disagree and a row field named `data` either gets counted, rewritten, or throws
|
||||
`DatasetNotFoundError`.
|
||||
- Route every binding pass through the shared `forEachDataBinding` /
|
||||
`mapDataBindings`. They share both halves that must agree — the `libraryRefName`
|
||||
classifier (what is a reference) and the prune of the **same two keys** (`data`,
|
||||
`datasets`) — so extraction, rename, and the reverse extraction cannot drift. If
|
||||
one classified differently, or descended into data payloads while another didn't,
|
||||
a row field named `data` would get counted, rewritten, or throw
|
||||
`DatasetNotFoundError`. (The renderer's `resolveDatasetRefs` mutates in place and
|
||||
can throw mid-walk, so it keeps its own copy of the walk — the one exception, held
|
||||
in step by the same prune-two-keys rule.)
|
||||
|
||||
**Don't**
|
||||
|
||||
- Don't let two code paths each have their own idea of "referenced names".
|
||||
Renamer, ref-recomputer, and renderer must use the same walk shape.
|
||||
- Don't add a fresh prune-walk for a new binding pass — reuse the shared pair. A
|
||||
hand-written copy is one classifier tweak away from disagreeing with the others.
|
||||
- Don't "enumerate the grammar" (scope the walk to a fixed list of container
|
||||
keys) to fix the payload-descent problem — pruning the two data-bearing keys
|
||||
stays correct as Vega-Lite's composition grammar grows; an allow-list rots.
|
||||
|
||||
### 3.2 Extracting embedded data into a dataset — the reverse (`spec-inline-data.ts`, `spec-refs.ts`)
|
||||
|
||||
Extract-to-Dataset is the inverse of a reference: it lifts a view's **embedded**
|
||||
data into a stored dataset and rewrites the spec to reference it by name. It is a
|
||||
cursor-scoped editor action — `services/extract-action` resolves the focused view's
|
||||
binding (`dataBindingAtPath`), seeds the modal, then opens it; the gate
|
||||
`specHasExtractableData` hides the toolbar action when no view carries liftable
|
||||
data. Two embedded shapes lift, both routing through the same `spec-data` classifier
|
||||
so they never disagree with reference detection:
|
||||
|
||||
- **Inline `values`** — `inlineValuesOf` captures the payload verbatim (a CSV/TSV
|
||||
string is kept as-is); confirm rewrites that view's `data` block, at its anchor
|
||||
path, to `{ name }`.
|
||||
- **A self-defined `datasets` entry** the view references — `selfDefinedPayloadOf`
|
||||
reads the named rows and `promoteSelfDefinedDataset` drops the `datasets` entry
|
||||
(and the map when it empties). Keeping the name needs no reference rewrite — it
|
||||
un-shadows onto the new library dataset; renaming rewrites every matching
|
||||
reference. This is the reverse direction of the self-defined-vs-library
|
||||
precedence in §3.1.
|
||||
|
||||
A `lookup` transform's inline `from.data` lifts like any view binding —
|
||||
`dataBindingAtPath` finds it. A `url`, a generator, or an existing library
|
||||
reference carries nothing to lift.
|
||||
|
||||
---
|
||||
|
||||
## 4. Reverse lookup: who uses this dataset?
|
||||
@@ -341,7 +351,7 @@ detection + normalization + the dedupe/rename/id-reassign helpers; `core/export-
|
||||
|
||||
- browser IO (`infrastructure/file-transfer.ts`). The pure helpers are unit-tested
|
||||
hardest; `transfer.ts` only orchestrates (read stores → call core → commit →
|
||||
notify). The behavioral contract is spec §08.
|
||||
notify). The behavior is recorded in spec §08.
|
||||
|
||||
Three rules a future change must keep:
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
> 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 contract is still [`docs/spec/`](../spec/);
|
||||
> 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."
|
||||
@@ -275,6 +275,206 @@ 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 |
|
||||
|
||||
@@ -328,21 +328,22 @@ Carbon and drawn `fill: currentColor`.
|
||||
|
||||
**Core set** — recurring, cross-surface:
|
||||
|
||||
| Meaning | Carbon glyph | Form | Surfaces |
|
||||
| -------------------- | --------------- | ------------------------ | -------------------------------------------------------------------------------------------- |
|
||||
| Close / dismiss | `Close` (✕) | icon-only ⭐ | `ModalShell`, `Toaster` |
|
||||
| Theme → dark | `Asleep` (moon) | icon-only ⭐ | `ThemeToggle` (shown when light) |
|
||||
| Theme → light | `Light` (sun) | icon-only ⭐ | `ThemeToggle` (shown when dark) |
|
||||
| References a dataset | `DataTable` | icon + text | Library row marker, Linked-datasets list, header **Datasets**, editor **Extract**⁴ |
|
||||
| Add / create-new | `Add` | icon + text → icon-only⁴ | Library "Create New Snippet" (collapses to "+" when the pane is narrow), Datasets "New …" |
|
||||
| Delete | `TrashCan` | icon-only ⭐ (danger) | Library row delete¹ — text "Delete" in the panel² |
|
||||
| Import workspace | `Upload` | icon + text | Header **Import** (a file is brought into the app) |
|
||||
| Export workspace | `Download` | icon + text | Header **Export** (the workspace is written out) |
|
||||
| About / information | `Information` | icon + text | Header **About** |
|
||||
| Revert draft | `Reset` | icon + text → icon-only⁴ | Editor toolbar **Revert** (restore last published) |
|
||||
| Live search | `Search` | icon-in-field⁵ | Library search box (leading magnifier; the input's `aria-label`/placeholder names the field) |
|
||||
| Settings (gear) | `Settings` | icon-only ⭐ | Per-pane settings disclosures (Editor, Preview, Library dates) |
|
||||
| Unpublished draft | (CSS dot) | status-glyph | Library row (paired with a hidden label) |
|
||||
| Meaning | Carbon glyph | Form | Surfaces |
|
||||
| --------------------- | --------------- | ------------------------ | -------------------------------------------------------------------------------------------- |
|
||||
| Close / dismiss | `Close` (✕) | icon-only ⭐ | `ModalShell`, `Toaster` |
|
||||
| Theme → dark | `Asleep` (moon) | icon-only ⭐ | `ThemeToggle` (shown when light) |
|
||||
| Theme → light | `Light` (sun) | icon-only ⭐ | `ThemeToggle` (shown when dark) |
|
||||
| References a dataset | `DataTable` | icon + text | Library row marker, Linked-datasets list, header **Datasets**, editor **Extract**⁴ |
|
||||
| Add / create-new | `Add` | icon + text → icon-only⁴ | Library "Create New Snippet" (collapses to "+" when the pane is narrow), Datasets "New …" |
|
||||
| Delete | `TrashCan` | icon-only ⭐ (danger) | Library row delete¹ — text "Delete" in the panel² |
|
||||
| Import workspace | `Upload` | icon + text | Header **Import** (a file is brought into the app) |
|
||||
| Export workspace | `Download` | icon + text | Header **Export** (the workspace is written out) |
|
||||
| About / information | `Information` | icon + text | Header **About** |
|
||||
| Revert draft | `Reset` | icon + text → icon-only⁴ | Editor toolbar **Revert** (restore last published) |
|
||||
| Live search | `Search` | icon-in-field⁵ | Library search box (leading magnifier; the input's `aria-label`/placeholder names the field) |
|
||||
| Settings (gear) | `Settings` | icon-only ⭐ | Per-pane settings disclosures (Editor, Preview, Library dates) |
|
||||
| Unpublished draft | (CSS dot) | status-glyph | Library row (paired with a hidden label) |
|
||||
| Composition structure | (custom frame) | icon-only ⭐ | Preview toolbar — composition-wireframe disclosure (a frame holding nested view blocks) |
|
||||
|
||||
**Pane-toggle set** — the one **custom** sub-family (not single Carbon glyphs): a
|
||||
panel frame with one of three regions filled, where the filled bar's _position_
|
||||
@@ -370,6 +371,16 @@ notifications/validation as they arrive):
|
||||
| Success | `CheckmarkFilled` | `--support-success` | `Toaster` (success) |
|
||||
| Info | `InformationFilled` | `--support-info` | `Toaster` (info) |
|
||||
|
||||
**Mark set** — a custom `mark-*` sub-family, one simplified glyph per Vega-Lite mark
|
||||
(bar, line, area, point, arc, rect, tick, rule, text, plus a `mark-generic` fallback),
|
||||
drawn on the same 32-grid (stroked where a line reads truer than a fill). It labels the
|
||||
leaves of the composition wireframe so views read apart at a glance; mark synonyms
|
||||
(circle/square → point, trail → line, image → rect) collapse onto it via `markIconName`
|
||||
(`mark-icon.ts`), and an unknown or absent mark falls to `mark-generic`. Decorative there
|
||||
(the box's `aria-label` names the view), so these are not in the ⭐ icon-only set. The sibling
|
||||
`layers` glyph (two offset planes) badges a `layer` node in the same wireframe — a row of marks
|
||||
in one frame — as a single shared space rather than a concat's box-per-view.
|
||||
|
||||
**Scoped set** — single-surface, glyph **reserved** in the ledger but **not yet in
|
||||
the `Icon` registry**:
|
||||
|
||||
@@ -379,7 +390,8 @@ the `Icon` registry**:
|
||||
|
||||
⭐ = **icon-only set** (the glyph alone names the control, via `aria-label`): the
|
||||
**universal** glyphs `close` + `theme`; the conventional disclosure/affordance
|
||||
glyphs `settings` (gear) and the **pane-toggle** trio (position is the meaning);
|
||||
glyphs `settings` (gear) and `structure` (the composition-wireframe frame) and the
|
||||
**pane-toggle** trio (position is the meaning);
|
||||
and **delete** as a deliberate destructive-row exception — a dense, repeated list
|
||||
action where a label would cost more than it gives. `search` is _not_ ⭐: its
|
||||
magnifier is a decorative lead-in to a labelled input (footnote ⁵), not a control
|
||||
|
||||
@@ -34,12 +34,12 @@ Astrolabe has four distinct ways to tell the user something. They are **not**
|
||||
interchangeable; picking the wrong one is the most common interaction bug. Choose by the
|
||||
nature of the message, not by convenience.
|
||||
|
||||
| Channel | Use when | Blocks? | Dismissal | Implemented by |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| **Confirm dialog** | A **destructive or irreversible** action needs explicit consent (delete, revert, reset) | Yes — modal | User must choose; Escape/Cancel = no; backdrop click does **not** dismiss | `ConfirmStore` + `ConfirmDialog` |
|
||||
| **Toast** | A **non-blocking outcome** happened the user should know about (save failed, published, imported) | No | Auto for success/info; persists for error/warning; always a close button | `NotificationStore` + `Toaster` |
|
||||
| **Inline error** | A problem is **tied to a specific surface** and recovers in place (invalid spec → editor + preview) | No | Clears automatically when the cause is fixed | `PreviewStore`, surfaced in `SpecEditor` + `LivePreview` |
|
||||
| **Status indicator** | **Passive, ambient** state worth glancing at (draft vs. published, storage usage) | No | N/A — it just reflects state | library draft dot; storage monitor (later) |
|
||||
| Channel | Use when | Blocks? | Dismissal | Implemented by |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------- | ------------------------------------------------------------------------- | ----------------------------------------------------- |
|
||||
| **Confirm dialog** | A **destructive or irreversible** action needs explicit consent (delete, revert, reset) | Yes — modal | User must choose; Escape/Cancel = no; backdrop click does **not** dismiss | `ConfirmStore` + `ConfirmDialog` |
|
||||
| **Toast** | A **non-blocking outcome** happened the user should know about (save failed, published, imported) | No | Auto for success/info; persists for error/warning; always a close button | `NotificationStore` + `Toaster` |
|
||||
| **Inline error** | A problem is **tied to a specific surface** and recovers in place (invalid spec → preview, cause squiggled in the editor) | No | Clears automatically when the cause is fixed | `LivePreview` local state; `vega-expr` editor markers |
|
||||
| **Status indicator** | **Passive, ambient** state worth glancing at (draft vs. published, storage usage) | No | N/A — it just reflects state | library draft dot; storage monitor (later) |
|
||||
|
||||
**Rules.**
|
||||
|
||||
@@ -80,9 +80,12 @@ nature of the message, not by convenience.
|
||||
the other publishes silently (the exact inconsistency this rule prevents). The shortcut is
|
||||
owned globally by the EventRouter (arch 04), so the helper is the only place the outcome is
|
||||
confirmed.
|
||||
- **The same failure can light up two channels.** An unrenderable spec shows the _same_
|
||||
message inline in both the editor (§03E) and the preview (§04) — one producer
|
||||
(`PreviewStore`), two subscribers. That's intentional, not duplication.
|
||||
- **A render failure has one message home: the preview.** An unrenderable spec shows its
|
||||
message in the preview pane (§04), where the chart would be — error _xor_ chart, since the
|
||||
preview's `error` state is non-null only when the render failed (success/empty clear it; export
|
||||
failures report through the export UI, never here). The editor marks the offending spot with
|
||||
an inline squiggle (§03E) rather than repeating the text. Render status is `LivePreview`'s own
|
||||
local state (`error`/`busy`) — one producer, one surface, so it needs no store.
|
||||
|
||||
## 2. Latency & feedback budgets
|
||||
|
||||
@@ -221,6 +224,49 @@ below) like `PaneSplitHandle`, with the gesture in `useRowResizeDrag` (the row t
|
||||
_(Consulted via `/council` → WAI-ARIA APG `windowsplitter`. This bullet is the contract;
|
||||
cite it, not the APG file.)_
|
||||
|
||||
**Resolved — composition structure wireframe.** The preview toolbar's structure disclosure (a
|
||||
schematic of the spec's multi-view composition — `CompositionWireframe`, arch 08) is a
|
||||
**WAI-ARIA APG `tree`** inside a disclosure popover (`usePopover`): bare nested boxes are
|
||||
`tree` → `treeitem` → `group`, single-select via `aria-selected`, **one tab stop with a roving
|
||||
tabindex**, arrow keys in **logical (document) order** — Up/Down between nodes, Left → parent,
|
||||
Right → first child, Home/End, Enter/Space activate — not spatial, since a mixed horizontal/
|
||||
vertical layout makes spatial arrows ambiguous. Each leaf carries a glyph of its mark type (the
|
||||
`mark-*` icon sub-family, arch 09 §5) so views read apart at a glance. A `layer` — one plotting
|
||||
space with several marks stacked — renders as **one frame** holding its child marks as a row of
|
||||
glyphs, badged as layered (the `layers` glyph), rather than the box-per-view of a concat; each
|
||||
mark stays an individual `treeitem` so selection and z-order reorder still work. Selecting a box reveals +
|
||||
selects that view's source range in the editor (`AppStore.requestRevealView`) but **does not steal
|
||||
focus**, so the wireframe stays the active surface while the editor scrolls to follow; the editor
|
||||
selection is the single source of truth. The toolbar glyph appears **only for a composed spec** —
|
||||
a single-view spec hides the affordance rather than disclosing an empty tree. _(Council: APG
|
||||
treeview; the cursor-scoping reachability rationale is in [arch 08](08-vega-editor-techniques.md).)_
|
||||
|
||||
On the **editable draft** the tree restructures the composition. Every restructure is **applied by
|
||||
the editor** (which owns the one-⌘Z edit) via `AppStore.requestComposeMove`/`requestComposeWrap`,
|
||||
never by writing the draft text directly — so the wireframe and editor share one undo history.
|
||||
|
||||
- **Reorder within a container — APG rearrangeable-listbox.** `Alt+↑`/`Alt+↓` moves the focused
|
||||
view among its siblings: a direct modifier+arrow move, **not** a grab/drop mode. Focus follows
|
||||
the moved box for consecutive moves (so a screen reader re-announces its new position), a
|
||||
**polite** live region states the result, and `aria-keyshortcuts` advertises the keys. _(Council:
|
||||
APG listbox-rearrangeable.)_
|
||||
- **Restructure by drag — zone model against the children's box.** Intent is read from where the
|
||||
pointer falls relative to a row/column's children, not one nearest edge, so each gesture owns a
|
||||
generous target: the **interior central band reorders** (an insertion slot by main-axis position —
|
||||
a drag _along_ the block rearranges it anywhere, not only on a sibling's edge); the **cross-axis
|
||||
frame margin** (a row's top/bottom, a column's left/right — the gutter between frame and children,
|
||||
or past the block) **pulls the source out** into a new full-span row/column wrapping the whole
|
||||
container, the root included; a drop onto **a view's far cross edge** pairs the two in a
|
||||
perpendicular split (`Shift` forces a pair from the centre). The source is the **innermost** view
|
||||
under the pointer — `beginDrag` stops propagation so a nested ancestor frame, itself draggable,
|
||||
can't claim the drag (un-stopped, its handler runs last on bubble and wins). The hit-test descends
|
||||
only through `hconcat`/`vconcat` and treats `layer`/`facet`/`repeat`/grid as **opaque**. Feedback:
|
||||
a cursor **chip** names the pending action, the target previews it (reorder line, pair half-split,
|
||||
pull-out band), and every frame's pull-out margins glow faintly while dragging. The drag is a
|
||||
pointer accelerator over keyboard-reachable capabilities (in-container reorder via `Alt+↑/↓`;
|
||||
cross-container restructure via the editor's wrap actions), so it adds **no keyboard-only gap**.
|
||||
Transform invariants in [arch 08](08-vega-editor-techniques.md).
|
||||
|
||||
**Resolved — pane toggle strip.** The persistent show/hide strip (spec §01A) is a **WAI-ARIA
|
||||
APG `toolbar`** (`role="toolbar"`, `aria-orientation="vertical"`, an `aria-label` such as
|
||||
"Workspace panes") — **not** a row of independently-tabbable buttons. Grouping into a toolbar
|
||||
@@ -394,11 +440,13 @@ data-first door ("Build a chart from your data") beside its primary. _(Consulted
|
||||
docs/exploration/chart-builder-enhancement-scope.md §3 · 3D. This bullet is the contract; cite it, not
|
||||
the source.)_
|
||||
|
||||
**Resolved — one live region per shared message.** When the same error feeds two surfaces
|
||||
(the §1 "one producer, two subscribers" case — render errors via `PreviewStore`), exactly
|
||||
**one** subscriber is the live region (`role="alert"` on the editor, where focus is); the
|
||||
other shows the text visually with no live role. Two live regions would announce the same
|
||||
message twice.
|
||||
**Resolved — a render error lives in one place, the preview.** The render-error message
|
||||
(`LivePreview`'s local `error` state) shows only in the preview pane, where the chart would be
|
||||
(error _xor_ chart), and that single surface is the `role="alert"` live region — assertive, since the user
|
||||
just caused it. It is announced regardless of where focus sits, so it needs no duplicate near
|
||||
the editor; the editor instead pinpoints the cause with an inline squiggle. Messages share a
|
||||
terse line-led / noun-led shape (`Line 14 · Unexpected end of input`, `Dataset "x" not found ·
|
||||
…`, `Invalid JSON · …`) — the location or the problem noun first, then the parser detail.
|
||||
|
||||
**Resolved — inline _live_ validation feedback is polite, glyphed, and field-linked.** A
|
||||
validator that re-checks on **every keystroke** (the Chart Builder expression inputs — a
|
||||
@@ -683,6 +731,39 @@ choice isn't re-litigated per surface:
|
||||
_(Consulted via /council → WAI-ARIA APG tabs/accordion/disclosure, IBM Carbon
|
||||
accordion usage, NN/g #6/#8.)_
|
||||
|
||||
## 10. Product claims & promise copy
|
||||
|
||||
Declarative copy — the landing, the About modal, onboarding, empty-state value props —
|
||||
makes **claims** about the product, not just feedback about an action. The care the §3
|
||||
triad gives error wording applies here too: **say only what we can certify, and say it
|
||||
once.** An overstated claim reads as insecurity, and the first time a user catches one
|
||||
being false it costs more trust than the claim ever bought (NN/g credibility; GOV.UK
|
||||
"don't oversell"). The test is not modesty for its own sake — it is that every sentence
|
||||
survives a skeptical reading.
|
||||
|
||||
- **Claim what we can certify — not the future, not what we don't control.** "No account,
|
||||
no server" is structural and always true (there is no backend). "Never leave your machine"
|
||||
is a vow over every future build and every edge case; state the posture instead — "stored
|
||||
locally on your device; there's no server to send them to."
|
||||
- **No absolutes.** _never · always · fully · entirely · everything._ One edge case or one
|
||||
future feature falsifies them, and the reader feels the overreach even when it happens to
|
||||
hold. Prefer the scoped form: "works offline" over "fully offline"; "your library lives in
|
||||
the browser" over "everything lives in the browser."
|
||||
- **Don't promise durability the platform doesn't back.** Browser storage (IndexedDB, no
|
||||
`persist()`) is best-effort and the browser may evict it. A chart is **saved**, not kept
|
||||
forever — route the permanence claim through **export**, which is the real backup.
|
||||
- **State a posture once per surface.** Repeating "local / no account / no server / offline"
|
||||
across the hero, the lede, and a feature grid is three chances to sound unsure of it. Give
|
||||
the posture one home and let the other surfaces describe the product.
|
||||
- **Match the register, and don't under-sell.** Astrolabe is a free, spare-time tool: the
|
||||
voice is plain and matter-of-fact, not manifesto. But concrete, true capabilities —
|
||||
portable Vega-Lite JSON, two authoring modes, custom themes — are claims worth making
|
||||
plainly. Reducing promises means cutting the _uncertain_ ones, never the real ones.
|
||||
|
||||
SOUL.md §"Local-Only by Default" is the internal **intent** and may be absolute; this
|
||||
section governs how that intent is **phrased to users**, where the promise should be only as
|
||||
strong as we can keep.
|
||||
|
||||
---
|
||||
|
||||
## Do / Don't
|
||||
@@ -694,6 +775,8 @@ accordion usage, NN/g #6/#8.)_
|
||||
- Adopt the APG keyboard pattern for new widgets; route all global keys through the one
|
||||
router.
|
||||
- Mark **optional** fields, not required ones (GOV.UK) — e.g. "Comment (optional)".
|
||||
- In product claims, say only what we can certify, once per surface; route durability
|
||||
through export.
|
||||
- Consult `/council` when this contract is silent — then record the answer back here.
|
||||
|
||||
**Don't**
|
||||
@@ -709,3 +792,5 @@ accordion usage, NN/g #6/#8.)_
|
||||
- Don't ship a **dead disabled control** as a placeholder for an unbuilt feature — a
|
||||
disabled button explains nothing and is skipped by assistive tech (GOV.UK, NN/g). Omit the
|
||||
action until it works, then show it enabled (e.g. "Build Chart" appears with M4).
|
||||
- Don't use absolutes in product claims (never/always/fully/everything) or promise what the
|
||||
platform can't keep — say "saved," not "permanent."
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
An interactive deep-dive into Vega-Lite, served at `/learn/`: a marketing surface separate
|
||||
from the app, where each lesson walks a spec from an 80%-naive version to a polished one to
|
||||
teach the grammar's dormant power and funnel readers into the app.
|
||||
teach the grammar's dormant power and funnel readers into the app. It is pitched past the
|
||||
basics — fundamentals are left to the official Vega-Lite docs (linked from the index), so
|
||||
the section leads with advanced cases (interaction, composition, transforms) rather than
|
||||
fundamentals.
|
||||
|
||||
## A marketing surface, like the landing
|
||||
|
||||
@@ -14,6 +17,12 @@ teach the grammar's dormant power and funnel readers into the app.
|
||||
stays light.
|
||||
- **Out of PWA scope.** The service worker is scoped to `/app/`, so the learning pages stay
|
||||
uncontrolled, always-fresh, and indexable — the point for organic-reach content.
|
||||
- **An index plus a page per lesson.** `/learn/` is the index (a card per lesson); each
|
||||
lesson is its own URL `/learn/<slug>/` — a separate indexable document with its
|
||||
frontmatter-derived `<title>`/`<meta>`. The per-lesson HTML shells are generated from the
|
||||
lesson frontmatter by `scripts/learn-pages.ts` (run from `vite.config` on every dev/build,
|
||||
so "drop a file" still holds; the shells are git-ignored). The single `src/learn` entry
|
||||
renders the index or one lesson from `location.pathname`.
|
||||
|
||||
## Lessons are markdown; the renderer is general
|
||||
|
||||
@@ -21,23 +30,31 @@ A lesson is a `.md` file in `src/learn/lessons/`, discovered with `import.meta.g
|
||||
**adding a lesson is dropping a file**, with no registry to edit. A lesson is a _free-form
|
||||
document of ordered blocks_, not a fixed template:
|
||||
|
||||
| Authored as | Block | Rendered by |
|
||||
| ---------------------------------------------------- | ------------- | --------------------------- |
|
||||
| plain markdown | `prose` | `Markdown` |
|
||||
| `:::progression` wrapping `##` stages + fenced specs | `progression` | `SpecProgression` |
|
||||
| a bare fenced `vega-lite` block | `chart` | `LandingChart` |
|
||||
| `:::name … :::` | `callout` | `Markdown` (styled by name) |
|
||||
| Authored as | Block | Rendered by |
|
||||
| ---------------------------------------------------- | ------------- | ----------------------------- |
|
||||
| plain markdown | `prose` | `Markdown` |
|
||||
| `:::progression` wrapping `##` stages + fenced specs | `progression` | `SpecProgression` |
|
||||
| a bare fenced `vega-lite` block | `chart` | `LandingChart` |
|
||||
| `:::name … :::` | `callout` | `Markdown` (styled by name) |
|
||||
| `:::data` wrapping a `{ name: rows }` JSON object | — metadata | injected into specs at render |
|
||||
|
||||
Inside a `:::progression`, each `##` heading is a stage: heading → tab label, prose → note,
|
||||
the following fenced `vega-lite` block → spec. Inline data repeats per fenced block — there
|
||||
is no shared-data construct.
|
||||
the following fenced `vega-lite` block → spec. A `:::data` block names datasets once for the
|
||||
whole lesson; specs reference them with `{ "data": { "name": … } }` and `injectDatasets`
|
||||
merges the rows in at render time — so a shared dataset isn't repeated per stage, and the
|
||||
source pane keeps a stage's grammar legible instead of burying it under data.
|
||||
|
||||
## The pipeline
|
||||
|
||||
`lessons/*.md` → `import.meta.glob` (in `LearnPage`) → `parseLesson` (`core/lesson-parse`) →
|
||||
`LessonBlock[]` → `LearnPage` block dispatch → `Markdown` | `SpecProgression` | `LandingChart`.
|
||||
`SpecProgression` renders each stage's spec with `formatSpec` (`core/json-format`) and
|
||||
highlights what the stage changed with `changedLines` (`core/spec-diff`, an LCS line-diff).
|
||||
`lessons/*.md` → `import.meta.glob` + `parseLesson` (`src/learn/lessons.ts`, using
|
||||
`core/lesson-parse`) → `LESSONS`. The `src/learn` entry reads the path: `/learn/` →
|
||||
`LearnIndex`, `/learn/<slug>/` → `LessonView`, both inside `LearnLayout` (shared
|
||||
header/footer/theme). `LessonView` dispatches each block → `Markdown` | `SpecProgression` |
|
||||
`LandingChart`. `SpecProgression` renders each stage's spec with `formatSpec`
|
||||
(`core/json-format`) and highlights what the stage changed with `changedLines`
|
||||
(`core/spec-diff`, an LCS line-diff). `parseLesson` also returns `datasets` (the `:::data`
|
||||
blocks); `LessonView`/`SpecProgression` call `injectDatasets` so only the _rendered_ spec
|
||||
carries the rows — the displayed-and-diffed spec keeps its by-name reference.
|
||||
|
||||
## Rules
|
||||
|
||||
@@ -52,3 +69,7 @@ highlights what the stage changed with `changedLines` (`core/spec-diff`, an LCS
|
||||
us), never user input — not an XSS surface.
|
||||
- Lesson specs are fenced JSON parsed with `JSON.parse`; the source pane re-formats them with
|
||||
`formatSpec` so the shown JSON matches the editor's house style.
|
||||
- **Lesson charts render through `LandingChart` with `fitMode: 'width'`** — which sets
|
||||
`width: "container"` and drops fixed heights on every view, and container width only works
|
||||
for a single or layered view, not side-by-side. A multi-view lesson is therefore a
|
||||
`vconcat` (a stacked column), not an `hconcat` dashboard, which would fight the sizing.
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,447 @@
|
||||
# Embedding Vega-Lite in a real app: the parts the docs don't warn you about
|
||||
|
||||
Vega-Lite is a joy to author and a little treacherous to embed. The grammar is
|
||||
well documented; the _runtime_ — view lifecycle, sizing, fonts, export, theming —
|
||||
is where you lose an afternoon to a blank chart with no error in the console.
|
||||
|
||||
This is a field guide from building a browser app that renders arbitrary,
|
||||
user-authored Vega-Lite specs live: type JSON on the left, see the chart on the
|
||||
right, export it, theme it, keep it responsive. Everything below is something that
|
||||
actually cost us time, with the fix and — more importantly — _why_ it happens, so
|
||||
you can recognize the next variant of it.
|
||||
|
||||
It assumes `vega-embed`. If you hand-roll `compile → parse → new View()`, the same
|
||||
issues apply; you just own more of the plumbing.
|
||||
|
||||
---
|
||||
|
||||
## 1. The view is the bug surface, not the spec
|
||||
|
||||
Every successful `vegaEmbed()` hands back a `result.view` — a live Vega `View`. It
|
||||
owns timers, signal listeners, event handlers, and DOM. Render a new spec into the
|
||||
same node without disposing the old view and the old one **leaks**: its listeners
|
||||
keep firing, resources accumulate, and a long editing session slowly degrades.
|
||||
|
||||
```ts
|
||||
let current: Awaited<ReturnType<typeof vegaEmbed>> | null = null;
|
||||
|
||||
async function rerender(node: HTMLElement, spec, config) {
|
||||
current?.view.finalize(); // tear down the previous view FIRST
|
||||
node.replaceChildren(); // drop any DOM the previous embed left behind
|
||||
current = await vegaEmbed(node, spec, { config });
|
||||
}
|
||||
```
|
||||
|
||||
Two rules that pay for themselves:
|
||||
|
||||
- **`finalize()` before every re-embed, and on unmount.** This is the single most
|
||||
important discipline. `finalize()` is not optional cleanup; it's how you avoid a
|
||||
zombie view.
|
||||
- **Keep all `vegaEmbed()` calls behind one small module.** Components ask it to
|
||||
"draw this spec into this node" and get back a handle with `destroy()`,
|
||||
`toImageURL()`, `resize()`. Nothing else imports `vega-embed` or touches a `View`
|
||||
directly. This one boundary is what makes every other fix in this article land in
|
||||
exactly one place.
|
||||
|
||||
---
|
||||
|
||||
## 2. Container sizing breaks in two completely different ways
|
||||
|
||||
`width: "container"` / `height: "container"` is how Vega-Lite does responsive
|
||||
sizing. It's also the source of the two most baffling bugs we hit, and they look
|
||||
nothing alike.
|
||||
|
||||
### Gotcha A — the chart collapses to zero width
|
||||
|
||||
Symptom: `width: "container"` charts render a sliver; `height: "container"` is
|
||||
often fine. Classic "width is broken, height works" head-scratcher.
|
||||
|
||||
Cause: `vega-embed` injects `.vega-embed { display: inline-block }` into `<head>`
|
||||
at runtime. Because it's injected late, it **wins the cascade** over a class you put
|
||||
on that same element. `inline-block` shrink-wraps horizontally, and `"container"`
|
||||
width reads `host.clientWidth` — which is now ~0. (Height survives because a tall
|
||||
parent still gives the box a `clientHeight`.)
|
||||
|
||||
A nasty wrinkle: `vega-embed` only adds its responsive `chart-wrapper` element —
|
||||
the thing its own `width: 100%` rule targets — **when the actions menu is enabled**.
|
||||
If you pass `actions: false` (you probably do; see §7), that path is dead and your
|
||||
element is branded `inline-block` directly, with nothing fixing it.
|
||||
|
||||
Fix: embed into a dedicated **inner host** with a _static_ className, nested inside
|
||||
an outer frame you control. Size the inner host with a **two-class selector** so you
|
||||
out-specify `.vega-embed`:
|
||||
|
||||
```css
|
||||
/* one class loses to .vega-embed; two classes win */
|
||||
.fitWidth .host {
|
||||
width: 100%;
|
||||
}
|
||||
```
|
||||
|
||||
Keep the inner host's class static so React (or whatever owns the DOM) never
|
||||
re-reconciles it and stomps Vega's runtime classes.
|
||||
|
||||
### Gotcha B — the chart never follows a resize
|
||||
|
||||
Symptom: a responsive chart sizes correctly on first render, then ignores the pane
|
||||
being dragged wider.
|
||||
|
||||
Cause: Vega-Lite compiles `"container"` sizing into width/height signals that
|
||||
re-read `containerSize()` **only on a `window:resize` event**. Two consequences
|
||||
people rediscover the hard way:
|
||||
|
||||
1. `view.resize()` does **not** re-measure. It re-runs layout with the _stale_ size.
|
||||
2. A pane drag (a splitter, a layout change) fires no `window:resize`, so nothing
|
||||
re-fits on its own.
|
||||
|
||||
Fix: observe the host with a `ResizeObserver` and synthesize the event Vega is
|
||||
actually listening for.
|
||||
|
||||
```ts
|
||||
const ro = new ResizeObserver(() => {
|
||||
window.dispatchEvent(new Event('resize')); // the mechanism, not a hack
|
||||
});
|
||||
ro.observe(host);
|
||||
```
|
||||
|
||||
This is the documented mechanism, not a workaround — it's literally what the Vega
|
||||
editor does. `ResizeObserver` callbacks are frame-batched, so it tracks a drag
|
||||
smoothly with no debounce. Bonus: only the container-bound dimension carries the
|
||||
resize handler, so a width-only chart re-fits width and leaves height natural for
|
||||
free, with zero bookkeeping.
|
||||
|
||||
---
|
||||
|
||||
## 3. Fonts must finish loading _before_ you render — any renderer
|
||||
|
||||
This one is invisible until you ship a custom font. The chart renders, the text
|
||||
looks slightly wrong (spacing off, labels colliding or over-padded), and it
|
||||
_sometimes_ fixes itself on the next edit.
|
||||
|
||||
Cause: Vega measures every text label with canvas `measureText` **regardless of
|
||||
renderer** — SVG, canvas, even the headless `'none'` renderer runs a layout pass. If
|
||||
a web font is still loading when you embed, the entire chart is laid out with
|
||||
_fallback_ font metrics. When the real font swaps in, the glyphs change but the
|
||||
layout was already computed against the wrong widths.
|
||||
|
||||
Fix: gate the render on the fonts the spec actually references.
|
||||
|
||||
```ts
|
||||
async function ensureFontsLoaded(families: string[]) {
|
||||
if (!document.fonts?.load) return; // no-op in tests / old browsers
|
||||
const loads = families.flatMap((f) =>
|
||||
['400', '600', '700'].map((w) => document.fonts.load(`${w} 16px ${f}`)),
|
||||
);
|
||||
// allSettled, not all: a missing face (offline, 404, a system family with no
|
||||
// @font-face) is EXPECTED — degrade to fallback metrics, never fail the chart.
|
||||
await Promise.race([
|
||||
Promise.allSettled(loads),
|
||||
new Promise((r) => setTimeout(r, 3000)), // bound a slow first fetch
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
Two judgment calls worth copying: use `allSettled` (a font failing to load is not a
|
||||
chart error — it's a render-with-fallback), and cap the wait with a timeout so a
|
||||
slow network never freezes the preview. A cached face resolves near-instantly; the
|
||||
timeout only ever bounds the very first fetch of an uncached subset.
|
||||
|
||||
---
|
||||
|
||||
## 4. SVG vs canvas is a real performance cliff, and canvas has a silent ceiling
|
||||
|
||||
The default `renderer: 'svg'` is the right call almost always — crisp at any zoom,
|
||||
inspectable, copyable, themeable. But SVG renders **one DOM node per mark**. A chart
|
||||
with thousands of marks (say one bar per row of a 10k-row dataset) costs _seconds_
|
||||
of main-thread layout and paint per render. We measured ~6.5s of paint on ~10k rows —
|
||||
and the freeze lands _after_ the chart first appears, because the browser paints the
|
||||
SVG tree lazily. The tab locks up holding a chart that looks done.
|
||||
|
||||
Switch many-mark charts to `renderer: 'canvas'`: a single node, painted in
|
||||
milliseconds. The raster trade-off (not crisp on zoom) is invisible for an ephemeral
|
||||
preview, and — crucially — image export is renderer-agnostic (§7), so you lose
|
||||
nothing downstream.
|
||||
|
||||
But canvas has its own trap: a **hard maximum dimension**. Browsers cap a canvas
|
||||
backing store at ~32,767px per side (less on Safari, which is also area-bound). Past
|
||||
that, the canvas fails to allocate and draws **nothing** — no error, no exception,
|
||||
just a blank surface and sometimes a null 2D context. A tall categorical chart
|
||||
(hundreds of natural-height rows) blows past this easily.
|
||||
|
||||
Fix: before committing to canvas, run a headless layout probe and read the resolved
|
||||
size. The `'none'` renderer computes layout without allocating a canvas:
|
||||
|
||||
```ts
|
||||
const probe = await vegaEmbed(detachedDiv, spec, { renderer: 'none', config });
|
||||
const height = probe.view.height();
|
||||
probe.view.finalize();
|
||||
|
||||
const limit = 32767 / (window.devicePixelRatio || 1); // backing store is dpr×
|
||||
if (height > limit) throw new ChartTooLargeError(height, limit);
|
||||
```
|
||||
|
||||
Now you can tell the user the _real_ cause ("this chart is 50,000px tall") instead
|
||||
of handing back a blank box. SVG has no such cap — it just gets slow — so the probe
|
||||
is canvas-only.
|
||||
|
||||
---
|
||||
|
||||
## 5. Exporting an image has three sharp edges
|
||||
|
||||
You'd think `view.toImageURL()` is the export story. It isn't, quite.
|
||||
|
||||
**Retina blur.** `toImageURL`'s `scaleFactor` ignores `devicePixelRatio`. A naive
|
||||
"1×" PNG export comes out at half resolution on a 2× display — soft, obviously
|
||||
wrong next to the crisp on-screen chart. Multiply the scale by dpr yourself:
|
||||
|
||||
```ts
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const canvas = await view.toCanvas(scale * dpr); // "1×" now matches the screen
|
||||
```
|
||||
|
||||
**Transparent background.** If your theme sets `background: 'transparent'` (you
|
||||
probably do, so the chart shows the pane color through it — §6), every export is
|
||||
also transparent. Usually not what someone wants in a PNG. Composite an opaque color
|
||||
under the canvas, and inject a full-bleed `<rect>` as the first child of the root
|
||||
`<svg>` for the vector path:
|
||||
|
||||
```ts
|
||||
svg = svg.replace(/(<svg\b[^>]*>)/, `$1<rect width="100%" height="100%" fill="${bg}"/>`);
|
||||
```
|
||||
|
||||
**SVG drops your fonts.** `view.toSVG()` serializes only the `font-family` _name_.
|
||||
Open that SVG anywhere the font isn't installed and it falls back to a system font.
|
||||
If the font is one your users uploaded, embed it as a base64 `@font-face` rule inside
|
||||
a `<style>` at the top of the SVG:
|
||||
|
||||
```ts
|
||||
const rule = `@font-face{font-family:"${family}";src:url(${dataUri}) format("woff2");}`;
|
||||
// SVG is XML, and a family name can contain & or <, so wrap the CSS in CDATA —
|
||||
// and defensively split the one sequence CDATA can't contain:
|
||||
const css = rule.replace(/]]>/g, ']]]]><![CDATA[>');
|
||||
svg = svg.replace(/(<svg\b[^>]*>)/, `$1<style type="text/css"><![CDATA[${css}]]></style>`);
|
||||
```
|
||||
|
||||
PNG needs none of this — the raster already baked the glyphs in. Only the vector
|
||||
format leaks the font dependency.
|
||||
|
||||
One nice property to lean on: export is **renderer-agnostic**. `view.toCanvas()` and
|
||||
`view.toSVG()` draw to their own off-screen surface, independent of how the chart is
|
||||
displayed. So you can show an SVG chart on screen and still export a high-res PNG, or
|
||||
show a canvas preview (§4) and still export a clean SVG.
|
||||
|
||||
---
|
||||
|
||||
## 6. Theme is a config you merge at embed time — and Vega is picky about it
|
||||
|
||||
A Vega-Lite **config** object styles every chart globally: fonts, axis colors,
|
||||
background, the categorical palette. The right model is to keep the config _out_ of
|
||||
the user's stored spec and inject it at embed time, so the same spec re-themes for
|
||||
free when the UI flips light/dark:
|
||||
|
||||
```ts
|
||||
await vegaEmbed(node, spec, { config: chartConfigFor(theme) });
|
||||
```
|
||||
|
||||
Three things that bit us:
|
||||
|
||||
- **The spec wins, key by key.** Vega-Lite merges your injected `config` _under_ the
|
||||
spec's own `config` (`mergeConfig(opt.config, spec.config)`). That's the behavior
|
||||
you want — a snippet can override or opt out locally — but know it: you can't force
|
||||
a style the spec contradicts.
|
||||
- **Don't rebuild a config from a fixed schema.** If you let users edit a config
|
||||
through structured controls, mutate the config object _in place_; don't reconstruct
|
||||
it from a known set of keys. Preset themes (and Vega proper) carry Vega-_layer_
|
||||
keys — `symbol`, `shape`, `path`, `group` — that aren't in the Vega-Lite `Config`
|
||||
type but are forwarded to Vega at render. A rebuild silently drops them.
|
||||
- **A bare scheme name passes compile but fails at render.** Writing
|
||||
`range: { category: "tableau20" }` (a bare string) survives Vega-Lite _compilation_
|
||||
and then Vega rejects it at _render_ with "Unrecognized scale range value" — and
|
||||
blanks the chart. The accepted form is the object: `range: { category: { scheme:
|
||||
"tableau20" } }`. This compile-passes/render-fails split is a recurring Vega theme;
|
||||
when a chart goes blank with a console error but no compile error, suspect a value
|
||||
that's structurally valid JSON but semantically wrong for the runtime.
|
||||
|
||||
If you want themes that follow the system light/dark, keep exactly **one** function
|
||||
that maps `(selection, uiTheme) → config`. Every render resolves through it; nothing
|
||||
else decides a chart's styling. (We also offer the `vega-themes` package's presets
|
||||
verbatim — it's already in your tree as a `vega-embed` dependency, so the famous
|
||||
FiveThirtyEight / Excel / Carbon looks are free.)
|
||||
|
||||
---
|
||||
|
||||
## 7. `actions: false` does more than hide a menu
|
||||
|
||||
You'll almost certainly want `actions: false` — the built-in "Save as / View Source /
|
||||
Open in Vega Editor" overlay doesn't belong on most embeds, and you'll provide your
|
||||
own export. Just know two side effects:
|
||||
|
||||
- As noted in §2, it removes the responsive `chart-wrapper`, so you own host sizing.
|
||||
- You also give up the built-in PNG/SVG export, so build your own through the view
|
||||
(§5). That's a feature, not a cost — you get dpr-correct, background-filled,
|
||||
font-embedded exports the built-in menu never gave you.
|
||||
|
||||
And for tooltips: pass `tooltip: { disableDefaultStyle: true }` so `vega-tooltip`
|
||||
doesn't inject its own light/dark stylesheet. The tooltip element (`#vg-tooltip-
|
||||
element`) is appended to `<body>`, so once the default style is gone you style it
|
||||
entirely from your own CSS — and because it lives under `<html>`, it inherits your
|
||||
`[data-theme]` cascade for free. `vega-tooltip` still handles positioning and the
|
||||
`.visible` toggle; you just supply the look.
|
||||
|
||||
---
|
||||
|
||||
## 8. Field names with dots are not what you think
|
||||
|
||||
If you construct specs from data-derived column names (a chart builder, an
|
||||
auto-encoding helper), this _will_ bite you. Vega-Lite treats `.`, `[`, and `]`
|
||||
inside a `field:` as **nested-property accessors**: `field: "user.age"` reads
|
||||
`row.user.age`, not a column literally named `"user.age"`. Real-world CSVs have
|
||||
columns like `Price ($)` or `2021.Q3` all the time.
|
||||
|
||||
```ts
|
||||
const escapeField = (name: string) => name.replace(/([.[\]])/g, '\\$1');
|
||||
encoding.x = { field: escapeField(columnName), type: 'quantitative' };
|
||||
```
|
||||
|
||||
Escape every data-derived name before it lands in any field-position key — `field`,
|
||||
`as`, `groupby`, tooltip fields, the lot. (For specs a user hand-authored, escaping
|
||||
is their responsibility; don't rewrite their `field:` values.)
|
||||
|
||||
---
|
||||
|
||||
## 9. Never mutate the spec you render
|
||||
|
||||
Rendering should be a pure function of (spec, config). If your pipeline rewrites the
|
||||
spec on the way to the view — resolving dataset references to inline values, applying
|
||||
a responsive sizing mode, escaping fields — do it on a **deep copy**:
|
||||
|
||||
```ts
|
||||
const prepared = structuredClone(userSpec);
|
||||
// ...mutate `prepared` freely: resolve refs, set width:"container", etc.
|
||||
await vegaEmbed(node, prepared, { config });
|
||||
// userSpec is untouched — what the user sees in the editor is still what they wrote.
|
||||
```
|
||||
|
||||
The moment rendering mutates the stored spec, you get spooky action: a fit-mode
|
||||
toggle permanently rewrites the user's `width`, an export inlines a 2MB dataset into
|
||||
the document they're editing. Keep the transform pure and copy-first, and it stays
|
||||
unit-testable without a DOM as a bonus.
|
||||
|
||||
A related subtlety: a "fit to container" mode that sets `width: "container"` should
|
||||
also _delete_ the spec's explicit `height` (and vice-versa) so the unconstrained
|
||||
dimension recomputes naturally. Which means a surface that lets the user type an
|
||||
explicit width/height must opt _out_ of fit mode while they do — the two fight over
|
||||
the same keys.
|
||||
|
||||
---
|
||||
|
||||
## 10. Live editing: debounce the input, guard the output
|
||||
|
||||
For a live preview that re-renders as the user types, two independent concerns:
|
||||
|
||||
**Debounce edit → state, not state → render.** Re-rendering must never compete with
|
||||
typing. Debounce the editor's text changes (we make the delay user-configurable,
|
||||
~500–5000ms); render only after a pause. But render _immediately_ for non-typing
|
||||
changes — loading a different spec, a theme flip, a fit-mode toggle. The debounce
|
||||
exists for keystroke churn and nothing else; detect "this was a keystroke" by
|
||||
elimination (the text changed but the document identity didn't).
|
||||
|
||||
**Guard against out-of-order renders.** `vegaEmbed`/`runAsync` is async, so a slow
|
||||
render can resolve _after_ a newer one already mounted. A bare debounce doesn't cover
|
||||
this. Stamp each render with a generation token and let only the latest one win:
|
||||
|
||||
```ts
|
||||
let generation = 0;
|
||||
async function render() {
|
||||
const mine = ++generation;
|
||||
const handle = await renderSpec(node, spec, config);
|
||||
if (mine !== generation) {
|
||||
handle.destroy();
|
||||
return;
|
||||
} // a newer render superseded us
|
||||
current = handle;
|
||||
}
|
||||
```
|
||||
|
||||
And keep the _last good chart on screen_ while the next render computes — overlay a
|
||||
subtle busy indicator rather than blanking the pane. A pane that flickers to empty on
|
||||
every keystroke feels broken even when it's fast.
|
||||
|
||||
---
|
||||
|
||||
## 11. Errors: a blank spec is not an error, and recovery should be automatic
|
||||
|
||||
Three stages fail, and you want them distinguishable in the message: JSON parse
|
||||
("Invalid JSON: …"), spec preparation ("Dataset not found: …"), and embed itself
|
||||
("Rendering error: …", the Vega-Lite compile or Vega runtime failure). Funnel all
|
||||
three to one error field the preview reads.
|
||||
|
||||
The behaviors that make it feel solid:
|
||||
|
||||
- **Empty/blank text renders nothing** — a clean pane, not an error. Finalize the
|
||||
current view, clear the error, stop.
|
||||
- **Every successful render clears the error.** Recovery is then automatic: the next
|
||||
valid edit re-renders and wipes the message. No retry button, no reload.
|
||||
- **Keep the last good chart visible under a parse error** if you can, so a
|
||||
half-typed keystroke doesn't strobe the whole pane.
|
||||
- **Wrap `runAsync`/embed 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 it.
|
||||
- **Don't dump a raw stack trace.** Give the reason plus a hint ("check your JSON and
|
||||
that the spec is valid Vega-Lite").
|
||||
|
||||
---
|
||||
|
||||
## 12. If you also embed an editor (Monaco) — wire it yourself
|
||||
|
||||
Optional, but if you're putting users in front of raw spec JSON you'll want schema
|
||||
validation and autocomplete. The non-obvious parts:
|
||||
|
||||
- **Bundle the schema; never fetch it.** `import schema from
|
||||
'vega-lite/vega-lite-schema.json'` and register it once, globally, via the JSON
|
||||
language service (`setDiagnosticsOptions`). Version-locked to your installed
|
||||
Vega-Lite, offline-safe, no runtime network call. Set `enableSchemaRequest: false`
|
||||
so the worker can't go fetch an unbundled `$schema` URL behind your back.
|
||||
- **Bind by `fileMatch`, not by the doc's `$schema` value.** If you key validation
|
||||
off the `$schema` line, a spec without one gets zero validation and zero
|
||||
completions. Match your model URIs instead so it always works.
|
||||
- **Monaco workers are on you.** With a CDN loader they're automatic; self-hosted,
|
||||
you must set `MonacoEnvironment.getWorker` to return the JSON worker for label
|
||||
`'json'` and the editor worker otherwise. No worker means no squiggles and no
|
||||
completions — and no error telling you why.
|
||||
- **`quickSuggestions: { strings: true }`.** Vega-Lite enum values (`"bar"`,
|
||||
`"quantitative"`) live _inside JSON strings_, where Monaco disables autocomplete by
|
||||
default. Without this, completions silently never appear.
|
||||
- **Two layers, two tiers.** The Monaco worker gives inline squiggles; a separate
|
||||
`ajv` pass can feed a richer error pane. Sort findings into _fatal_ (syntax / compile
|
||||
/ runtime errors that suppress the chart) and _advisory_ (schema-validation warnings
|
||||
that don't). Vega-Lite emits plenty of benign warnings; treating them as fatal hides
|
||||
specs that render fine.
|
||||
- **ajv has its own gotchas:** construct it with `strict: false`, add the draft-06
|
||||
meta-schema (the VL schema is draft-06; ajv 8 defaults newer), register a no-op
|
||||
`color-hex` format, and **compile the validator once at module load** — the schema
|
||||
is multi-megabyte and compiling per keystroke is a real perf sink.
|
||||
|
||||
---
|
||||
|
||||
## The short version
|
||||
|
||||
If you skim one thing, skim this:
|
||||
|
||||
| Trap | Fix |
|
||||
| --------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| Re-embedding leaks the old view | `view.finalize()` before every re-embed and on unmount |
|
||||
| `width:"container"` collapses to ~0 | Inner host + out-specify `.vega-embed { inline-block }` with a 2-class selector |
|
||||
| Chart won't follow a resize | `ResizeObserver` → `window.dispatchEvent(new Event('resize'))`, not `view.resize()` |
|
||||
| Custom font lays out wrong | `document.fonts.load(...)` (allSettled + timeout) before embed; metrics are measured regardless of renderer |
|
||||
| Many-mark SVG freezes the tab | Switch to `renderer: 'canvas'`; probe size first — canvas fails silently past ~32k px |
|
||||
| Export looks soft on Retina | Multiply scale by `devicePixelRatio` |
|
||||
| Export is transparent / loses fonts | Composite a bg color; embed `@font-face` (CDATA) in the SVG |
|
||||
| Bare scheme name blanks the chart | Use `range: { category: { scheme: "…" } }`, not a bare string |
|
||||
| Dotted column names misread | Escape `.[]` in every data-derived `field:` |
|
||||
| Stale async render clobbers a fresh one | Render-generation token; only the latest wins |
|
||||
| Rendering mutates the user's spec | Transform on a `structuredClone` copy |
|
||||
|
||||
None of these are exotic. They're the gap between "it works in the demo" and "it
|
||||
holds up under a 10k-row dataset, a custom font, a dragged pane, and a Retina
|
||||
export." Vega-Lite is excellent; it just expects you to know where its runtime edges
|
||||
are. Now you do.
|
||||
@@ -15,3 +15,4 @@ snapshots, not live numbers.
|
||||
- `chart-builder-enhancement-scope.md` — consolidated Tier-B → Tier-C forward plan for the Chart Builder.
|
||||
- `chart-theming-scope.md` — chart theming plan and slice breakdown.
|
||||
- `monetization-and-sync-exploration.md` — monetization + BYO-cloud-sync direction memo.
|
||||
- `ai-augmentation-exploration.md` — why Astrolabe stays AI-free, and the key-storage security analysis behind it.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# AI Augmentation — Exploration
|
||||
|
||||
> **Status:** Exploration, not a commitment. Captured 2026-06-27 from a strategy
|
||||
> conversation. The conclusion is folded into [`SOUL.md`](../../SOUL.md) (_Local-Only by
|
||||
> Default_ and _Not an AI tool_); this memo keeps the _reasoning_ — including the security
|
||||
> analysis behind rejecting browser-stored keys — so a future "should we add AI?" session
|
||||
> doesn't re-derive it.
|
||||
>
|
||||
> **Question:** Most tools shipping in 2026 carry some AI/LLM augmentation. Should
|
||||
> Astrolabe?
|
||||
>
|
||||
> **Short answer:** No — and the user benefit, not purity, is the reason. With no server,
|
||||
> no account, and no AI, nothing the user makes is handled by a third party, so Astrolabe
|
||||
> is safe for confidential and work data from the first chart. "Everyone ships AI in 2026"
|
||||
> is the weakest possible reason to add it: ubiquity makes AI table-stakes noise, not
|
||||
> differentiation, and a tool that demonstrably keeps your data on your machine is
|
||||
> differentiated _because_ it resists the trend.
|
||||
|
||||
---
|
||||
|
||||
## 1. Where AI would genuinely fit, if ever
|
||||
|
||||
Two spots where Vega-Lite is actually painful and rules can't help but a model could:
|
||||
natural-language authoring (NL → spec) and explaining/decoding the editor's opaque
|
||||
validation errors. The obvious third — "recommend a chart from my data" — is **already
|
||||
solved deterministically** by the Chart Builder's rule-based inference, and the rule-based
|
||||
version is better here because it's explainable and runs locally. So the genuine surface is
|
||||
narrow.
|
||||
|
||||
## 2. Why browser-stored BYO keys were rejected
|
||||
|
||||
The only AI model consistent with "no server, no account" is bring-your-own-key, called
|
||||
direct browser → provider (never proxied through a server we run, which would put us back in
|
||||
the data-custody business). The blocker is key storage:
|
||||
|
||||
- A browser has **no secure vault for a secret you must read back**. localStorage,
|
||||
IndexedDB, cookies — all readable by any same-origin JS, devtools, and extensions. Client
|
||||
encryption only helps if the unlock secret isn't _also_ stored, i.e. a passphrase typed
|
||||
each session; a key kept beside its ciphertext is theater.
|
||||
- The dominant threat is therefore **same-origin script execution (XSS)**, and Astrolabe is
|
||||
unusually exposed to it: it renders arbitrary user specs through vega-embed, whose
|
||||
expression evaluator and data loader are a real script-execution / exfiltration surface.
|
||||
Holding a secret in that origin upgrades any spec-driven bug from "annoying" to "steals
|
||||
the user's key." **Introducing a stored secret raises the threat level of the whole app,
|
||||
the chart renderer included** — the opposite of what the privacy posture exists to do.
|
||||
- The one mitigant: LLM keys are revocable and spend-cappable, so the blast radius is "bill
|
||||
abuse until you rotate it," not data loss. That's why the industry tolerates browser BYOK
|
||||
at all — but it doesn't undo the origin-coupling above.
|
||||
|
||||
**If AI is ever revisited:** the only acceptable form is a **session-only** key (held in
|
||||
memory, never persisted, re-entered each session) called browser → provider direct, with the
|
||||
core staying fully functional and offline for anyone who never engages it. Persisting the
|
||||
key is the specific part that compromises the posture.
|
||||
@@ -0,0 +1,115 @@
|
||||
# Multi-view data model — scope & plan
|
||||
|
||||
Astrolabe authors arbitrary Vega-Lite, including **composed** specs (`layer`,
|
||||
`hconcat`/`vconcat`/`concat`, `facet`, `repeat`). Most of the spec-structure
|
||||
machinery already handles composition; the data-facing features carried
|
||||
single-view assumptions. This memo records the assessment, the data-model
|
||||
contract that anchors the work, and the milestone plan to make multi-view support
|
||||
durable. The guiding constraint: **extend Vega-Lite, never break it** — every
|
||||
native data form must keep working.
|
||||
|
||||
## The data-model contract
|
||||
|
||||
A dataset reference is exactly Vega-Lite **named data**: a `data` block with a
|
||||
string `name` and no `values`/`url`/generator key, whose name the spec does not
|
||||
self-define via top-level `datasets`. This mirrors Vega-Lite's `isNamedData`
|
||||
(`reference/vega-lite/src/data.ts`). The classification is owned by
|
||||
`core/spec-data` (`classifyData`, `libraryRefName`); reference extraction
|
||||
(`spec-refs`), rename (`spec-refs`), and render-time resolution (`rendering`) all
|
||||
route through it. See `docs/architecture/07` §3.1.
|
||||
|
||||
Library references resolve to inline data before embedding
|
||||
(`core/rendering` → `prepareSpecForRender`); a self-defined `datasets` name is
|
||||
left for Vega-Lite to resolve natively.
|
||||
|
||||
## Assessment: already multi-view vs. single-view assumptions
|
||||
|
||||
**Already composition-aware** (recurse all view operators): ref extraction/rename
|
||||
(`spec-refs`), reference resolution + fit-mode (`rendering`), structural wrap/
|
||||
unwrap/add-view (`spec-transforms`, `spec-cursor`, `spec-insert`), derived-field
|
||||
collection (`spec-fields`), config baking (`spec-config`), standalone export
|
||||
(`chart-export`, reuses `prepareSpecForRender`).
|
||||
|
||||
**Single-view assumptions** (the work):
|
||||
|
||||
- **Editor data context** (`app/services/active-dataset`) resolves _one_ dataset
|
||||
for the whole draft (first ref, or first inline data), with no notion of which
|
||||
view the cursor sits in. Completion/hover/inlay (`spec-dataset-hints`) and the
|
||||
facet/repeat field defaults (`spec-transform-actions`) therefore offer the wrong
|
||||
view's columns in a composition whose views bind different datasets.
|
||||
- **Extract inline data** (`app/stores/ExtractStore`) lifts only the top-level
|
||||
`data` block.
|
||||
- **Data inspector** (`DataInspector`) surfaces one input + one resolved table; a
|
||||
composition produces several `source_<n>`/`data_<n>`.
|
||||
- **`deriveSnippetName`** (`core/snippet`) reads top-level `mark`/`encoding` only,
|
||||
so a composed spec falls back to the default name (graceful, not a bug).
|
||||
- **Chart builder** is single-view by design; its strict round-trip hydration
|
||||
returns `null` for composed specs, so they stay Monaco-only (correct).
|
||||
|
||||
## Vega-Lite fidelity clashes
|
||||
|
||||
1. **Named inline/url data misread as a reference** — classifying on "has a
|
||||
string `name`" alone caught named-inline (`{ name, values }`) and named-url,
|
||||
breaking valid specs (spurious `DatasetNotFoundError`, or clobbered inline
|
||||
values). _Resolved_ by the `core/spec-data` classifier (M1).
|
||||
2. **Shadowing** — a library dataset whose name equals a self-defined `datasets`
|
||||
key is silently ignored (self-defined wins). Documented precedence; candidate
|
||||
for a user-facing note, no code change required.
|
||||
3. **Case-rule split** — library matching is case-insensitive (`naming.ts`);
|
||||
self-defined exclusion and Vega-Lite's own named-data lookup are case-sensitive.
|
||||
These are distinct namespaces, so the split is defensible; minor.
|
||||
4. **Runtime-injected named data** — Vega-Lite allows binding `{ name }` at
|
||||
runtime; Astrolabe always pre-resolves, so an imported spec relying on runtime
|
||||
injection won't render. Out of scope.
|
||||
|
||||
## Milestone plan
|
||||
|
||||
- **M1 — data-model foundation** ✅ — `core/spec-data` classifier mirroring
|
||||
`isNamedData`; `spec-refs` + `rendering` routed through it. Closes clash 1.
|
||||
- **M2 — view-scoped editor context** ✅ — `dataBindingAtPath` (climb the cursor's
|
||||
JSON path to the nearest enclosing `data`, honoring Vega-Lite's parent→child
|
||||
inheritance) + `derivedFieldNamesAtPath` (ancestor-chain `as` outputs).
|
||||
`active-dataset` is cursor-scoped (`dataInfoAt(text, offset)`), resolving columns
|
||||
for every form (library ref case-insensitive, inline, named-inline, self-defined
|
||||
`datasets`, url/generator → none); the three Monaco providers and the
|
||||
facet/repeat defaults pass the cursor offset.
|
||||
- **M4 — multi-view inspection** ✅ (pulled ahead of M3) — `core/inspect-views`
|
||||
enumerates the distinct tables the marks draw (from the compiled Vega spec's
|
||||
`from.data` + `data[].source` lineage), each with its input + resolved ends;
|
||||
`RenderHandle.inspectData()` returns those tables with rows; `DataInspector` adds
|
||||
a `SelectControl` view picker (hidden for the single-table case), labels via
|
||||
`inspectViewLabel` (named dataset or "View N", never compiler names) + a
|
||||
`columns · rows` cue. Enumerating by drawn table (not authored view) is forced by
|
||||
Vega-Lite desugaring (a `point: true` line compiles to two layers). Replaced the
|
||||
single-pair `core/result-data`.
|
||||
- **M5 — live / interactive inspection** ✅ — the inspector reacts to interactive
|
||||
selections. `RenderHandle.onDataChange` attaches a debounced `view.addDataListener`
|
||||
to each drawn table's resolved + input names; a selection-as-**filter**
|
||||
(`filter: {param}`) recomputes a downstream view's `data_N`, so its listener fires
|
||||
and `LivePreview` bumps a `liveEpoch` that re-reads the table ("what am I
|
||||
visualizing _now_"); a selection-as-**highlight** (a `condition` encoding) changes
|
||||
no data, so nothing fires. The watcher is gated on the inspector being open
|
||||
(a collapsed one costs nothing) and is **always live, no toggle** — the table just
|
||||
tracks the brush; the ~120ms debounce coalesces a drag's continuous pulses.
|
||||
Selection `*_store` tables are not drawn, so the M4 enumeration already ignores them.
|
||||
- **M3 — view-scoped extract** ✅ — Extract is scoped to the view at the cursor.
|
||||
`services/extract-action` resolves the focused view's data binding
|
||||
(`dataBindingAtPath`) and lifts whichever of two embedded-data shapes it carries:
|
||||
a view's inline **`data.values`** (`inlineValuesOf` → rewrite that view's `data`
|
||||
block at its anchor path), or a **`{ name }` reference to a self-defined
|
||||
`datasets` entry** (`selfDefinedPayloadOf` → `promoteSelfDefinedDataset`: drop the
|
||||
`datasets` entry, and the map when it empties, so the same reference resolves to
|
||||
the new library dataset; rename refs when the name changes, pre-filled with the
|
||||
existing name). The toolbar offers Extract whenever any view carries either shape
|
||||
(`specHasExtractableData`); a cursor in a view with neither (a library ref, url,
|
||||
generator) gets a guide toast. A single-view spec resolves to the root binding
|
||||
from any cursor, so the common case is unchanged. Confirm re-serializes in the
|
||||
app's house style. A `lookup` transform's inline `from.data` is covered incidentally
|
||||
— `dataBindingAtPath` finds it like any view binding (which also means the editor
|
||||
_hints_ read the lookup table's columns when the cursor sits inside the transform;
|
||||
acceptable for now, noted). The orphan case — a `datasets` entry no view references
|
||||
— is out of scope (no view to scope the cursor to; it is dead data to delete).
|
||||
|
||||
Delivery is incremental, one milestone per commit, verified against real behavior.
|
||||
The durable contract is recorded in `docs/architecture` 05 (live inspection) and 07
|
||||
(reference detection + extraction, §3.1–3.2); this memo stays the point-in-time record.
|
||||
@@ -0,0 +1,168 @@
|
||||
# Visual Composition Editing — Exploration
|
||||
|
||||
> **Status:** research recorded 2026-06-29. Point-in-time record of a feasibility study for a
|
||||
> **visual, drag-editable view of multi-view composition** (`vconcat`/`hconcat`/`concat`/
|
||||
> `layer`/`facet`/`repeat`). Two surfaces were weighed: a **schematic wireframe panel** and an
|
||||
> **on-chart overlay** aligned to the real rendered chart.
|
||||
>
|
||||
> **Decision (2026-06-29):** build the **schematic wireframe first** — it carries ~90% of the
|
||||
> value with a deterministic spec↔box mapping and no coupling to Vega runtime internals. The
|
||||
> **on-chart overlay** is a feasible later "geometry skin" over the same edit core, deferred
|
||||
> because its risk (compiled-name↔source-path correlation, and an edit-vs-interact pointer
|
||||
> conflict) is real and isolated. First build step: a **read-only Phase A spike** —
|
||||
> `viewTree(spec)` + a static nested-box renderer with click-to-cursor sync.
|
||||
>
|
||||
> **Shipped (2026-06-29):** Phases A–C — the wireframe, mark-type leaf glyphs, in-container
|
||||
> reorder (drag + `Alt+↑/↓`), and cross-container drag-to-restructure (`core/spec-restructure`
|
||||
> `wrapViews`). The live contract is now arch 08 (transforms) + arch 10 (interaction). Phase D
|
||||
> (on-chart overlay) and Phase E (size editing) remain deferred — size deferred by choice.
|
||||
|
||||
---
|
||||
|
||||
## 1. The brief
|
||||
|
||||
A visual, interactive representation of a spec's multi-view structure — boxes for the views,
|
||||
showing arrangement and nesting (what contains what), draggable to rearrange, with the spec
|
||||
reacting. Explicitly **not** a chart preview: a wireframe of blocks. Reference feel: a Tableau
|
||||
dashboard's GUI.
|
||||
|
||||
## 2. The framing realization: a tree of tiled containers, not a canvas
|
||||
|
||||
Vega-Lite composition is a **nested tree**, not a free 2D plane:
|
||||
|
||||
- `layer` / `hconcat` / `vconcat` / `concat` hold an **array** of child views.
|
||||
- `facet` / `repeat` hold a **single, data-generated** child (`spec`), not an array.
|
||||
|
||||
So the apt analogy is Tableau's **tiled containers** (nested horizontal/vertical), not Tableau's
|
||||
**floating** layout. Every drag must resolve to a discrete tree operation — reorder within a
|
||||
container, move across containers, wrap siblings into a new container, flip orientation, unwrap —
|
||||
never an arbitrary `(x, y)` drop. Communicating that constraint _as_ the design (snap-to-zones,
|
||||
not free placement) is the central UX problem.
|
||||
|
||||
## 3. Two surfaces, one edit core
|
||||
|
||||
Both surfaces feed the **same mutation core** and differ only in where the boxes come from.
|
||||
|
||||
| | **Schematic wireframe** | **On-chart overlay** |
|
||||
| -------------------------------- | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| Box geometry | Computed from the source tree | Read from Vega's rendered scenegraph |
|
||||
| Vega-runtime dependency | None | Hard (`view.scenegraph()`) |
|
||||
| Path ↔ box mapping | **Deterministic** (we own every path) | **Brittle** — correlate compiled group names (`concat_0_group`, `child__a_group`) back to source paths |
|
||||
| Reflects true rendered sizes | No (schematic; equal-weight unless explicit `width`/`height`) | Yes (pixel-accurate) |
|
||||
| Works on mid-edit / invalid JSON | Yes (error-tolerant parse) | No (needs a successful render) |
|
||||
|
||||
**Geometry sync fidelity (overlay).** High. `view.scenegraph()` exposes each sub-view as a
|
||||
`SceneGroup` with exact `bounds`/`width`/`height`; the default **SVG** renderer also yields real
|
||||
DOM `<g name="…">` nodes measurable via `getBoundingClientRect()`. The app already holds the live
|
||||
`view` handle (the Inspector reads live data from it), so exposing the scenegraph is a small
|
||||
`RenderHandle` extension. The fidelity ceiling is not geometry — it is the three overlay risks in
|
||||
§6.
|
||||
|
||||
## 4. Existing infrastructure vs. new work
|
||||
|
||||
**Reusable today** (much of it from the cursor-scoped CodeLens work):
|
||||
|
||||
- Mutations & paths: `compositionTargetAt` / `insertView` / `moveView` / `elementOffset`
|
||||
(`core/spec-insert`), wrap/unwrap with property-partition rules (`core/spec-transforms`:
|
||||
`SHARED_TOP`/`LAYER_TOP`, `placeholderView`, `unwrapSingleton`, `ARRAY_COMPOSITIONS`).
|
||||
- Data model: `dataBindingAtPath` (`core/spec-data`) for data inheritance.
|
||||
- Write-back: the whole-document reformat + paired `pushUndoStop` path (`spec-transform-actions`),
|
||||
so a drag is one ⌘Z.
|
||||
- UI scaffolding: pointer-drag hook `useResizeDrag` (no DnD library in use), resizable-panel
|
||||
scaffolding (`PanesStore`/`AppStore`), the preview pane's stacked layout (header / chart /
|
||||
`DataInspector`).
|
||||
- Render handle: `chart-renderer` keeps `result.view`; SVG by default.
|
||||
|
||||
**New work, roughly in build order:**
|
||||
|
||||
- **`viewTree(spec)`** — recursive source-spec → tree of `{ kind, path, label, children, sizeHint }`.
|
||||
Small, pure core. No tree builder exists today; `inspectableViews` walks the _compiled_ vg spec
|
||||
for data tables, which is the wrong layer for structure.
|
||||
- **Cross-container mutations** — `moveViewTo(from, toContainer, index)`, `wrapSiblings(...)`
|
||||
(drop-creates-container), `removeView` + collapse. **Medium-large and correctness-sensitive** —
|
||||
the real cost and the subtle bugs live here (§6).
|
||||
- **Wireframe renderer** — nested flex boxes from `viewTree`, plus drag + drop-zones +
|
||||
create-container zones, and selection↔cursor sync (reuses cursor plumbing).
|
||||
- **(Overlay only)** scenegraph→path correlation, an absolutely-positioned overlay with
|
||||
coordinate transforms, re-sync on every render, and an edit⇄interact mode toggle.
|
||||
|
||||
## 5. Effort & phasing
|
||||
|
||||
- **Phase A — read-only wireframe.** `viewTree` + static nested-box renderer + click-to-cursor
|
||||
sync. Small. De-risks the model with zero mutation risk.
|
||||
- **Phase B — reorder within a container.** Drag → `moveView` (exists). Small.
|
||||
- **Phase C — cross-container move + wrap-on-drop + delete/collapse.** Medium-large. The core
|
||||
value and the correctness work.
|
||||
- **Phase D — on-chart overlay (optional).** Geometry skin over the proven core. Medium; risk
|
||||
isolated to correlation + mode conflict.
|
||||
- **Phase E — resize handles → `width`/`height` (optional).** Medium.
|
||||
|
||||
## 6. Edge cases & hidden problems
|
||||
|
||||
**Composition model**
|
||||
|
||||
- **Facet/repeat cells are data-generated** — count depends on data cardinality (unknown without
|
||||
running), and individual cells are not arrangeable (they do not exist in the source). Render as
|
||||
one "grid" placeholder with a badge.
|
||||
- **`layer` is z-order, not spatial** — children coincide in one box; needs a depth/stack
|
||||
metaphor, and in the overlay the layer rectangles overlap (ambiguous hit-testing).
|
||||
- **`concat` + `columns: N`** is a wrap-grid — a third layout mode beside pure h/v.
|
||||
- Deep nesting → tiny boxes (min-size + zoom/scroll); mixed orientations recurse.
|
||||
|
||||
**Mutation correctness (the subtle traps)**
|
||||
|
||||
- **Property migration across container types.** `width`/`height` live on the _child_ in concat
|
||||
but on the _wrapper_ in layer; `data`/`resolve` on the wrapper. A cross-type move must relocate
|
||||
these or the view silently renders wrong. The partition rules exist for wrap; cross-move needs
|
||||
the analogue.
|
||||
- **Data-inheritance breakage.** A child with no explicit `data` inherits its nearest ancestor's.
|
||||
Moved under a different data source it silently rebinds; detect via `dataBindingAtPath` and
|
||||
pin the effective data onto the moved view (a real decision, not free).
|
||||
- **Degenerate drops** — onto itself, into its own descendant (cycle), or a move that empties /
|
||||
single-childs a container (collapse via `unwrapSingleton`, which can strand a `resolve`/`spacing`
|
||||
that no longer has a composition to apply to).
|
||||
|
||||
**Round-trip & sync**
|
||||
|
||||
- Specs are **plain JSON; reformat strips comments** and rewrites the whole document — already
|
||||
true of the existing transforms, so consistent.
|
||||
- Source of truth is the draft text; wireframe and editor both mutate it → reuse the atomic
|
||||
write-back + undo-stop path.
|
||||
- Mid-edit invalid JSON: wireframe degrades to last-valid; overlay has no fresh render to track.
|
||||
|
||||
**Overlay-specific**
|
||||
|
||||
- **Compiled-name ↔ source-path correlation is an undocumented compiler contract** — can shift
|
||||
across Vega-Lite versions and is ambiguous for layers and facet internals. The overlay's biggest
|
||||
risk.
|
||||
- **Edit-overlay vs. the chart's own interactivity.** With `params`/brush/pan-zoom selections, an
|
||||
editing overlay steals the pointer events those selections need → requires an explicit
|
||||
**edit ⇄ interact** mode toggle, an interaction split the wireframe avoids.
|
||||
- The view is **finalized and recreated each render**, so the overlay re-measures every time (brief
|
||||
flicker) and must track scroll/resize/DPR and `autosize`-container coordinate transforms.
|
||||
|
||||
**Accessibility**
|
||||
|
||||
- Drag-and-drop needs a keyboard path (Move up/down exist; cross-container needs a keyboard
|
||||
equivalent), per the WAI-ARIA APG drag-and-drop pattern, plus reduced-motion. A `/council`
|
||||
item before the interaction is built.
|
||||
|
||||
## 6a. Deferred polish (Phase A follow-ons)
|
||||
|
||||
- **Mark-type glyph per leaf.** _(Shipped.)_ A simplified glyph of each unit view's mark inside
|
||||
its box (the `mark-*` icon sub-family), so which-is-which reads at a glance.
|
||||
- **A more legible `layered` primitive.** _(Shipped.)_ A layer renders as **one frame** holding
|
||||
its child marks as a row of glyphs, badged as layered (the `layers` glyph) — not separate boxes,
|
||||
so it reads as one space and stays distinct from a concat (which is box-per-view). The
|
||||
overlapping/stacked-planes options weighed here were rejected: at glyph scale, overlapping
|
||||
line-art muddies the very marks the glyphs exist to show; legibility beat the z-order-depth cue.
|
||||
- **Hide the affordance for single-view specs.** _(Shipped.)_ The toolbar glyph appears only when
|
||||
the spec has a composition.
|
||||
|
||||
## 7. Recommendation
|
||||
|
||||
Build the schematic wireframe (Phase A → C) first: deterministic mapping, no Vega-internal
|
||||
coupling, works mid-edit, no pointer conflict with chart interactivity. Treat the on-chart overlay
|
||||
as an optional later skin over the same proven edit core. Start with the **Phase A spike**
|
||||
(`viewTree` + static nested boxes + click-to-cursor) to make the model concrete before committing
|
||||
to the mutation work.
|
||||
@@ -13,6 +13,13 @@ The editor presents the active snippet's spec as formatted JSON with full code-e
|
||||
- The editor's appearance and behavior — font size, editor theme, minimap visibility, word wrap, line numbers, and tab size — are configurable and read from _Settings_; this section does not redefine their defaults.
|
||||
- The editor always edits a single active snippet. Selecting a different snippet in the _Snippet Library_, or toggling the Draft/Published view, replaces the editor content with the corresponding spec.
|
||||
|
||||
### Scaffolding assistance
|
||||
|
||||
Beyond the schema's own suggestions, the editor scaffolds common Vega-Lite structures as ready-to-fill skeletons, seeded from the data actually in scope at the cursor. An inserted skeleton arrives with editable placeholders (Tab moves between them), pre-filled with type-appropriate values where the data allows and descriptive names otherwise. Scaffolding acts on the draft only — the published view is a read-only reference and offers none of it.
|
||||
|
||||
- **Data transforms.** With the cursor in a view, inline actions above the code offer the pipeline: _Add transform_ when the view has none, the common steps (filter, aggregate, calculate, bin, timeUnit) on an existing `transform` array. Inside a `transform[]` element slot, typing offers the full step catalog as completions, each seeded with a matching column (a numeric field for aggregate, a temporal one for timeUnit). A step added on a composition parent notes that it applies to every child view below it.
|
||||
- **Parameters.** The same affordance for `params`, split by where Vega-Lite allows each family: **variable** widgets (slider, dropdown — an input control bound to a name the spec can reference) are offered at the spec's top level, the only place they are legal; **selection** parameters (point, interval — interaction on the chart's marks) on the unit view the cursor is in; in a single-view spec, where the top level is the unit, both appear together. Inside a `params[]` element slot, completions offer the catalog — both families in the top-level array, selections only in a nested view's. Defaults are data-seeded where possible: a slider's min/max/step from the numeric field's actual range, a point selection's field from a categorical column.
|
||||
|
||||
## B. Auto-Save of the Draft
|
||||
|
||||
Edits persist automatically so the user never loses work and never needs an explicit "save" action for ordinary editing.
|
||||
@@ -57,14 +64,14 @@ Every snippet carries two versions of its spec: a **published** (stable) version
|
||||
- On confirmation, the editor reloads with the published spec and a toast confirms the draft was reverted.
|
||||
- Revert is unavailable when no snippet is active.
|
||||
|
||||
## E. Inline Error Surface
|
||||
## E. Error Surface
|
||||
|
||||
When the spec cannot be parsed or cannot be rendered, the editor pane shows the problem clearly while keeping the user in place to fix it.
|
||||
When the spec cannot be parsed or cannot be rendered, the problem is shown clearly while keeping the user in place to fix it.
|
||||
|
||||
- When the spec is invalid JSON, or is valid JSON but fails to render as Vega-Lite (including an unresolved dataset reference), a clear, readable error message appears in the editor pane, near the editor area.
|
||||
- The error message is plainly legible (monospaced, distinct from normal content) and conveys what went wrong.
|
||||
- When the spec is invalid JSON, or is valid JSON but fails to render as Vega-Lite (including an unresolved dataset reference or a malformed Vega expression), a clear, readable error message appears in the **preview pane, in place of the chart** — a spec either renders or shows its error, never both.
|
||||
- The message is plainly legible (monospaced, distinct from normal content) and leads with the location or the problem, then the detail — for example `Line 14 · Unexpected end of input` or `Dataset "sales" not found · create it from Datasets`.
|
||||
- In the **editor**, the offending spot is marked with an inline squiggle — a JSON syntax error where it occurs, a malformed expression on its own string — so the cause is locatable without leaving the code.
|
||||
- The editor remains fully usable while an error is shown, so the user can edit to fix it; the error clears automatically once a subsequent edit renders successfully.
|
||||
- This is the editor-side error affordance only; how a valid spec is drawn lives in _Live Preview_.
|
||||
|
||||
## F. Extract Inline Data to a Dataset
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 09 · Data Model & Persistence
|
||||
|
||||
This section defines the persistent entities of Astrolabe and how they relate. It is the authoritative data contract: an implementer recreating the app should store equivalent records with these fields and meanings. Types are given abstractly (string, number, boolean, ISO-timestamp string, string[], object, "JSON value") so they map onto any stack. "JSON value" means any valid JSON shape — object, array, string, number, boolean, or null.
|
||||
This section defines the persistent entities of Astrolabe and how they relate. It is the data record of the shipped app: an implementer recreating the app should store equivalent records with these fields and meanings. Types are given abstractly (string, number, boolean, ISO-timestamp string, string[], object, "JSON value") so they map onto any stack. "JSON value" means any valid JSON shape — object, array, string, number, boolean, or null.
|
||||
|
||||
All data lives entirely in the browser. There is no server, account, or sync. Records survive page reload and remain available offline (see _Application Shell & Navigation_). To move data between browsers or devices, use _Import & Export_.
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ A UX/behavioral specification of **Astrolabe**, a browser-based snippet manager
|
||||
- Each subsequent file is one feature area and can be read on its own; they cross-reference each other by title.
|
||||
- Every section describes intended behavior plus testable acceptance points ("The user can…", "When X, the system…").
|
||||
- Section numbers and lettered headings (e.g. `§03G`, `§09B`) are **stable anchors** — code comments reference them. Extend by appending the next letter/number; never renumber existing ones.
|
||||
- The spec is **descriptive**: it records what the shipped app does. The code leads — when the app and a section here disagree, the section is stale; rewrite it to match (deliberately) rather than treating it as a veto on the code.
|
||||
|
||||
## What this spec deliberately omits
|
||||
|
||||
|
||||
@@ -8,6 +8,13 @@ record the resolution into the contract (`docs/architecture/09`+`10` and the rel
|
||||
|
||||
## Open
|
||||
|
||||
- **Extract-to-Dataset has no keyboard accelerator** — its sibling editor actions (wrap /
|
||||
config, in `spec-transform-actions` / `spec-config-actions`) register an F1-palette command
|
||||
and a lightbulb; Extract is toolbar-only (`runExtract` in `services/extract-action.ts`),
|
||||
because it opens a modal rather than making an in-place undoable edit, so the palette/lightbulb
|
||||
fit awkwardly. Decide whether to add a palette command anyway for parity (a keyboard path to
|
||||
open the modal at the cursor), or leave toolbar-only.
|
||||
|
||||
- **Storage-full copy implies a per-tier budget, but quota is whole-origin** — the messages
|
||||
say "snippet storage is full" / "dataset storage is full" and tell the user to delete that
|
||||
entity's items, yet IndexedDB quota is shared across the whole origin. Per-tier framing is
|
||||
@@ -17,6 +24,17 @@ record the resolution into the contract (`docs/architecture/09`+`10` and the rel
|
||||
`storageErrorNotification` and `entityStorageErrorNotification` in `services/storage-errors.ts`
|
||||
and the import-quota copy in `services/transfer.ts`.
|
||||
|
||||
- **"Row" vs "column" naming differs between the wireframe's pull-out and pair drags** — the
|
||||
frame-margin pull-out chip/announcement (`pullLabel`, `commitDrop`) name the new full-span band
|
||||
by its _spatial_ shape (a `vconcat` slot is "a new row above"; an `hconcat` slot "a new column
|
||||
left"), while the pair-into-split chip/announcement use the _container_ convention (`hconcat` =
|
||||
"row", `vconcat` = "column"). Both describe the same axis — pulling a view above a row and
|
||||
pairing two views vertically are both a `vconcat` — yet one calls it a row and the other a
|
||||
column. Each reading is locally sensible (a pulled-out band reads as a row; a 2-cell vertical
|
||||
split reads as a column) but the divergence can confuse. Decide: unify on one vocabulary, or keep
|
||||
the gesture-specific framing. In `components/CompositionWireframe.tsx` (`pullLabel`, the
|
||||
`'row'`/`'column'` ternaries in `resolveDrop`/`commitDrop`).
|
||||
|
||||
## Deferred (not design debts, revisit on demand)
|
||||
|
||||
- **Drag-and-drop field assignment** — chips are click/keyboard-first by design; drag would
|
||||
|
||||
+3
-3
@@ -44,10 +44,10 @@ export default tseslint.config(
|
||||
languageOptions: { globals: { ...globals.node } },
|
||||
},
|
||||
|
||||
// Plain JS config files (this file, etc.) are not part of the TS project —
|
||||
// run them through the untyped ruleset only.
|
||||
// Plain JS config files and Node scripts (this file, scripts/*.mjs, etc.) are
|
||||
// not part of the TS project — run them through the untyped ruleset only.
|
||||
{
|
||||
files: ['**/*.js'],
|
||||
files: ['**/*.{js,mjs}'],
|
||||
extends: [tseslint.configs.disableTypeChecked],
|
||||
languageOptions: { globals: { ...globals.node } },
|
||||
},
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
<link rel="mask-icon" href="/icon-mono.svg" color="#0e7490" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta
|
||||
name="description"
|
||||
content="A local-first studio for Vega-Lite charts. Author specs as JSON, see them render live, and keep a searchable library in your browser. No account, no server, no AI — with no backend to send it to, your data stays on your device, so confidential work is safe here from the first chart."
|
||||
/>
|
||||
<title>Astrolabe — a local Vega-Lite studio</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Generated
+8
@@ -22,6 +22,7 @@
|
||||
"@fontsource/space-mono": "^5.2.9",
|
||||
"@fontsource/spectral": "^5.2.8",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"marked": "^18.0.5",
|
||||
"monaco-editor": "^0.54.0",
|
||||
"react": "^19.2.7",
|
||||
@@ -39,6 +40,7 @@
|
||||
"@types/react": "^19.2.16",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"ajv": "^8.20.0",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
@@ -6148,6 +6150,12 @@
|
||||
"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": {
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
|
||||
|
||||
+3
-1
@@ -6,7 +6,7 @@
|
||||
"description": "A browser-based snippet manager for Vega-Lite visualizations.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"build": "tsc --noEmit && vite build && node scripts/check-light-entries.mjs",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
@@ -38,6 +38,7 @@
|
||||
"@fontsource/space-mono": "^5.2.9",
|
||||
"@fontsource/spectral": "^5.2.8",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"marked": "^18.0.5",
|
||||
"monaco-editor": "^0.54.0",
|
||||
"react": "^19.2.7",
|
||||
@@ -55,6 +56,7 @@
|
||||
"@types/react": "^19.2.16",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"ajv": "^8.20.0",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Cloudflare Pages header rules (copied into dist/ by Vite's public/ passthrough).
|
||||
#
|
||||
# Everything under /assets/ is content-hashed by the build (JS, CSS, and the
|
||||
# font files), so it is safe to cache forever — a changed file gets a new URL.
|
||||
# Without this rule CF Pages serves its default `max-age=14400, must-revalidate`,
|
||||
# making every returning visitor revalidate multi-MB vendor chunks every 4 hours.
|
||||
# HTML keeps the platform default (max-age=0, must-revalidate) so deploys
|
||||
# propagate instantly; icons live at the root un-hashed and keep the default too.
|
||||
/assets/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
@@ -0,0 +1,47 @@
|
||||
// Post-build gate: the marketing entries stay light. The landing (/) and the
|
||||
// learning section (/learn/) must never gain a static edge into the heavy
|
||||
// vendor chunks — this shipped once (Vite's preload helper emitted inside the
|
||||
// monaco chunk chained every lazy import() to it; a vega-scale import in
|
||||
// theme-controls was reachable from Landing). Vite lists an entry's full
|
||||
// static graph as modulepreload links / module scripts in its HTML, so
|
||||
// grepping the emitted HTML catches any regression regardless of cause.
|
||||
//
|
||||
// Runs as part of `npm run build` (after `vite build`). Exit 1 on violation.
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const pages = ['dist/index.html', 'dist/learn/index.html'];
|
||||
if (existsSync('dist/learn')) {
|
||||
for (const entry of readdirSync('dist/learn', { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) pages.push(join('dist/learn', entry.name, 'index.html'));
|
||||
}
|
||||
}
|
||||
|
||||
const HEAVY = /vendor-(?:monaco|vega)-[^"']*\.js/;
|
||||
const missing = pages.filter((p) => !existsSync(p));
|
||||
if (missing.length > 0) {
|
||||
console.error(
|
||||
`check-light-entries: expected pages missing from dist/:\n ${missing.join('\n ')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
// The gate bans chunks by name, so it must not fail open: if the names minted in
|
||||
// vite.config.ts (manualChunks) ever change, this makes the rename update the gate
|
||||
// instead of silently disarming it.
|
||||
const assets = readdirSync('dist/assets');
|
||||
for (const chunk of ['vendor-monaco', 'vendor-vega']) {
|
||||
if (!assets.some((f) => f.startsWith(`${chunk}-`) && f.endsWith('.js'))) {
|
||||
console.error(
|
||||
`check-light-entries: no ${chunk}-*.js in dist/assets — chunk naming changed; update vite.config.ts manualChunks and this gate together.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
const offenders = pages.filter((p) => HEAVY.test(readFileSync(p, 'utf8')));
|
||||
if (offenders.length > 0) {
|
||||
console.error(
|
||||
`check-light-entries: light entries reference heavy vendor chunks:\n ${offenders.join('\n ')}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`check-light-entries: OK (${pages.length} pages clean of vendor-monaco/vendor-vega)`);
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Generate one static `learn/<slug>/index.html` shell per lesson, so each lesson is
|
||||
* its own indexable URL (`/learn/<slug>/`) carrying lesson-specific `<title>` and
|
||||
* `<meta description>`. The shared `src/learn` entry renders the right view from the
|
||||
* path. Driven by the lesson `.md` frontmatter — adding a lesson stays "drop a
|
||||
* file" — and run from `vite.config.ts` at load (dev and build) so the shells stay
|
||||
* in sync; the generated dirs are git-ignored.
|
||||
*
|
||||
* Plain content-shells only (per-URL metadata, client-rendered body). Prerendering
|
||||
* the prose into the HTML would help non-JS crawlers but is deliberately deferred.
|
||||
*/
|
||||
import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const ROOT = new URL('../', import.meta.url);
|
||||
const LESSONS_DIR = fileURLToPath(new URL('src/learn/lessons/', ROOT));
|
||||
const LEARN_DIR = fileURLToPath(new URL('learn/', ROOT));
|
||||
|
||||
interface LessonMeta {
|
||||
slug: string;
|
||||
title: string;
|
||||
tagline: string;
|
||||
}
|
||||
|
||||
// A minimal frontmatter reader, separate from `core/lesson-parse`'s `parseLesson`:
|
||||
// this runs at vite-config load time, before the `@core` alias is registered, and
|
||||
// only needs the three header fields (not the parsed body). The two must agree on
|
||||
// the frontmatter shape — both read `slug`/`title`/`tagline` from `--- … ---`.
|
||||
/** Pull `slug`/`title`/`tagline` from a lesson's `--- … ---` frontmatter. */
|
||||
function frontmatter(md: string): LessonMeta | null {
|
||||
const block = md.match(/^---\n([\s\S]*?)\n---/);
|
||||
if (!block) return null;
|
||||
const field = (key: string): string | undefined =>
|
||||
block[1].match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1].trim();
|
||||
const slug = field('slug');
|
||||
const title = field('title');
|
||||
const tagline = field('tagline');
|
||||
return slug && title && tagline ? { slug, title, tagline } : null;
|
||||
}
|
||||
|
||||
/** Lesson metadata for every `src/learn/lessons/*.md`, sorted by slug. */
|
||||
function readLessons(): LessonMeta[] {
|
||||
return readdirSync(LESSONS_DIR)
|
||||
.filter((f) => f.endsWith('.md'))
|
||||
.map((f) => frontmatter(readFileSync(LESSONS_DIR + f, 'utf8')))
|
||||
.filter((l): l is LessonMeta => l !== null)
|
||||
.sort((a, b) => a.slug.localeCompare(b.slug));
|
||||
}
|
||||
|
||||
/** Rollup inputs (one per lesson) → the generated `learn/<slug>/index.html` shells. */
|
||||
export function lessonInputs(): Record<string, string> {
|
||||
const input: Record<string, string> = {};
|
||||
for (const { slug } of readLessons()) input[`learn-${slug}`] = `${LEARN_DIR}${slug}/index.html`;
|
||||
return input;
|
||||
}
|
||||
|
||||
const escapeHtml = (s: string): string =>
|
||||
s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
function shell({ title, tagline }: LessonMeta): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en" data-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="mask-icon" href="/icon-mono.svg" color="#0e7490" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="description" content="${escapeHtml(tagline)}" />
|
||||
<title>${escapeHtml(title)} — Astrolabe</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/learn/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
/** Write a shell per lesson and prune dirs for lessons that no longer exist. */
|
||||
export function generateLearnPages(): void {
|
||||
const lessons = readLessons();
|
||||
const wanted = new Set(lessons.map((l) => l.slug));
|
||||
for (const lesson of lessons) {
|
||||
mkdirSync(`${LEARN_DIR}${lesson.slug}/`, { recursive: true });
|
||||
writeFileSync(`${LEARN_DIR}${lesson.slug}/index.html`, shell(lesson));
|
||||
}
|
||||
for (const entry of readdirSync(LEARN_DIR, { withFileTypes: true })) {
|
||||
if (entry.isDirectory() && !wanted.has(entry.name)) {
|
||||
rmSync(`${LEARN_DIR}${entry.name}`, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
* Rendered inside ModalShell — no backdrop, close button, or focus trap here;
|
||||
* the shell owns all of that (docs/architecture/03 → Layer 3). This component
|
||||
* is pure content: app identity, keyboard shortcuts (§01D), privacy posture
|
||||
* (SOUL.md — local-only, no accounts, no telemetry), and acknowledgements of
|
||||
* (SOUL.md — local-only, no accounts, no telemetry, no AI), and acknowledgements of
|
||||
* the projects Astrolabe is built on and shaped by.
|
||||
*/
|
||||
|
||||
@@ -47,9 +47,8 @@ export function AboutModal() {
|
||||
v<span className={styles.version}>{__APP_VERSION__}</span>
|
||||
</p>
|
||||
<p className={styles.body}>
|
||||
A local-first workspace for authoring, organizing, and previewing Vega-Lite charts. Edit
|
||||
JSON, see the chart update live, and keep a personal library of snippets — with no
|
||||
account, no server, and full offline support.
|
||||
A local-first workspace for authoring and organizing Vega-Lite charts. Edit the JSON,
|
||||
watch it render live, and keep a personal library of snippets.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
@@ -74,17 +73,23 @@ export function AboutModal() {
|
||||
<section className={styles.section}>
|
||||
<h3 className={styles.heading}>Privacy</h3>
|
||||
<p className={styles.body}>
|
||||
Astrolabe runs entirely in your browser. Your snippets, datasets, and settings are stored
|
||||
locally and never leave your machine.
|
||||
Astrolabe runs in your browser. Your snippets, datasets, and settings are stored locally
|
||||
on your device — there’s no server to send them to. Work that has to stay
|
||||
confidential is safe here.
|
||||
</p>
|
||||
<ul className={styles.list}>
|
||||
<li>No account, no sign-in, no server-side storage.</li>
|
||||
<li>The app runs no analytics or tracking — no cookies, no telemetry, no profiling.</li>
|
||||
<li>No account or sign-in.</li>
|
||||
<li>
|
||||
The only outbound network requests are ones you create: URL-sourced datasets you add
|
||||
yourself.
|
||||
No AI. No model authors, edits, or critiques your charts, and nothing is sent to one.
|
||||
The chart builder’s suggestions are computed locally from your data.
|
||||
</li>
|
||||
<li>After the first load, the app works fully offline.</li>
|
||||
<li>
|
||||
The app itself runs no analytics or tracking. Its host (Cloudflare) records standard,
|
||||
aggregate traffic like any web server — not your library or the charts you build, which
|
||||
stay in your browser.
|
||||
</li>
|
||||
<li>The only outbound requests are ones you make: datasets you load from a URL.</li>
|
||||
<li>After the first load, the app works offline.</li>
|
||||
<li>
|
||||
Use the header’s import / export buttons to move your library between devices.
|
||||
</li>
|
||||
|
||||
@@ -25,7 +25,12 @@ vi.mock('../services/chart-renderer', () => {
|
||||
}
|
||||
return {
|
||||
renderSpec: vi.fn(() =>
|
||||
Promise.resolve({ destroy() {}, resize() {}, inspectData: () => null }),
|
||||
Promise.resolve({
|
||||
destroy() {},
|
||||
resize() {},
|
||||
inspectData: () => null,
|
||||
onDataChange: () => () => {},
|
||||
}),
|
||||
),
|
||||
ChartTooLargeError,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ vi.mock('../services/chart-renderer', () => ({
|
||||
resize() {},
|
||||
toImageURL: () => Promise.resolve(''),
|
||||
inspectData: () => null,
|
||||
onDataChange: () => () => {},
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -20,13 +20,8 @@
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import type { JsonObject } from '@core/spec-config';
|
||||
import {
|
||||
type ConfigPath,
|
||||
countSet,
|
||||
getConfigValue,
|
||||
schemeColors,
|
||||
schemesByKind,
|
||||
} from '@core/theme-controls';
|
||||
import { type ConfigPath, countSet, getConfigValue, schemesByKind } from '@core/theme-controls';
|
||||
import { schemeColors } from '@core/scheme-colors';
|
||||
import { Button } from './Button';
|
||||
import { ColorField } from './ColorField';
|
||||
import { Icon } from './Icon';
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
.wrap {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/* Disclosure panel — portaled to body, positioned `fixed` by usePopover. The shared
|
||||
disclosure-popover surface (arch 10): elevated --layer-01, hairline border, --radius. */
|
||||
.pop {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
width: 300px;
|
||||
max-height: 60vh;
|
||||
overflow: auto;
|
||||
padding: var(--space-4);
|
||||
background: var(--layer-01);
|
||||
border: var(--border-width) solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 var(--space-3);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tree {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Bare wireframe: a crisp box, fill only the canvas. Passive structure, so the
|
||||
boxes carry the function (drop targets, later) — `--border-strong`, no chrome. */
|
||||
.node {
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--bg);
|
||||
}
|
||||
/* The padding is the *pull-out margin*: a frame's gutter, between its border and its
|
||||
child boxes, is the drop zone that lifts a view out into a new full-span row/column
|
||||
(vs. a box's own edge, which pairs or reorders). Sized for a comfortable target. */
|
||||
.container {
|
||||
--pull-gutter: var(--space-5);
|
||||
--reveal-tint: color-mix(in srgb, var(--accent) 7%, transparent);
|
||||
position: relative;
|
||||
padding: var(--pull-gutter);
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaf {
|
||||
min-height: 48px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
/* The mark glyph is a quiet identity hint, not chrome — muted, inherits theme. */
|
||||
.markIcon {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.node:hover {
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Draggable boxes (draft only) advertise the drag with a grab cursor; the body
|
||||
cursor flips to grabbing for the duration of an active drag (set in JS). */
|
||||
.node[data-draggable] {
|
||||
cursor: grab;
|
||||
}
|
||||
.node[data-dragging] {
|
||||
opacity: 0.4;
|
||||
}
|
||||
/* Reorder (move) indicator (arch 10 §5): a 3px accent line on the target box's edge
|
||||
marks where the dragged view lands in the sequence. */
|
||||
.node[data-drop-mode='move'][data-drop-edge='left'] {
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
.node[data-drop-mode='move'][data-drop-edge='right'] {
|
||||
box-shadow: inset -3px 0 0 var(--accent);
|
||||
}
|
||||
.node[data-drop-mode='move'][data-drop-edge='top'] {
|
||||
box-shadow: inset 0 3px 0 var(--accent);
|
||||
}
|
||||
.node[data-drop-mode='move'][data-drop-edge='bottom'] {
|
||||
box-shadow: inset 0 -3px 0 var(--accent);
|
||||
}
|
||||
/* Pair (wrap) indicator: the two views split into a new row/column. The target box is
|
||||
outlined, and the half the incoming view will take is shaded with a seam line at the
|
||||
split — so it previews the split, not just "drop here". */
|
||||
.node[data-drop-mode='wrap'] {
|
||||
position: relative;
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
.node[data-drop-mode='wrap']::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: var(--accent-soft);
|
||||
pointer-events: none;
|
||||
}
|
||||
.node[data-drop-mode='wrap'][data-drop-edge='top']::after {
|
||||
inset: 0 0 50% 0;
|
||||
border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
.node[data-drop-mode='wrap'][data-drop-edge='bottom']::after {
|
||||
inset: 50% 0 0 0;
|
||||
border-top: 2px solid var(--accent);
|
||||
}
|
||||
.node[data-drop-mode='wrap'][data-drop-edge='left']::after {
|
||||
inset: 0 50% 0 0;
|
||||
border-right: 2px solid var(--accent);
|
||||
}
|
||||
.node[data-drop-mode='wrap'][data-drop-edge='right']::after {
|
||||
inset: 0 0 0 50%;
|
||||
border-left: 2px solid var(--accent);
|
||||
}
|
||||
/* Pull-out indicator: the dragged view would lift out into a new full-span row/column
|
||||
here. A shaded band fills the frame's margin on the resolved side, capped by a solid
|
||||
accent line — the "shaded drop-zone" feedback (vs. the box-edge line of a pair/move). */
|
||||
.node[data-drop-pull]::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: var(--accent-soft);
|
||||
pointer-events: none;
|
||||
}
|
||||
.node[data-drop-pull='top']::after {
|
||||
inset: 0 0 auto 0;
|
||||
height: var(--pull-gutter);
|
||||
border-top: 2px solid var(--accent);
|
||||
}
|
||||
.node[data-drop-pull='bottom']::after {
|
||||
inset: auto 0 0 0;
|
||||
height: var(--pull-gutter);
|
||||
border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
.node[data-drop-pull='left']::after {
|
||||
inset: 0 auto 0 0;
|
||||
width: var(--pull-gutter);
|
||||
border-left: 2px solid var(--accent);
|
||||
}
|
||||
.node[data-drop-pull='right']::after {
|
||||
inset: 0 0 0 auto;
|
||||
width: var(--pull-gutter);
|
||||
border-right: 2px solid var(--accent);
|
||||
}
|
||||
/* Reveal-on-drag: while a drag is live, every frame's pull-out margins glow faintly so
|
||||
the targets are discoverable without hunting — across the container's own axis (a
|
||||
row's margins are top/bottom, a column's left/right). The active target reads
|
||||
stronger via the band above. */
|
||||
.tree[data-drag-active] .container[data-orientation='horizontal'] {
|
||||
box-shadow:
|
||||
inset 0 var(--pull-gutter) 0 var(--reveal-tint),
|
||||
inset 0 calc(-1 * var(--pull-gutter)) 0 var(--reveal-tint);
|
||||
}
|
||||
.tree[data-drag-active] .container[data-orientation='vertical'] {
|
||||
box-shadow:
|
||||
inset var(--pull-gutter) 0 0 var(--reveal-tint),
|
||||
inset calc(-1 * var(--pull-gutter)) 0 0 var(--reveal-tint);
|
||||
}
|
||||
/* Selection mirrors the library's active-row language (arch 09 §4). */
|
||||
.node.selected {
|
||||
border-color: var(--accent);
|
||||
box-shadow: inset 0 0 0 1px var(--accent);
|
||||
}
|
||||
.leaf.selected {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.node:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.children {
|
||||
gap: var(--space-2);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
.row > * {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
.col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
/* General concat wraps into a grid (honors `columns`). */
|
||||
.grid {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
}
|
||||
.grid > * {
|
||||
flex: 1 1 84px;
|
||||
}
|
||||
/* Layer: one plotting space holding several marks (z-order). Render it as a single
|
||||
frame — a row of the child mark glyphs, badged as layered — not as separate boxes,
|
||||
so it reads as one space and the marks stay legible (vs. the concat box-per-view). */
|
||||
.layerBox {
|
||||
position: relative;
|
||||
}
|
||||
.layered {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-3);
|
||||
min-height: 40px;
|
||||
}
|
||||
/* A layer's mark is a bare glyph, not a boxed leaf — the frame is the box. The
|
||||
transparent border keeps the hover/selection outline (from `.node`) consistent. */
|
||||
.layerMark {
|
||||
min-height: 0;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
.layerBadge {
|
||||
position: absolute;
|
||||
top: var(--space-2);
|
||||
right: var(--space-2);
|
||||
color: var(--text-placeholder);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Facet / repeat: one authored child stands for many generated cells — a card
|
||||
peeking out behind hints at the multiples. */
|
||||
.generated {
|
||||
position: relative;
|
||||
}
|
||||
.generated::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 5px -5px -5px 5px;
|
||||
border: 1px solid var(--border);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
.caption {
|
||||
margin: var(--space-3) 0 0;
|
||||
min-height: 1.4em;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
.caption code {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-placeholder);
|
||||
}
|
||||
|
||||
/* Drag chip: follows the cursor during a drag, naming the pending action ("New row
|
||||
above", "Pair into a column", "Reorder"). Inverted ink-on-bg for contrast on any
|
||||
theme; above the popover (z 1000) since it portals to the body. */
|
||||
.dragChip {
|
||||
position: fixed;
|
||||
z-index: 1100;
|
||||
pointer-events: none;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--bg);
|
||||
background: var(--text);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dragChip[data-empty] {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Redundant single-child wrapper: a frame holding one view adds no structure. A soft
|
||||
warning hairline marks it as cleanable (not broken); the prompt below offers the fix. */
|
||||
.node[data-degenerate] {
|
||||
border-style: dashed;
|
||||
border-color: var(--support-warning-fg);
|
||||
}
|
||||
.warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-3);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: 11px;
|
||||
color: var(--support-warning-fg);
|
||||
background: color-mix(in srgb, var(--support-warning) 12%, var(--bg));
|
||||
border: var(--border-width) solid color-mix(in srgb, var(--support-warning) 40%, var(--bg));
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.warning span {
|
||||
flex: 1;
|
||||
}
|
||||
.simplify {
|
||||
flex-shrink: 0;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.simplify:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.simplify:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* CompositionWireframe — the read-only structure tree (arch 08 / arch 10 §5).
|
||||
*
|
||||
* Guards the load-bearing behavior: the affordance is hidden for a single-view
|
||||
* spec (nothing to schematize), the disclosed panel is an APG tree with one
|
||||
* roving tab stop, arrow keys walk it in document order, and activating a box
|
||||
* asks the editor to reveal that view's exact source range. The viewTree model
|
||||
* itself is covered in core (spec-view-tree.test.ts).
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { usePopoverStore } from '../stores/PopoverStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { CompositionWireframe } from './CompositionWireframe';
|
||||
import { markIconName } from './mark-icon';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const COMPOSED = JSON.stringify({ vconcat: [{ mark: 'point' }, { mark: 'bar' }] }, null, 2);
|
||||
const LAYERED = JSON.stringify({ layer: [{ mark: 'bar' }, { mark: 'line' }] }, null, 2);
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
const setSpec = (text: string) => {
|
||||
useSnippetStore.getState().reset();
|
||||
useSnippetStore.setState({ draftText: text });
|
||||
};
|
||||
|
||||
/** Like `setSpec`, but with an active draft so reorder affordances are enabled. */
|
||||
const setEditableSpec = (text: string) => {
|
||||
useSnippetStore.getState().reset();
|
||||
useSnippetStore.setState({ draftText: text, activeSnippetId: 'snip', editorView: 'draft' });
|
||||
};
|
||||
|
||||
const key = (e: KeyboardEventInit) => new KeyboardEvent('keydown', { bubbles: true, ...e });
|
||||
|
||||
const treeItems = () =>
|
||||
Array.from(document.body.querySelectorAll<HTMLElement>('[role="treeitem"]'));
|
||||
const item = (key: string) => document.body.querySelector<HTMLElement>(`[data-key="${key}"]`)!;
|
||||
|
||||
async function renderOpen() {
|
||||
await act(async () => {
|
||||
root.render(<CompositionWireframe />);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await act(async () => {
|
||||
usePopoverStore.getState().show('composition-wireframe');
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
usePopoverStore.setState({ openId: null });
|
||||
useAppStore.setState({ revealTarget: null, composeRequest: null });
|
||||
setSpec(COMPOSED);
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
usePopoverStore.setState({ openId: null });
|
||||
useAppStore.setState({ revealTarget: null, composeRequest: null });
|
||||
useSnippetStore.getState().reset();
|
||||
});
|
||||
|
||||
describe('CompositionWireframe', () => {
|
||||
test('renders nothing for a single-view spec', () => {
|
||||
setSpec('{"mark":"point"}');
|
||||
act(() => root.render(<CompositionWireframe />));
|
||||
expect(container.querySelector('button')).toBeNull();
|
||||
});
|
||||
|
||||
test('shows a structure trigger for a composed spec', () => {
|
||||
act(() => root.render(<CompositionWireframe />));
|
||||
expect(container.querySelector('button[aria-controls="composition-wireframe"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
test('discloses an APG tree: the vconcat root and its two leaf views', async () => {
|
||||
await renderOpen();
|
||||
expect(document.body.querySelector('[role="tree"]')).not.toBeNull();
|
||||
expect(treeItems().map((el) => el.dataset.key)).toEqual(['root', 'vconcat|0', 'vconcat|1']);
|
||||
});
|
||||
|
||||
test('roving tabindex: exactly one treeitem is in the tab order', async () => {
|
||||
await renderOpen();
|
||||
expect(treeItems().filter((el) => el.tabIndex === 0)).toHaveLength(1);
|
||||
expect(item('root').tabIndex).toBe(0); // the root holds it on open
|
||||
});
|
||||
|
||||
test('ArrowDown moves the roving tab stop in document order', async () => {
|
||||
await renderOpen();
|
||||
act(() => {
|
||||
item('root').dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
||||
});
|
||||
expect(item('root').tabIndex).toBe(-1);
|
||||
expect(item('vconcat|0').tabIndex).toBe(0);
|
||||
});
|
||||
|
||||
test('activating a box asks the editor to reveal that view’s source range', async () => {
|
||||
await renderOpen();
|
||||
act(() => item('vconcat|1').click());
|
||||
const target = useAppStore.getState().revealTarget!;
|
||||
expect(COMPOSED.slice(target.offset, target.offset + target.length)).toContain('"bar"');
|
||||
});
|
||||
|
||||
test('a layer discloses its marks as reorderable treeitems (z-order)', async () => {
|
||||
// The layer renders as one frame of mark glyphs, but each mark stays a treeitem
|
||||
// so selection and Alt+arrow z-order reorder keep working.
|
||||
setEditableSpec(LAYERED);
|
||||
await renderOpen();
|
||||
expect(treeItems().map((el) => el.dataset.key)).toEqual(['root', 'layer|0', 'layer|1']);
|
||||
act(() => {
|
||||
item('root').dispatchEvent(key({ key: 'ArrowDown' }));
|
||||
});
|
||||
act(() => {
|
||||
item('layer|0').dispatchEvent(key({ key: 'ArrowDown', altKey: true }));
|
||||
});
|
||||
expect(useAppStore.getState().composeRequest).toMatchObject({
|
||||
kind: 'move',
|
||||
arrayPath: ['layer'],
|
||||
from: 0,
|
||||
to: 1,
|
||||
});
|
||||
});
|
||||
|
||||
test('Alt+ArrowDown asks the editor to reorder the focused view down', async () => {
|
||||
setEditableSpec(COMPOSED);
|
||||
await renderOpen();
|
||||
// Move the roving focus onto the first leaf, then reorder it down.
|
||||
act(() => {
|
||||
item('root').dispatchEvent(key({ key: 'ArrowDown' }));
|
||||
});
|
||||
act(() => {
|
||||
item('vconcat|0').dispatchEvent(key({ key: 'ArrowDown', altKey: true }));
|
||||
});
|
||||
const req = useAppStore.getState().composeRequest!;
|
||||
expect(req).toMatchObject({ arrayPath: ['vconcat'], from: 0, to: 1 });
|
||||
});
|
||||
|
||||
test('Alt+ArrowUp at the start does not reorder', async () => {
|
||||
setEditableSpec(COMPOSED);
|
||||
await renderOpen();
|
||||
act(() => {
|
||||
item('root').dispatchEvent(key({ key: 'ArrowDown' }));
|
||||
});
|
||||
act(() => {
|
||||
item('vconcat|0').dispatchEvent(key({ key: 'ArrowUp', altKey: true }));
|
||||
});
|
||||
expect(useAppStore.getState().composeRequest).toBeNull();
|
||||
});
|
||||
|
||||
test('reorder is inert on a read-only (non-draft) view', async () => {
|
||||
// Default COMPOSED is set with no active snippet → not editable.
|
||||
await renderOpen();
|
||||
act(() => {
|
||||
item('root').dispatchEvent(key({ key: 'ArrowDown' }));
|
||||
});
|
||||
act(() => {
|
||||
item('vconcat|0').dispatchEvent(key({ key: 'ArrowDown', altKey: true }));
|
||||
});
|
||||
expect(useAppStore.getState().composeRequest).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('CompositionWireframe — drag to restructure', () => {
|
||||
// happy-dom has no layout, so the drop hit-test (which reads box rects) needs them
|
||||
// stubbed. A vertical stack of two equal halves, keyed by the box's data-key.
|
||||
const RECTS: Record<string, { x: number; y: number; w: number; h: number }> = {
|
||||
root: { x: 0, y: 0, w: 200, h: 200 },
|
||||
'vconcat|0': { x: 0, y: 0, w: 200, h: 100 },
|
||||
'vconcat|1': { x: 0, y: 100, w: 200, h: 100 },
|
||||
};
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
|
||||
this: HTMLElement,
|
||||
): DOMRect {
|
||||
const r = (this.dataset?.key && RECTS[this.dataset.key]) || { x: 0, y: 0, w: 0, h: 0 };
|
||||
return {
|
||||
left: r.x,
|
||||
top: r.y,
|
||||
right: r.x + r.w,
|
||||
bottom: r.y + r.h,
|
||||
width: r.w,
|
||||
height: r.h,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
toJSON: () => ({}),
|
||||
};
|
||||
});
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const drag = (fromKey: string, to: { x: number; y: number }, shift = false) => {
|
||||
act(() => {
|
||||
item(fromKey).dispatchEvent(
|
||||
new MouseEvent('pointerdown', { bubbles: true, clientX: 100, clientY: 150 }),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: to.x, clientY: to.y, shiftKey: shift }),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(new MouseEvent('pointerup', {}));
|
||||
});
|
||||
};
|
||||
|
||||
test('dropping onto a view’s far cross edge pairs the two into a row', async () => {
|
||||
setEditableSpec(COMPOSED);
|
||||
await renderOpen();
|
||||
drag('vconcat|1', { x: 190, y: 50 }); // right edge of the top view (cross axis)
|
||||
expect(useAppStore.getState().composeRequest).toMatchObject({
|
||||
kind: 'wrap',
|
||||
targetPath: ['vconcat', 0],
|
||||
sourcePath: ['vconcat', 1],
|
||||
axis: 'horizontal',
|
||||
side: 'after',
|
||||
});
|
||||
});
|
||||
|
||||
test('dropping along the container reorders within it', async () => {
|
||||
setEditableSpec(COMPOSED);
|
||||
await renderOpen();
|
||||
drag('vconcat|1', { x: 100, y: 8 }); // top edge of the top view
|
||||
expect(useAppStore.getState().composeRequest).toMatchObject({
|
||||
kind: 'move',
|
||||
arrayPath: ['vconcat'],
|
||||
from: 1,
|
||||
to: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CompositionWireframe — pull a view out via the frame margin', () => {
|
||||
const HCON = JSON.stringify({ hconcat: [{ mark: 'point' }, { mark: 'bar' }, { mark: 'line' }] });
|
||||
// A row of three boxes inset inside the root frame, leaving a margin (the pull-out
|
||||
// zone) all around. happy-dom has no layout, so the hit-test rects are stubbed.
|
||||
const RECTS: Record<string, { x: number; y: number; w: number; h: number }> = {
|
||||
root: { x: 0, y: 0, w: 300, h: 120 },
|
||||
'hconcat|0': { x: 24, y: 24, w: 80, h: 72 },
|
||||
'hconcat|1': { x: 110, y: 24, w: 80, h: 72 },
|
||||
'hconcat|2': { x: 196, y: 24, w: 80, h: 72 },
|
||||
};
|
||||
beforeEach(() => {
|
||||
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (
|
||||
this: HTMLElement,
|
||||
): DOMRect {
|
||||
const r = (this.dataset?.key && RECTS[this.dataset.key]) || { x: 0, y: 0, w: 0, h: 0 };
|
||||
const box = {
|
||||
left: r.x,
|
||||
top: r.y,
|
||||
right: r.x + r.w,
|
||||
bottom: r.y + r.h,
|
||||
width: r.w,
|
||||
height: r.h,
|
||||
x: r.x,
|
||||
y: r.y,
|
||||
};
|
||||
return { ...box, toJSON: () => ({}) };
|
||||
});
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
const drag = (fromKey: string, to: { x: number; y: number }, shift = false) => {
|
||||
act(() => {
|
||||
item(fromKey).dispatchEvent(
|
||||
new MouseEvent('pointerdown', { bubbles: true, clientX: 60, clientY: 60 }),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(
|
||||
new MouseEvent('pointermove', { clientX: to.x, clientY: to.y, shiftKey: shift }),
|
||||
);
|
||||
});
|
||||
act(() => {
|
||||
window.dispatchEvent(new MouseEvent('pointerup', {}));
|
||||
});
|
||||
};
|
||||
|
||||
test('dropping in the cross-axis margin pulls the view into a new full-span row', async () => {
|
||||
setEditableSpec(HCON);
|
||||
await renderOpen();
|
||||
drag('hconcat|0', { x: 150, y: 8 }); // top margin of the row — across its axis
|
||||
expect(useAppStore.getState().composeRequest).toMatchObject({
|
||||
kind: 'wrap-container',
|
||||
containerPath: [],
|
||||
sourcePath: ['hconcat', 0],
|
||||
axis: 'vertical',
|
||||
side: 'before',
|
||||
});
|
||||
});
|
||||
|
||||
test('a with-axis margin reorders to that end of the row, not a pull', async () => {
|
||||
setEditableSpec(HCON);
|
||||
await renderOpen();
|
||||
drag('hconcat|1', { x: 8, y: 60 }); // left margin (along the axis), before every box
|
||||
expect(useAppStore.getState().composeRequest).toMatchObject({
|
||||
kind: 'move',
|
||||
arrayPath: ['hconcat'],
|
||||
from: 1,
|
||||
to: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('dropping over a sibling’s central band reorders past it', async () => {
|
||||
setEditableSpec(HCON);
|
||||
await renderOpen();
|
||||
drag('hconcat|2', { x: 50, y: 70 }); // central band of the first box → before it
|
||||
expect(useAppStore.getState().composeRequest).toMatchObject({
|
||||
kind: 'move',
|
||||
arrayPath: ['hconcat'],
|
||||
from: 2,
|
||||
to: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('dropping on a sibling’s top edge stacks the two into a column', async () => {
|
||||
setEditableSpec(HCON);
|
||||
await renderOpen();
|
||||
drag('hconcat|2', { x: 130, y: 30 }); // top edge of the middle box (cross axis)
|
||||
expect(useAppStore.getState().composeRequest).toMatchObject({
|
||||
kind: 'wrap',
|
||||
targetPath: ['hconcat', 1],
|
||||
sourcePath: ['hconcat', 2],
|
||||
axis: 'vertical',
|
||||
side: 'before',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CompositionWireframe — simplify redundant wrappers', () => {
|
||||
const simplifyButton = () =>
|
||||
Array.from(document.body.querySelectorAll('button')).find((b) => b.textContent === 'Simplify');
|
||||
|
||||
test('offers Simplify for a single-child composition and dispatches it', async () => {
|
||||
setEditableSpec(JSON.stringify({ hconcat: [{ mark: 'point' }] }));
|
||||
await renderOpen();
|
||||
const btn = simplifyButton();
|
||||
expect(btn).toBeTruthy();
|
||||
act(() => btn!.click());
|
||||
expect(useAppStore.getState().composeRequest).toMatchObject({ kind: 'simplify' });
|
||||
});
|
||||
|
||||
test('no Simplify prompt when every composition has multiple views', async () => {
|
||||
setEditableSpec(COMPOSED); // a vconcat of two
|
||||
await renderOpen();
|
||||
expect(simplifyButton()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('markIconName', () => {
|
||||
test('maps marks to glyphs, collapsing synonyms', () => {
|
||||
expect(markIconName('bar')).toBe('mark-bar');
|
||||
expect(markIconName('circle')).toBe('mark-point');
|
||||
expect(markIconName('square')).toBe('mark-point');
|
||||
expect(markIconName('trail')).toBe('mark-line');
|
||||
});
|
||||
|
||||
test('falls back to a generic glyph for an unknown or absent mark', () => {
|
||||
expect(markIconName('boxplot')).toBe('mark-generic');
|
||||
expect(markIconName(undefined)).toBe('mark-generic');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,798 @@
|
||||
/**
|
||||
* Composition wireframe — an interactive schematic of the active spec's multi-view
|
||||
* structure (docs/architecture/08 → editor augmentation). Nested boxes for
|
||||
* `layer`/`hconcat`/`vconcat`/`concat`/`facet`/`repeat` down to the unit views;
|
||||
* each leaf carries a glyph of its mark type so which-is-which reads at a glance.
|
||||
* Clicking a box selects it and reveals that view's source range in the editor
|
||||
* (`AppStore.requestRevealView`) — the editor stays the source of truth.
|
||||
*
|
||||
* A disclosure popover (`usePopover`) off a glyph in the preview toolbar; the panel
|
||||
* is the WAI-ARIA APG **tree** widget — `tree`/`treeitem`/`group`, single-select,
|
||||
* roving tabindex, arrow-key nav in logical (document) order (arch 10 §5).
|
||||
*
|
||||
* On the editable draft a view can be **restructured by dragging its box**. Intent is
|
||||
* read from where the pointer falls against a row/column's children, not one nearest
|
||||
* edge (arch 10 §5):
|
||||
* - the interior **central band** reorders within the container;
|
||||
* - the **cross-axis frame margin** (a row's top/bottom, a column's left/right, or
|
||||
* past the block) **pulls the source out** into a new full-span row/column wrapping
|
||||
* the whole container — the root included;
|
||||
* - a view's **far cross edge** pairs the two in a perpendicular `hconcat`/`vconcat`
|
||||
* (`Shift` forces a pair from the centre); a with-axis drop moves/inserts there.
|
||||
* Opaque `layer`/`facet`/`repeat`/grid boxes keep the simpler nearest-edge model, and a
|
||||
* redundant single-child wrapper is flagged with a one-click **Simplify**. The keyboard
|
||||
* equivalent for in-container reorder is Alt+↑/↓ (the APG rearrangeable-listbox pattern);
|
||||
* cross-container restructuring stays the editor's wrap actions for keyboard users. Every
|
||||
* edit is applied by the editor (which owns the undoable edit) via
|
||||
* `AppStore.requestComposeMove`/`requestComposeWrap`/`requestComposeWrapContainer`/
|
||||
* `requestComposeSimplify`, focus follows the affected box, and a polite live region
|
||||
* announces the result.
|
||||
*/
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { isPrefixPath, type SpecPath } from '@core/spec-insert';
|
||||
import type { DropAxis } from '@core/spec-restructure';
|
||||
import { ARRAY_COMPOSITIONS } from '@core/spec-transforms';
|
||||
import { viewTree, type Orientation, type ViewNode } from '@core/spec-view-tree';
|
||||
import { usePopover } from '../hooks/usePopover';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||
import { Icon } from './Icon';
|
||||
import { IconButton } from './IconButton';
|
||||
import { markIconName } from './mark-icon';
|
||||
import styles from './CompositionWireframe.module.css';
|
||||
|
||||
const POPOVER_ID = 'composition-wireframe';
|
||||
const INITIAL_FOCUS = ['[role="treeitem"]'] as const;
|
||||
/** Pointer travel (px) before a press becomes a drag rather than a click. */
|
||||
const DRAG_THRESHOLD = 4;
|
||||
/** The concat orientations a drop can descend into (layers/grids are opaque targets). */
|
||||
const DESCENDABLE: ReadonlySet<Orientation> = new Set<Orientation>(['horizontal', 'vertical']);
|
||||
|
||||
type Edge = 'left' | 'right' | 'top' | 'bottom';
|
||||
|
||||
/**
|
||||
* Fraction of a view's cross-axis size, at each end, that reads as "pair here": drop a
|
||||
* view onto another's far edge (a row view's top/bottom, a column view's left/right) to
|
||||
* stack the two in a perpendicular split. The central band stays reorder, so an ordinary
|
||||
* along-the-axis drag rearranges. (Shift pairs from the central band too.)
|
||||
*/
|
||||
const PAIR_BAND = 0.25;
|
||||
|
||||
/** A DOM-safe, unique key for a node from its path. */
|
||||
const keyOf = (path: SpecPath): string => (path.length ? path.join('|') : 'root');
|
||||
|
||||
// TODO: vocabulary diverges from the pair labels — pull-out names a vconcat slot "a row"
|
||||
// (spatial: a full-span band) and an hconcat slot "a column", while the pair-into labels and
|
||||
// the commit announcements use the container convention (hconcat = "row", vconcat = "column").
|
||||
// Both describe the same axis; unify or keep the gesture-specific framing (docs/ux-second-pass.md).
|
||||
/** The pull-out action label for the margin a drop landed in. */
|
||||
const pullLabel = (edge: Edge): string =>
|
||||
edge === 'top'
|
||||
? 'New row above'
|
||||
: edge === 'bottom'
|
||||
? 'New row below'
|
||||
: edge === 'left'
|
||||
? 'New column left'
|
||||
: 'New column right';
|
||||
|
||||
/** A readable path like `vconcat[1].hconcat[0]` for the caption. */
|
||||
function pathLabel(path: SpecPath): string {
|
||||
if (path.length === 0) return 'root';
|
||||
let out = '';
|
||||
for (const seg of path) out += typeof seg === 'number' ? `[${seg}]` : out ? `.${seg}` : seg;
|
||||
return out;
|
||||
}
|
||||
|
||||
const descriptor = (n: ViewNode): string =>
|
||||
n.kind === 'unit' ? (n.mark ? `${n.mark} view` : 'view') : `${n.op} · ${n.children.length} views`;
|
||||
|
||||
const ariaLabelOf = (n: ViewNode): string =>
|
||||
n.kind === 'unit'
|
||||
? n.mark
|
||||
? `${n.mark} view`
|
||||
: 'view'
|
||||
: `${n.op}, ${n.orientation}, ${n.children.length} views`;
|
||||
|
||||
/** Children layout class per orientation (layered is a row of marks in one frame). */
|
||||
const LAYOUT: Record<Orientation, string> = {
|
||||
horizontal: styles.row,
|
||||
vertical: styles.col,
|
||||
grid: styles.grid,
|
||||
layered: styles.layered,
|
||||
};
|
||||
|
||||
interface Flat {
|
||||
node: ViewNode;
|
||||
key: string;
|
||||
parentKey: string | null;
|
||||
/** The composition that holds this node, or null for the root. */
|
||||
parent: ViewNode | null;
|
||||
}
|
||||
|
||||
/** Pre-order flatten — drives roving keyboard nav (next/prev/parent/first-child). */
|
||||
function flatten(root: ViewNode): Flat[] {
|
||||
const out: Flat[] = [];
|
||||
const walk = (n: ViewNode, parentKey: string | null, parent: ViewNode | null) => {
|
||||
const key = keyOf(n.path);
|
||||
out.push({ node: n, key, parentKey, parent });
|
||||
for (const c of n.children) walk(c, key, n);
|
||||
};
|
||||
walk(root, null, null);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The nearest edge of rect `r` to the point — drives the drop axis and side. */
|
||||
function edgeOf(r: DOMRect, x: number, y: number): Edge {
|
||||
const fx = (x - r.left) / r.width;
|
||||
const fy = (y - r.top) / r.height;
|
||||
const dist: Record<Edge, number> = { left: fx, right: 1 - fx, top: fy, bottom: 1 - fy };
|
||||
return (Object.keys(dist) as Edge[]).reduce((a, b) => (dist[a] <= dist[b] ? a : b));
|
||||
}
|
||||
|
||||
/** The resolved drop the wireframe will commit, and how to draw it on the target. */
|
||||
interface DropResolution {
|
||||
targetKey: string;
|
||||
edge: Edge;
|
||||
mode: 'move' | 'wrap' | 'pull';
|
||||
/** A short human label for what the drop will do — shown in the drag chip. */
|
||||
label: string;
|
||||
commit:
|
||||
| { kind: 'move'; arrayPath: SpecPath; from: number; to: number }
|
||||
| {
|
||||
kind: 'wrap';
|
||||
targetPath: SpecPath;
|
||||
sourcePath: SpecPath;
|
||||
axis: DropAxis;
|
||||
side: 'before' | 'after';
|
||||
}
|
||||
| {
|
||||
kind: 'wrap-container';
|
||||
containerPath: SpecPath;
|
||||
sourcePath: SpecPath;
|
||||
axis: DropAxis;
|
||||
side: 'before' | 'after';
|
||||
};
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
sourceKey: string;
|
||||
sourceNode: ViewNode;
|
||||
resolution: DropResolution | null;
|
||||
/** Live pointer position, for the drag chip that follows the cursor. */
|
||||
pointer: { x: number; y: number };
|
||||
}
|
||||
|
||||
function WireframeTree({ tree }: { tree: ViewNode }) {
|
||||
const requestRevealView = useAppStore((s) => s.requestRevealView);
|
||||
const requestComposeMove = useAppStore((s) => s.requestComposeMove);
|
||||
const requestComposeWrap = useAppStore((s) => s.requestComposeWrap);
|
||||
const requestComposeWrapContainer = useAppStore((s) => s.requestComposeWrapContainer);
|
||||
const requestComposeSimplify = useAppStore((s) => s.requestComposeSimplify);
|
||||
// Restructure only on the editable draft — the published view is read-only.
|
||||
const editable = useSnippetStore((s) => s.editorView === 'draft' && s.activeSnippetId !== null);
|
||||
const flat = useMemo(() => flatten(tree), [tree]);
|
||||
const parentByKey = useMemo(() => new Map(flat.map((f) => [f.key, f.parent])), [flat]);
|
||||
const rootKey = keyOf(tree.path);
|
||||
|
||||
// Redundant single-child wrappers — a `{hconcat: [oneView]}` is just that view (a
|
||||
// layer/concat of one is the same). Flagged on the boxes and offered up for a one-click
|
||||
// Simplify. (facet/repeat hold one child by design, so they're never array-compositions.)
|
||||
const degenerateKeys = useMemo(() => {
|
||||
const keys = new Set<string>();
|
||||
for (const { node, key } of flat) {
|
||||
if (node.op && ARRAY_COMPOSITIONS.includes(node.op) && node.children.length === 1)
|
||||
keys.add(key);
|
||||
}
|
||||
return keys;
|
||||
}, [flat]);
|
||||
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [focusedKey, setFocusedKey] = useState<string | null>(null);
|
||||
const [hoverKey, setHoverKey] = useState<string | null>(null);
|
||||
const [drag, setDrag] = useState<DragState | null>(null);
|
||||
const [announcement, setAnnouncement] = useState('');
|
||||
// The box to focus once the tree rebuilds after a restructure (a drag leaves focus
|
||||
// on the body; keyboard needs focus to follow the view to its new place — APG).
|
||||
const [pendingFocusKey, setPendingFocusKey] = useState<string | null>(null);
|
||||
|
||||
const treeRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const justDraggedRef = useRef(false);
|
||||
const setDragState = (d: DragState | null) => {
|
||||
dragRef.current = d;
|
||||
setDrag(d);
|
||||
};
|
||||
|
||||
const effectiveFocus =
|
||||
(focusedKey && flat.some((f) => f.key === focusedKey) && focusedKey) || rootKey;
|
||||
|
||||
const select = useCallback(
|
||||
(n: ViewNode) => {
|
||||
setSelectedKey(keyOf(n.path));
|
||||
setFocusedKey(keyOf(n.path));
|
||||
requestRevealView(n.offset, n.length);
|
||||
},
|
||||
[requestRevealView],
|
||||
);
|
||||
|
||||
// Select, focus-after-rebuild, and announce the box at `key` once a restructure
|
||||
// has been requested (the tree rebuilds from the edited draft text).
|
||||
const settleOn = useCallback((key: string, message: string) => {
|
||||
setSelectedKey(key);
|
||||
setFocusedKey(key);
|
||||
setPendingFocusKey(key);
|
||||
setAnnouncement(message);
|
||||
}, []);
|
||||
|
||||
const reorder = useCallback(
|
||||
(arrayPath: SpecPath, from: number, to: number, node: ViewNode, count: number) => {
|
||||
requestComposeMove(arrayPath, from, to);
|
||||
settleOn(
|
||||
keyOf([...arrayPath, to]),
|
||||
`Moved ${descriptor(node)} to position ${to + 1} of ${count}`,
|
||||
);
|
||||
},
|
||||
[requestComposeMove, settleOn],
|
||||
);
|
||||
|
||||
// Restore focus to the affected box once it re-renders at its new position.
|
||||
useEffect(() => {
|
||||
if (!pendingFocusKey) return;
|
||||
const el = treeRef.current?.querySelector<HTMLElement>(`[data-key="${pendingFocusKey}"]`);
|
||||
if (el) {
|
||||
el.focus();
|
||||
setPendingFocusKey(null);
|
||||
}
|
||||
}, [flat, pendingFocusKey]);
|
||||
|
||||
// Rect of the box for `key`, read live from the DOM during a drag.
|
||||
const rectOf = useCallback((key: string): DOMRect | null => {
|
||||
const el = treeRef.current?.querySelector<HTMLElement>(`[data-key="${key}"]`);
|
||||
return el ? el.getBoundingClientRect() : null;
|
||||
}, []);
|
||||
|
||||
// The drop target: descend through concats (row/column) into the child under the
|
||||
// pointer; stop at a leaf or an opaque container (layer/facet/repeat/grid). The
|
||||
// dragged box is skipped so the pointer never targets it or its subtree.
|
||||
const dropTargetNode = useCallback(
|
||||
(x: number, y: number, sourceKey: string): ViewNode | null => {
|
||||
let node = tree;
|
||||
while (node.kind === 'composition' && node.orientation && DESCENDABLE.has(node.orientation)) {
|
||||
const child = node.children.find((c) => {
|
||||
if (keyOf(c.path) === sourceKey) return false;
|
||||
const r = rectOf(keyOf(c.path));
|
||||
return r ? x >= r.left && x <= r.right && y >= r.top && y <= r.bottom : false;
|
||||
});
|
||||
if (!child) break;
|
||||
node = child;
|
||||
}
|
||||
return keyOf(node.path) === sourceKey ? null : node;
|
||||
},
|
||||
[tree, rectOf],
|
||||
);
|
||||
|
||||
// TODO: the drag-math cluster (edgeOf + dropTargetNode + this zone classifier) is pure
|
||||
// decision logic — given pointer, child rects, orientation and paths it returns a commit or
|
||||
// null — but lives in the component, tested only through happy-dom rect stubs. Lift it into a
|
||||
// core function tested without a DOM when a second draggable surface appears (eng-council).
|
||||
const resolveDrop = useCallback(
|
||||
(x: number, y: number, source: ViewNode, pair: boolean): DropResolution | null => {
|
||||
const sourcePath = source.path;
|
||||
const target = dropTargetNode(x, y, keyOf(sourcePath));
|
||||
if (!target) return null;
|
||||
|
||||
// The row/column container we're acting within, and the child under the pointer
|
||||
// (null when the pointer fell in the container's margin or an inter-child gap).
|
||||
const targetIsRowCol =
|
||||
target.kind === 'composition' &&
|
||||
!!target.orientation &&
|
||||
DESCENDABLE.has(target.orientation);
|
||||
const container = targetIsRowCol ? target : (parentByKey.get(keyOf(target.path)) ?? null);
|
||||
const childUnder = targetIsRowCol ? null : target;
|
||||
|
||||
// Inside a row/column, intent is read against the children's bounding box: reorder
|
||||
// is the default for the interior central band (so a drag along the block just
|
||||
// rearranges it); a pull-out needs the pointer past the children on the *cross* axis
|
||||
// (the frame margin or beyond the block); dropping onto a view's *far cross edge*
|
||||
// pairs the two into a nested split. (Opaque layer/grid contexts keep the
|
||||
// nearest-edge model further down.)
|
||||
if (container?.orientation && DESCENDABLE.has(container.orientation) && container.op) {
|
||||
const horizontal = container.orientation === 'horizontal';
|
||||
const arrayPath: SpecPath = [...container.path, container.op];
|
||||
const kids = container.children
|
||||
.map((c) => ({ node: c, rect: rectOf(keyOf(c.path)) }))
|
||||
.filter((k): k is { node: ViewNode; rect: DOMRect } => k.rect != null);
|
||||
if (!kids.length) return null;
|
||||
const bbox = {
|
||||
left: Math.min(...kids.map((k) => k.rect.left)),
|
||||
top: Math.min(...kids.map((k) => k.rect.top)),
|
||||
right: Math.max(...kids.map((k) => k.rect.right)),
|
||||
bottom: Math.max(...kids.map((k) => k.rect.bottom)),
|
||||
};
|
||||
|
||||
// 1) Pull-out — pointer past the children on the cross axis. Pulling a node into
|
||||
// its own descendant is the one degenerate case.
|
||||
const before = horizontal ? y < bbox.top : x < bbox.left;
|
||||
const after = horizontal ? y > bbox.bottom : x > bbox.right;
|
||||
if (before || after) {
|
||||
if (isPrefixPath(sourcePath, container.path)) return null;
|
||||
const edge: Edge = horizontal ? (before ? 'top' : 'bottom') : before ? 'left' : 'right';
|
||||
return {
|
||||
targetKey: keyOf(container.path),
|
||||
edge,
|
||||
mode: 'pull',
|
||||
label: pullLabel(edge),
|
||||
commit: {
|
||||
kind: 'wrap-container',
|
||||
containerPath: container.path,
|
||||
sourcePath,
|
||||
axis: horizontal ? 'vertical' : 'horizontal',
|
||||
side: before ? 'before' : 'after',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 2) Pair — drop onto a view's far cross edge (a row view's top/bottom, a column
|
||||
// view's left/right) to nest the two in a perpendicular split; Shift pairs from
|
||||
// the central band too. The cross half picks the order.
|
||||
if (childUnder) {
|
||||
const cr = rectOf(keyOf(childUnder.path));
|
||||
const related =
|
||||
isPrefixPath(sourcePath, childUnder.path) || isPrefixPath(childUnder.path, sourcePath);
|
||||
if (cr && !related) {
|
||||
const cf = horizontal ? (y - cr.top) / cr.height : (x - cr.left) / cr.width;
|
||||
if (pair || cf < PAIR_BAND || cf > 1 - PAIR_BAND) {
|
||||
const lead = cf < 0.5;
|
||||
const axis: DropAxis = horizontal ? 'vertical' : 'horizontal';
|
||||
const edge: Edge = horizontal ? (lead ? 'top' : 'bottom') : lead ? 'left' : 'right';
|
||||
return {
|
||||
targetKey: keyOf(childUnder.path),
|
||||
edge,
|
||||
mode: 'wrap',
|
||||
label: `Pair into a ${axis === 'horizontal' ? 'row' : 'column'}`,
|
||||
commit: {
|
||||
kind: 'wrap',
|
||||
targetPath: childUnder.path,
|
||||
sourcePath,
|
||||
axis,
|
||||
side: lead ? 'before' : 'after',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Reorder (default) — the insertion slot from the pointer's main-axis position,
|
||||
// anchored on the child whose edge marks the gap.
|
||||
const mainPos = horizontal ? x : y;
|
||||
let index = 0;
|
||||
for (const k of kids) {
|
||||
const center = horizontal
|
||||
? (k.rect.left + k.rect.right) / 2
|
||||
: (k.rect.top + k.rect.bottom) / 2;
|
||||
if (mainPos > center) index += 1;
|
||||
}
|
||||
const atEnd = index >= kids.length;
|
||||
const anchor = atEnd ? kids[kids.length - 1].node : kids[index].node;
|
||||
const edge: Edge = horizontal ? (atEnd ? 'right' : 'left') : atEnd ? 'bottom' : 'top';
|
||||
|
||||
if (keyOf(sourcePath.slice(0, -1)) === keyOf(arrayPath)) {
|
||||
const from = sourcePath[sourcePath.length - 1] as number;
|
||||
let to = index > from ? index - 1 : index;
|
||||
to = Math.max(0, Math.min(to, container.children.length - 1));
|
||||
if (to === from) return null; // no-op
|
||||
return {
|
||||
targetKey: keyOf(anchor.path),
|
||||
edge,
|
||||
mode: 'move',
|
||||
label: 'Reorder',
|
||||
commit: { kind: 'move', arrayPath, from, to },
|
||||
};
|
||||
}
|
||||
// From another container → insert here, as a with-axis wrap that flattens in.
|
||||
if (isPrefixPath(sourcePath, anchor.path) || isPrefixPath(anchor.path, sourcePath))
|
||||
return null;
|
||||
return {
|
||||
targetKey: keyOf(anchor.path),
|
||||
edge,
|
||||
mode: 'move',
|
||||
label: 'Move here',
|
||||
commit: {
|
||||
kind: 'wrap',
|
||||
targetPath: anchor.path,
|
||||
sourcePath,
|
||||
axis: horizontal ? 'horizontal' : 'vertical',
|
||||
side: atEnd ? 'after' : 'before',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Opaque container (layer/grid) or root: the simpler nearest-edge model.
|
||||
const targetPath = target.path;
|
||||
if (targetPath.length === 0) return null;
|
||||
if (isPrefixPath(sourcePath, targetPath) || isPrefixPath(targetPath, sourcePath)) return null;
|
||||
const r = rectOf(keyOf(targetPath));
|
||||
if (!r) return null;
|
||||
const edge = edgeOf(r, x, y);
|
||||
const axis: DropAxis = edge === 'left' || edge === 'right' ? 'horizontal' : 'vertical';
|
||||
const side: 'before' | 'after' = edge === 'left' || edge === 'top' ? 'before' : 'after';
|
||||
const parent = parentByKey.get(keyOf(targetPath));
|
||||
const tIdx = targetPath[targetPath.length - 1];
|
||||
const along =
|
||||
(axis === 'horizontal' && parent?.orientation === 'horizontal') ||
|
||||
(axis === 'vertical' && parent?.orientation === 'vertical');
|
||||
if (along && parent && typeof tIdx === 'number') {
|
||||
const arrayPath = targetPath.slice(0, -1);
|
||||
if (keyOf(sourcePath.slice(0, -1)) === keyOf(arrayPath)) {
|
||||
const from = sourcePath[sourcePath.length - 1] as number;
|
||||
const gap = side === 'before' ? tIdx : tIdx + 1;
|
||||
let to = gap > from ? gap - 1 : gap;
|
||||
to = Math.max(0, Math.min(to, parent.children.length - 1));
|
||||
if (to === from) return null; // no-op
|
||||
return {
|
||||
targetKey: keyOf(targetPath),
|
||||
edge,
|
||||
mode: 'move',
|
||||
label: 'Reorder',
|
||||
commit: { kind: 'move', arrayPath, from, to },
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
targetKey: keyOf(targetPath),
|
||||
edge,
|
||||
mode: 'wrap',
|
||||
label: along ? 'Move here' : `Pair into a ${axis === 'horizontal' ? 'row' : 'column'}`,
|
||||
commit: { kind: 'wrap', targetPath, sourcePath, axis, side },
|
||||
};
|
||||
},
|
||||
[dropTargetNode, parentByKey, rectOf],
|
||||
);
|
||||
|
||||
const commitDrop = useCallback(
|
||||
(res: DropResolution, source: ViewNode) => {
|
||||
if (res.commit.kind === 'move') {
|
||||
const { arrayPath, from, to } = res.commit;
|
||||
const count = parentByKey.get(res.targetKey)?.children.length ?? 0;
|
||||
reorder(arrayPath, from, to, source, count);
|
||||
} else if (res.commit.kind === 'wrap-container') {
|
||||
const { containerPath, sourcePath, axis, side } = res.commit;
|
||||
requestComposeWrapContainer(containerPath, sourcePath, axis, side);
|
||||
// Focus/selection lands on the container's slot — now the new split holding it.
|
||||
settleOn(
|
||||
keyOf(containerPath),
|
||||
`Pulled ${descriptor(source)} into a new ${axis === 'vertical' ? 'row' : 'column'}`,
|
||||
);
|
||||
} else {
|
||||
const { targetPath, sourcePath, axis, side } = res.commit;
|
||||
requestComposeWrap(targetPath, sourcePath, axis, side);
|
||||
// Focus/selection lands on the new split at the target's slot. When the drop
|
||||
// flattens to a plain insert (a with-axis cross-container drop), the target's
|
||||
// path shifts, so this key no longer resolves and the focus-restore no-ops — a
|
||||
// tolerated gap while cross-container wrap is pointer-only (focus matters for the
|
||||
// keyboard path, which is in-container `Alt+↑/↓` reorder, where the key is exact).
|
||||
settleOn(
|
||||
keyOf(targetPath),
|
||||
`Paired ${descriptor(source)} into a ${axis === 'horizontal' ? 'row' : 'column'}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
[parentByKey, reorder, requestComposeWrap, requestComposeWrapContainer, settleOn],
|
||||
);
|
||||
|
||||
const beginDrag = (e: ReactPointerEvent<HTMLDivElement>, source: ViewNode) => {
|
||||
if (e.button !== 0 || !editable) return;
|
||||
// Only the innermost view under the pointer starts the drag: every nested frame is
|
||||
// itself draggable, and without this the pointerdown bubbles to each ancestor, whose
|
||||
// beginDrag runs *after* (bubble order) and overwrites the source — so grabbing a
|
||||
// child would drag its outer block instead.
|
||||
e.stopPropagation();
|
||||
const startX = e.clientX;
|
||||
const startY = e.clientY;
|
||||
let started = false;
|
||||
// The last pointer + Shift state, so a Shift press/release re-resolves in place
|
||||
// (Shift forces a pair when the pointer is over a view's central band).
|
||||
let last = { x: startX, y: startY, pair: e.shiftKey };
|
||||
|
||||
const resolveAt = () => {
|
||||
setDragState({
|
||||
sourceKey: keyOf(source.path),
|
||||
sourceNode: source,
|
||||
resolution: resolveDrop(last.x, last.y, source, last.pair),
|
||||
pointer: { x: last.x, y: last.y },
|
||||
});
|
||||
};
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
if (!started) {
|
||||
if (Math.hypot(ev.clientX - startX, ev.clientY - startY) < DRAG_THRESHOLD) return;
|
||||
started = true;
|
||||
document.body.style.cursor = 'grabbing';
|
||||
document.body.style.userSelect = 'none';
|
||||
}
|
||||
last = { x: ev.clientX, y: ev.clientY, pair: ev.shiftKey };
|
||||
resolveAt();
|
||||
};
|
||||
const onShift = (ev: WindowEventMap['keydown']) => {
|
||||
if (started && ev.key === 'Shift') {
|
||||
last = { ...last, pair: ev.type === 'keydown' };
|
||||
resolveAt();
|
||||
}
|
||||
};
|
||||
const cleanup = () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
window.removeEventListener('pointercancel', onCancel);
|
||||
window.removeEventListener('keydown', onShift);
|
||||
window.removeEventListener('keyup', onShift);
|
||||
document.body.style.cursor = '';
|
||||
document.body.style.userSelect = '';
|
||||
};
|
||||
const onUp = () => {
|
||||
const d = dragRef.current;
|
||||
cleanup();
|
||||
setDragState(null);
|
||||
if (started) {
|
||||
justDraggedRef.current = true; // swallow the click that follows a real drag
|
||||
if (d?.resolution) commitDrop(d.resolution, d.sourceNode);
|
||||
}
|
||||
};
|
||||
// A touch the browser reclaims for scrolling fires pointercancel — abort cleanly.
|
||||
const onCancel = () => {
|
||||
cleanup();
|
||||
setDragState(null);
|
||||
};
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
window.addEventListener('pointercancel', onCancel);
|
||||
window.addEventListener('keydown', onShift);
|
||||
window.addEventListener('keyup', onShift);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
const i = flat.findIndex((f) => f.key === effectiveFocus);
|
||||
if (i < 0) return;
|
||||
|
||||
// Alt+↑/↓ reorders the focused view among its siblings (APG rearrangeable list).
|
||||
if (e.altKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) {
|
||||
e.preventDefault();
|
||||
const f = flat[i];
|
||||
const idx = f.node.path.at(-1);
|
||||
if (!editable || !f.parent || typeof idx !== 'number') return;
|
||||
const count = f.parent.children.length;
|
||||
const to = e.key === 'ArrowUp' ? idx - 1 : idx + 1;
|
||||
if (to < 0 || to >= count) {
|
||||
setAnnouncement(e.key === 'ArrowUp' ? 'Already at the start' : 'Already at the end');
|
||||
return;
|
||||
}
|
||||
reorder(f.node.path.slice(0, -1), idx, to, f.node, count);
|
||||
return;
|
||||
}
|
||||
|
||||
const moveTo = (j: number) => {
|
||||
const target = flat[j];
|
||||
if (!target) return;
|
||||
e.preventDefault();
|
||||
setFocusedKey(target.key);
|
||||
treeRef.current?.querySelector<HTMLElement>(`[data-key="${target.key}"]`)?.focus();
|
||||
};
|
||||
switch (e.key) {
|
||||
case 'ArrowDown':
|
||||
return moveTo(i + 1);
|
||||
case 'ArrowUp':
|
||||
return moveTo(i - 1);
|
||||
case 'Home':
|
||||
return moveTo(0);
|
||||
case 'End':
|
||||
return moveTo(flat.length - 1);
|
||||
case 'ArrowRight': // first child is the next node in pre-order
|
||||
return flat[i].node.children.length ? moveTo(i + 1) : undefined;
|
||||
case 'ArrowLeft': {
|
||||
const pk = flat[i].parentKey;
|
||||
return pk ? moveTo(flat.findIndex((f) => f.key === pk)) : undefined;
|
||||
}
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
return select(flat[i].node);
|
||||
}
|
||||
};
|
||||
|
||||
const renderNode = (n: ViewNode, parent: ViewNode | null): ReactNode => {
|
||||
const key = keyOf(n.path);
|
||||
const container = n.kind === 'composition';
|
||||
const generated = n.op === 'facet' || n.op === 'repeat';
|
||||
const idx = n.path.at(-1);
|
||||
const draggable = editable && parent != null && typeof idx === 'number';
|
||||
const onTarget = drag?.resolution?.targetKey === key ? drag.resolution : null;
|
||||
// A unit view inside a layer is a bare mark glyph in the layer's shared frame,
|
||||
// not its own box — a layer is one plotting space with several marks stacked.
|
||||
const compactMark = parent?.op === 'layer' && !container;
|
||||
|
||||
const cls = [
|
||||
styles.node,
|
||||
compactMark ? styles.layerMark : container ? styles.container : styles.leaf,
|
||||
n.op === 'layer' ? styles.layerBox : '',
|
||||
key === selectedKey ? styles.selected : '',
|
||||
generated ? styles.generated : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
data-key={key}
|
||||
role="treeitem"
|
||||
aria-label={ariaLabelOf(n)}
|
||||
aria-selected={key === selectedKey}
|
||||
aria-expanded={container ? true : undefined}
|
||||
aria-keyshortcuts={draggable ? 'Alt+ArrowUp Alt+ArrowDown' : undefined}
|
||||
tabIndex={key === effectiveFocus ? 0 : -1}
|
||||
className={cls}
|
||||
data-draggable={draggable || undefined}
|
||||
data-dragging={drag?.sourceKey === key || undefined}
|
||||
data-orientation={n.orientation}
|
||||
data-degenerate={degenerateKeys.has(key) || undefined}
|
||||
data-drop-edge={onTarget && onTarget.mode !== 'pull' ? onTarget.edge : undefined}
|
||||
data-drop-mode={onTarget && onTarget.mode !== 'pull' ? onTarget.mode : undefined}
|
||||
data-drop-pull={onTarget?.mode === 'pull' ? onTarget.edge : undefined}
|
||||
onPointerDown={draggable ? (e) => beginDrag(e, n) : undefined}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (justDraggedRef.current) {
|
||||
justDraggedRef.current = false;
|
||||
return;
|
||||
}
|
||||
select(n);
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.stopPropagation();
|
||||
setHoverKey(key);
|
||||
}}
|
||||
onMouseLeave={() => setHoverKey(null)}
|
||||
onFocus={(e) => {
|
||||
e.stopPropagation();
|
||||
setFocusedKey(key);
|
||||
}}
|
||||
>
|
||||
{container ? (
|
||||
<>
|
||||
{n.op === 'layer' && <Icon name="layers" className={styles.layerBadge} />}
|
||||
<div
|
||||
role="group"
|
||||
data-orientation={n.orientation}
|
||||
className={`${styles.children} ${n.orientation ? LAYOUT[n.orientation] : ''}`}
|
||||
>
|
||||
{n.children.map((c) => renderNode(c, n))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<Icon name={markIconName(n.mark)} size="md" className={styles.markIcon} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const captionNode =
|
||||
(hoverKey ?? selectedKey) ? flat.find((f) => f.key === (hoverKey ?? selectedKey))?.node : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={treeRef}
|
||||
role="tree"
|
||||
aria-label="View composition"
|
||||
className={styles.tree}
|
||||
data-drag-active={drag ? '' : undefined}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{renderNode(tree, null)}
|
||||
</div>
|
||||
{editable && degenerateKeys.size > 0 && (
|
||||
<div className={styles.warning}>
|
||||
<span>
|
||||
{degenerateKeys.size === 1
|
||||
? 'A single-view wrapper adds no structure.'
|
||||
: `${degenerateKeys.size} single-view wrappers add no structure.`}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.simplify}
|
||||
onClick={() => requestComposeSimplify()}
|
||||
>
|
||||
Simplify
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className={styles.caption} aria-hidden="true">
|
||||
{captionNode ? (
|
||||
<>
|
||||
<code>{pathLabel(captionNode.path)}</code> — {descriptor(captionNode)}
|
||||
</>
|
||||
) : (
|
||||
<span className={styles.muted}>
|
||||
{editable
|
||||
? 'Drag to reorder · onto a view’s edge to stack · into a frame’s margin to pull out · click to reveal'
|
||||
: 'Hover a block to identify it · click to reveal it'}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<div className="visually-hidden" role="status" aria-live="polite">
|
||||
{announcement}
|
||||
</div>
|
||||
{/* A chip following the cursor names what the drop will do — the live "what
|
||||
happens" hint (pointer-only affordance, so aria-hidden; SR users get the
|
||||
commit announcement above). Portaled out of the clipped popover. */}
|
||||
{drag &&
|
||||
createPortal(
|
||||
<div
|
||||
className={styles.dragChip}
|
||||
data-empty={drag.resolution ? undefined : ''}
|
||||
style={{ left: drag.pointer.x + 14, top: drag.pointer.y + 16 }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{drag.resolution?.label ?? 'No change here'}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function CompositionWireframe() {
|
||||
const { open, toggle, close, triggerRef, setPopNode } = usePopover({
|
||||
id: POPOVER_ID,
|
||||
align: 'right',
|
||||
flip: true,
|
||||
initialFocus: INITIAL_FOCUS,
|
||||
});
|
||||
const shownText = useSnippetStore(selectShownText);
|
||||
const tree = useMemo(() => viewTree(shownText), [shownText]);
|
||||
const hasComposition = !!tree && tree.kind === 'composition';
|
||||
|
||||
// The wireframe is meaningless for a single-view spec — hide the affordance, and
|
||||
// close it if it was open when the composition is unwrapped away.
|
||||
useEffect(() => {
|
||||
if (!hasComposition && open) close();
|
||||
}, [hasComposition, open, close]);
|
||||
|
||||
if (!tree || tree.kind !== 'composition') return null;
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
label="View composition structure"
|
||||
aria-expanded={open}
|
||||
aria-controls={POPOVER_ID}
|
||||
onClick={toggle}
|
||||
>
|
||||
<Icon name="structure" />
|
||||
</IconButton>
|
||||
{open &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={setPopNode}
|
||||
id={POPOVER_ID}
|
||||
className={styles.pop}
|
||||
role="group"
|
||||
aria-label="Composition structure"
|
||||
>
|
||||
<h4 className={styles.title}>Structure</h4>
|
||||
<WireframeTree tree={tree} />
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -32,12 +32,38 @@ afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const toggle = () => container.querySelector<HTMLButtonElement>('button[aria-expanded]')!;
|
||||
const toggle = () =>
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>('button[aria-expanded]')).find((b) =>
|
||||
b.textContent?.includes('Data'),
|
||||
)!;
|
||||
const text = () => container.textContent ?? '';
|
||||
const viewButton = (label: string) =>
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>('[role="radio"]')).find(
|
||||
(b) => b.textContent === label,
|
||||
)!;
|
||||
/** The view picker's trigger, or null when it is not shown (single-table case). */
|
||||
const picker = () => container.querySelector<HTMLButtonElement>('[aria-label^="Inspected view"]');
|
||||
|
||||
/** Build the inspector payload — one table per (label, input, resolved) entry. */
|
||||
const tablesOf = (
|
||||
...entries: Array<{
|
||||
label: string;
|
||||
input?: Record<string, unknown>[];
|
||||
resolved: Record<string, unknown>[];
|
||||
}>
|
||||
): InspectedData => ({
|
||||
tables: entries.map((e, i) => ({
|
||||
id: `t${i}`,
|
||||
label: e.label,
|
||||
input: e.input ?? [],
|
||||
resolved: e.resolved,
|
||||
})),
|
||||
});
|
||||
/** The common single-table payload. */
|
||||
const oneTable = (
|
||||
input: Record<string, unknown>[],
|
||||
resolved: Record<string, unknown>[],
|
||||
): InspectedData => tablesOf({ label: 'View 1', input, resolved });
|
||||
|
||||
const render = (props: Partial<Parameters<typeof DataInspectorPanel>[0]> = {}) =>
|
||||
act(() => {
|
||||
@@ -68,26 +94,33 @@ describe('DataInspectorPanel', () => {
|
||||
expect(container.querySelector('table')).toBeNull();
|
||||
});
|
||||
|
||||
test('open with a chart that draws nothing inspectable: says so', () => {
|
||||
render({ getData: () => ({ tables: [] }) });
|
||||
expect(text()).toContain('no inspectable data');
|
||||
expect(container.querySelector('table')).toBeNull();
|
||||
});
|
||||
|
||||
test('defaults to the Resolved view and renders its rows', () => {
|
||||
const data: InspectedData = {
|
||||
input: [{ region: 'West', sales: '1204' }],
|
||||
resolved: [{ region: 'West', total: 1204 }],
|
||||
};
|
||||
render({ getData: () => data });
|
||||
render({
|
||||
getData: () =>
|
||||
oneTable([{ region: 'West', sales: '1204' }], [{ region: 'West', total: 1204 }]),
|
||||
});
|
||||
// Resolved is the default — its column ("total"), not the input's ("sales").
|
||||
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
|
||||
expect(headers).toEqual(['region', 'total']);
|
||||
});
|
||||
|
||||
test('switching to Input shows the source rows', () => {
|
||||
const data: InspectedData = {
|
||||
input: [
|
||||
{ region: 'West', sales: '1204' },
|
||||
{ region: 'East', sales: '980' },
|
||||
],
|
||||
resolved: [{ region: 'West', total: 1204 }],
|
||||
};
|
||||
render({ getData: () => data });
|
||||
render({
|
||||
getData: () =>
|
||||
oneTable(
|
||||
[
|
||||
{ region: 'West', sales: '1204' },
|
||||
{ region: 'East', sales: '980' },
|
||||
],
|
||||
[{ region: 'West', total: 1204 }],
|
||||
),
|
||||
});
|
||||
act(() => viewButton('Input').click());
|
||||
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
|
||||
expect(headers).toEqual(['region', 'sales']);
|
||||
@@ -96,25 +129,25 @@ describe('DataInspectorPanel', () => {
|
||||
});
|
||||
|
||||
test('resolved empty: names the empty-transform signal', () => {
|
||||
render({ getData: () => ({ input: [{ a: 1 }], resolved: [] }) });
|
||||
render({ getData: () => oneTable([{ a: 1 }], []) });
|
||||
expect(text()).toContain('left nothing to draw');
|
||||
});
|
||||
|
||||
test('input empty: names the empty source', () => {
|
||||
render({ getData: () => ({ input: [], resolved: [] }) });
|
||||
render({ getData: () => oneTable([], []) });
|
||||
act(() => viewButton('Input').click());
|
||||
expect(text()).toContain('source data has no rows');
|
||||
});
|
||||
|
||||
test('caps the table and reports the total', () => {
|
||||
const resolved = Array.from({ length: 120 }, (_, i) => ({ i }));
|
||||
render({ getData: () => ({ input: [], resolved }) });
|
||||
render({ getData: () => oneTable([], resolved) });
|
||||
expect(container.querySelectorAll('tbody tr')).toHaveLength(50);
|
||||
expect(text()).toContain('first 50 of 120');
|
||||
});
|
||||
|
||||
test('re-reads getData when renderEpoch changes', () => {
|
||||
const getData = vi.fn((): InspectedData => ({ input: [], resolved: [{ a: 1 }] }));
|
||||
const getData = vi.fn(() => oneTable([], [{ a: 1 }]));
|
||||
render({ getData, renderEpoch: 0 });
|
||||
const before = getData.mock.calls.length;
|
||||
render({ getData, renderEpoch: 1 });
|
||||
@@ -122,7 +155,7 @@ describe('DataInspectorPanel', () => {
|
||||
});
|
||||
|
||||
test('applies an explicit height when given (resizable mode)', () => {
|
||||
render({ getData: () => ({ input: [], resolved: [{ a: 1 }] }), heightPx: 240 });
|
||||
render({ getData: () => oneTable([], [{ a: 1 }]), heightPx: 240 });
|
||||
const panel = container.firstElementChild as HTMLElement;
|
||||
expect(panel.style.height).toBe('240px');
|
||||
});
|
||||
@@ -133,6 +166,44 @@ describe('DataInspectorPanel', () => {
|
||||
act(() => toggle().click());
|
||||
expect(onToggle).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
// ── Multi-view selector ──────────────────────────────────────────────────────
|
||||
|
||||
test('a single drawn table shows no view picker', () => {
|
||||
render({ getData: () => oneTable([], [{ a: 1 }]) });
|
||||
expect(picker()).toBeNull();
|
||||
});
|
||||
|
||||
test('multiple drawn tables show a picker, defaulting to the first table', () => {
|
||||
render({
|
||||
getData: () =>
|
||||
tablesOf(
|
||||
{ label: 'sales', resolved: [{ region: 'W', revenue: 1 }] },
|
||||
{ label: 'regions', resolved: [{ region: 'W', population: 2 }] },
|
||||
),
|
||||
});
|
||||
// Picker present and on the first table; the grid shows that table's columns.
|
||||
expect(picker()?.getAttribute('aria-label')).toBe('Inspected view: sales');
|
||||
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
|
||||
expect(headers).toEqual(['region', 'revenue']);
|
||||
});
|
||||
|
||||
test('choosing another table switches the inspected data', () => {
|
||||
render({
|
||||
getData: () =>
|
||||
tablesOf(
|
||||
{ label: 'sales', resolved: [{ region: 'W', revenue: 1 }] },
|
||||
{ label: 'regions', resolved: [{ region: 'W', population: 2 }] },
|
||||
),
|
||||
});
|
||||
act(() => picker()!.click()); // open the portaled popover
|
||||
const option = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((b) =>
|
||||
b.textContent?.includes('regions'),
|
||||
)!;
|
||||
act(() => option.click());
|
||||
const headers = Array.from(container.querySelectorAll('th')).map((th) => th.textContent);
|
||||
expect(headers).toEqual(['region', 'population']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DataInspector', () => {
|
||||
|
||||
@@ -1,46 +1,67 @@
|
||||
/**
|
||||
* Data inspector — input vs. resolved rows (spec §04).
|
||||
* Data inspector — input vs. resolved rows, per drawn table (spec §04).
|
||||
*
|
||||
* Shows the chart's data with a toggle between two views of the same rendered
|
||||
* view: **Input** (the parsed source rows, before the spec's transforms) and
|
||||
* **Resolved** (the rows the chart draws, after filters / calculated fields /
|
||||
* aggregation). Seeing input → output side by side is how you answer "why is my
|
||||
* chart empty/wrong" — look at what the transforms did to the data. Both tables
|
||||
* come from the live Vega view via the renderer's `RenderHandle.inspectData()`
|
||||
* accessor, passed in as `getData` so this component never touches the view (the
|
||||
* embedding boundary, arch 05).
|
||||
* Shows the chart's data with a toggle between two ends of a table's pipeline:
|
||||
* **Input** (the parsed source rows, before the view's transforms) and
|
||||
* **Resolved** (the rows the marks draw, after filters / calculated fields /
|
||||
* aggregation). Seeing input → output is how you answer "why is my chart
|
||||
* empty/wrong" — look at what the transforms did to the data.
|
||||
*
|
||||
* A composed spec (layer/concat/facet/repeat) draws several tables, so a **view
|
||||
* picker** (`SelectControl`) lets the user choose which one to inspect — "what data
|
||||
* am I actually visualizing?". The picker is hidden for the common single-table
|
||||
* case (council: NN/g #8 — no one-option control; docs/architecture/10). Labels
|
||||
* never show Vega's compiler names (`source_0`/`data_2`), only a user-authored
|
||||
* dataset name or an ordinal "View N" plus a columns·rows recognition cue
|
||||
* (`@core/inspect-views`; NN/g #2/#6).
|
||||
*
|
||||
* Tables come from the live Vega view via `RenderHandle.inspectData()`, passed in as
|
||||
* `getData` so this component never touches the view (the embedding boundary, arch
|
||||
* 05). Read lazily — only while expanded — so a collapsed inspector costs nothing.
|
||||
*
|
||||
* `DataInspectorPanel` is the reusable shape (an APG disclosure, mirroring the
|
||||
* builder's source-rows preview); `DataInspector` binds it to the persisted
|
||||
* preview-pane open state for the live-preview pane. The data is read lazily —
|
||||
* only while expanded — because listing the view's datasets serializes them (see
|
||||
* `RenderHandle.inspectData`), so a collapsed inspector costs nothing.
|
||||
* preview-pane open state for the live-preview pane.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { InspectedData } from '../services/chart-renderer';
|
||||
import type { InspectableTable, InspectedData } from '../services/chart-renderer';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { DataTable } from './DataTable';
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||
import { SelectControl, type SelectControlOption } from './SelectControl';
|
||||
import styles from './DataInspector.module.css';
|
||||
|
||||
/** Rows shown before truncating — matches the builder's source-preview cap (spec §06). */
|
||||
const ROW_LIMIT = 50;
|
||||
/** Columns named in a table's picker `detail` before eliding the rest. */
|
||||
const DETAIL_COLUMNS = 4;
|
||||
|
||||
type DataView = 'input' | 'resolved';
|
||||
|
||||
/** The two views, in input → output order (the natural reading direction). */
|
||||
/** The two stages, in input → output order (the natural reading direction). */
|
||||
const VIEW_OPTIONS: ReadonlyArray<SegmentedOption<DataView>> = [
|
||||
// `title` doubles as the accessible name (WCAG 2.5.3 label-in-name; leads with
|
||||
// the visible label).
|
||||
{ value: 'input', label: 'Input', title: 'Input — the source rows before the spec’s transforms' },
|
||||
{ value: 'input', label: 'Input', title: 'Input — the source rows before the view’s transforms' },
|
||||
{
|
||||
value: 'resolved',
|
||||
label: 'Resolved',
|
||||
title: 'Resolved — the rows the chart draws, after its transforms',
|
||||
title: 'Resolved — the rows the marks draw, after the view’s transforms',
|
||||
},
|
||||
];
|
||||
|
||||
/** A recognition cue for the view picker: row count + the first few columns. */
|
||||
function tableDetail(table: InspectableTable): string {
|
||||
const rows = table.resolved;
|
||||
const count = `${rows.length.toLocaleString()} ${rows.length === 1 ? 'row' : 'rows'}`;
|
||||
const columns = rows[0] ? Object.keys(rows[0]) : [];
|
||||
if (columns.length === 0) return count;
|
||||
const shown = columns.slice(0, DETAIL_COLUMNS).join(', ');
|
||||
const more = columns.length > DETAIL_COLUMNS ? `, +${columns.length - DETAIL_COLUMNS}` : '';
|
||||
return `${count} · ${shown}${more}`;
|
||||
}
|
||||
|
||||
interface DataInspectorPanelProps {
|
||||
/** Whether the panel is expanded. */
|
||||
open: boolean;
|
||||
@@ -52,7 +73,12 @@ interface DataInspectorPanelProps {
|
||||
* `renderEpoch`).
|
||||
*/
|
||||
getData: () => InspectedData | null;
|
||||
/** Bumps whenever a render settles, so the open table re-reads the new rows. */
|
||||
/**
|
||||
* Refresh trigger: bumps whenever the data to show may have changed, so the open
|
||||
* table re-reads. The live-preview pane bumps it on each settled render *and* on
|
||||
* an interactive selection that changes the inspected rows (live mode, M5); the
|
||||
* builder bumps it on render only.
|
||||
*/
|
||||
renderEpoch: number;
|
||||
/**
|
||||
* Explicit panel height (px) — the live-preview pane sets this from its
|
||||
@@ -83,13 +109,20 @@ export function DataInspectorPanel({
|
||||
id,
|
||||
}: DataInspectorPanelProps) {
|
||||
const [view, setView] = useState<DataView>('resolved');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
|
||||
// Read both tables only while open; re-read when a render settles. `renderEpoch`
|
||||
// is an intentional refresh trigger — not read in the body (getData is stable and
|
||||
// always reads the latest view), so exhaustive-deps sees it as unnecessary.
|
||||
// Read tables only while open; re-read when a render settles. `renderEpoch` is an
|
||||
// intentional refresh trigger — not read in the body (getData is stable and always
|
||||
// reads the latest view), so exhaustive-deps sees it as unnecessary.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const data = useMemo(() => (open ? getData() : null), [open, renderEpoch, getData]);
|
||||
const rows = data === null ? null : data[view];
|
||||
const tables = data?.tables ?? null;
|
||||
// The chosen table, falling back to the first when the selection is stale (the
|
||||
// spec changed under it) or unset — so a re-render never lands on a missing table.
|
||||
const table = tables?.find((t) => t.id === selectedId) ?? tables?.[0] ?? null;
|
||||
const rows = table ? table[view] : null;
|
||||
|
||||
const pickerId = `${id ?? 'preview'}-view-picker`;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -111,8 +144,21 @@ export function DataInspectorPanel({
|
||||
</button>
|
||||
{open && (
|
||||
<>
|
||||
{tables && tables.length > 1 && table && (
|
||||
<SelectControl
|
||||
id={pickerId}
|
||||
label="Inspected view"
|
||||
value={table.id}
|
||||
onSelect={setSelectedId}
|
||||
options={tables.map<SelectControlOption<string>>((t) => ({
|
||||
value: t.id,
|
||||
label: t.label,
|
||||
detail: tableDetail(t),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
<SegmentedControl
|
||||
label="Data view"
|
||||
label="Pipeline stage"
|
||||
options={VIEW_OPTIONS}
|
||||
value={view}
|
||||
onChange={setView}
|
||||
@@ -127,12 +173,14 @@ export function DataInspectorPanel({
|
||||
</div>
|
||||
|
||||
{open &&
|
||||
(rows === null ? (
|
||||
(data === null ? (
|
||||
<p className={styles.stateNote}>Render a chart to inspect its data.</p>
|
||||
) : table === null || rows === null ? (
|
||||
<p className={styles.stateNote}>This chart has no inspectable data.</p>
|
||||
) : rows.length === 0 ? (
|
||||
<p className={styles.stateNote}>
|
||||
{view === 'resolved'
|
||||
? 'No rows — the spec’s filters or transforms left nothing to draw.'
|
||||
? 'No rows — the view’s filters or transforms left nothing to draw.'
|
||||
: 'The source data has no rows.'}
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Extract-to-Dataset — the modal body (spec §03F).
|
||||
*
|
||||
* Shows a read-only preview of the active snippet draft's inline data and asks
|
||||
* Shows a read-only preview of the focused view's embedded data and asks
|
||||
* for a dataset name. On confirm it saves the data as a new dataset and rewrites
|
||||
* the draft to reference it by name (logic in ExtractStore), then force-closes
|
||||
* (the commit is the user's confirmation, so no discard prompt). Cancel leaves
|
||||
@@ -38,13 +38,13 @@ export function ExtractModal() {
|
||||
};
|
||||
|
||||
if (!source) {
|
||||
return <p className={styles.muted}>This snippet has no inline data to extract.</p>;
|
||||
return <p className={styles.muted}>This snippet has no embedded data to extract.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.extract}>
|
||||
<p className={styles.intro}>
|
||||
Save this snippet’s inline data as a reusable dataset. The spec will be rewritten to
|
||||
Save this snippet’s embedded data as a reusable dataset. The spec will be rewritten to
|
||||
reference it by name.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -34,6 +34,21 @@ export type IconName =
|
||||
| 'export' // export the workspace to a file — Carbon Download (a file comes out)
|
||||
| 'info' // about / information — Carbon Information (outline)
|
||||
| 'revert' // revert draft to last published — Carbon Reset
|
||||
| 'structure' // composition-structure wireframe disclosure (preview toolbar) — nested view blocks
|
||||
| 'layers' // layered-composition badge (wireframe) — two stacked planes (one shared space)
|
||||
// Mark sub-family (composition wireframe leaves) — a simplified glyph of a unit
|
||||
// view's mark type, so which-is-which reads at a glance. Vega-Lite mark synonyms
|
||||
// collapse onto these via `markIconName` (CompositionWireframe); unknown → generic.
|
||||
| 'mark-bar'
|
||||
| 'mark-line'
|
||||
| 'mark-area'
|
||||
| 'mark-point'
|
||||
| 'mark-arc'
|
||||
| 'mark-rect'
|
||||
| 'mark-tick'
|
||||
| 'mark-rule'
|
||||
| 'mark-text'
|
||||
| 'mark-generic'
|
||||
// Pane-toggle sub-family (spec §01A): a panel frame with one region filled, so the
|
||||
// glyph shows *which* pane it controls by position (left / centre / right).
|
||||
| 'pane-library' // toggle the library pane (left)
|
||||
@@ -150,6 +165,104 @@ const GLYPHS: Record<IconName, ReactNode> = {
|
||||
<rect x="21" y="10" width="4" height="12" />
|
||||
</>
|
||||
),
|
||||
// Composition structure: a panel frame (matching the pane family) holding nested
|
||||
// view blocks — two side by side over one wide — the wireframe in miniature.
|
||||
structure: (
|
||||
<>
|
||||
<rect x="4" y="6" width="24" height="2" />
|
||||
<rect x="4" y="24" width="24" height="2" />
|
||||
<rect x="4" y="6" width="2" height="20" />
|
||||
<rect x="26" y="6" width="2" height="20" />
|
||||
<rect x="9" y="10" width="6" height="5" />
|
||||
<rect x="17" y="10" width="6" height="5" />
|
||||
<rect x="9" y="17" width="14" height="5" />
|
||||
</>
|
||||
),
|
||||
// Two offset planes — a layer is one space with several marks stacked in z-order.
|
||||
layers: (
|
||||
<>
|
||||
<rect x="6" y="6" width="15" height="15" fill="none" stroke="currentColor" strokeWidth={2} />
|
||||
<rect
|
||||
x="11"
|
||||
y="11"
|
||||
width="15"
|
||||
height="15"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
// Mark glyphs: simplified renderings of each Vega-Lite mark, drawn on the same
|
||||
// 32-grid. Stroke-based where a line reads truer than a fill (line/rule/generic).
|
||||
'mark-bar': (
|
||||
<>
|
||||
<rect x="5" y="14" width="5" height="14" />
|
||||
<rect x="13" y="8" width="5" height="20" />
|
||||
<rect x="21" y="18" width="5" height="10" />
|
||||
</>
|
||||
),
|
||||
'mark-line': (
|
||||
<polyline
|
||||
points="4,22 11,13 18,17 28,6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
),
|
||||
'mark-area': <path d="M4,28 L4,17 L12,11 L20,16 L28,7 L28,28 Z" />,
|
||||
'mark-point': (
|
||||
<>
|
||||
<circle cx="9" cy="20" r="2.6" />
|
||||
<circle cx="16" cy="12" r="2.6" />
|
||||
<circle cx="22" cy="22" r="2.6" />
|
||||
<circle cx="25" cy="9" r="2.6" />
|
||||
</>
|
||||
),
|
||||
// A three-quarter pie wedge (one quadrant empty) reads as arc/pie at a glance.
|
||||
'mark-arc': <path d="M16,16 L16,4 A12,12 0 1,1 4,16 Z" />,
|
||||
'mark-rect': (
|
||||
<>
|
||||
<rect x="5" y="9" width="6" height="6" />
|
||||
<rect x="13" y="9" width="6" height="6" />
|
||||
<rect x="21" y="9" width="6" height="6" />
|
||||
<rect x="5" y="17" width="6" height="6" />
|
||||
<rect x="13" y="17" width="6" height="6" />
|
||||
<rect x="21" y="17" width="6" height="6" />
|
||||
</>
|
||||
),
|
||||
'mark-tick': (
|
||||
<>
|
||||
<rect x="6" y="10" width="2" height="12" />
|
||||
<rect x="12" y="10" width="2" height="12" />
|
||||
<rect x="18" y="10" width="2" height="12" />
|
||||
<rect x="24" y="10" width="2" height="12" />
|
||||
</>
|
||||
),
|
||||
'mark-rule': (
|
||||
<line
|
||||
x1="5"
|
||||
y1="24"
|
||||
x2="27"
|
||||
y2="8"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2.5}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
),
|
||||
'mark-text': (
|
||||
<>
|
||||
<rect x="6" y="8" width="20" height="3" />
|
||||
<rect x="6" y="15" width="14" height="3" />
|
||||
<rect x="6" y="22" width="18" height="3" />
|
||||
</>
|
||||
),
|
||||
'mark-generic': (
|
||||
<rect x="5" y="6" width="22" height="20" fill="none" stroke="currentColor" strokeWidth={2} />
|
||||
),
|
||||
moon: (
|
||||
<path d="M13.5025,5.4136A15.0755,15.0755,0,0,0,25.096,23.6082a11.1134,11.1134,0,0,1-7.9749,3.3893c-.1385,0-.2782.0051-.4178,0A11.0944,11.0944,0,0,1,13.5025,5.4136M14.98,3a1.0024,1.0024,0,0,0-.1746.0156A13.0959,13.0959,0,0,0,16.63,28.9973c.1641.006.3282,0,.4909,0a13.0724,13.0724,0,0,0,10.702-5.5556,1.0094,1.0094,0,0,0-.7833-1.5644A13.08,13.08,0,0,1,15.8892,4.38,1.0149,1.0149,0,0,0,14.98,3Z" />
|
||||
),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* LivePreview — busy overlay guard (spec §04; arch §10.2).
|
||||
* LivePreview — busy overlay + render serialization (spec §04; arch §10.2).
|
||||
*
|
||||
* The render pipeline is integration-heavy (vega-embed, IndexedDB, Monaco); the
|
||||
* busy OVERLAY itself is purely a function of `PreviewStore.busy`. These tests
|
||||
* set that flag directly and assert the DOM result — no timing, no mocking of the
|
||||
* async render path.
|
||||
* The render pipeline is integration-heavy (vega-embed, IndexedDB, Monaco), so
|
||||
* `renderSpec` is mocked to park each embed in `H.pending` — a test decides when
|
||||
* embeds settle. Render status (`error`/`busy`) is the pane's own local state, so
|
||||
* the busy overlay is driven through its real path — a render left in flight past
|
||||
* the ~1s timer — not by poking a flag.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
@@ -12,7 +13,6 @@ import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { chartConfigForSelection } from '@core/vega-themes';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { usePreviewStore } from '../stores/PreviewStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { LivePreview } from './LivePreview';
|
||||
@@ -27,10 +27,6 @@ const H = vi.hoisted(() => ({
|
||||
pending: [] as Array<() => void>,
|
||||
destroyed: [] as number[],
|
||||
configs: [] as unknown[],
|
||||
inspected: null as {
|
||||
input: ReadonlyArray<Record<string, unknown>>;
|
||||
resolved: ReadonlyArray<Record<string, unknown>>;
|
||||
} | null,
|
||||
}));
|
||||
vi.mock('../services/chart-renderer', () => ({
|
||||
renderSpec: (node: HTMLElement, _spec: unknown, config: unknown) => {
|
||||
@@ -48,7 +44,8 @@ vi.mock('../services/chart-renderer', () => ({
|
||||
H.destroyed.push(id);
|
||||
},
|
||||
resize() {},
|
||||
inspectData: () => H.inspected,
|
||||
inspectData: () => null,
|
||||
onDataChange: () => () => {},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -74,7 +71,6 @@ beforeEach(() => {
|
||||
H.pending.length = 0;
|
||||
H.destroyed.length = 0;
|
||||
H.configs.length = 0;
|
||||
usePreviewStore.setState({ error: null, busy: false });
|
||||
useSnippetStore.getState().reset();
|
||||
useDatasetStore.getState().reset();
|
||||
|
||||
@@ -87,11 +83,12 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
usePreviewStore.setState({ error: null, busy: false });
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('LivePreview busy overlay', () => {
|
||||
const tick = (ms = 6000) => act(async () => void (await vi.advanceTimersByTimeAsync(ms)));
|
||||
|
||||
// The overlay is the aria-hidden element carrying the "Rendering…" label — a
|
||||
// bare [aria-hidden] query would also match decorative bits of the header
|
||||
// controls (e.g. the chart-theme select's caret).
|
||||
@@ -100,33 +97,44 @@ describe('LivePreview busy overlay', () => {
|
||||
/rendering/i.test(el.textContent ?? ''),
|
||||
) ?? null;
|
||||
|
||||
test('does not render the busy overlay when busy=false', () => {
|
||||
// The overlay element should not be in the DOM at all during normal operation.
|
||||
// Start a render and leave it parked in `H.pending`; advancing past the debounce
|
||||
// and the ~1s busy timer flips `busy` on — the real (and only) path now that it
|
||||
// is local state.
|
||||
const startSlowRender = async () => {
|
||||
act(() => {
|
||||
useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"point"}' });
|
||||
});
|
||||
await tick();
|
||||
};
|
||||
|
||||
test('no overlay and no aria-busy before a render is in flight', () => {
|
||||
expect(overlay()).toBeNull();
|
||||
});
|
||||
|
||||
test('renders the busy overlay when PreviewStore.busy=true', () => {
|
||||
act(() => usePreviewStore.setState({ busy: true }));
|
||||
expect(overlay()).not.toBeNull();
|
||||
});
|
||||
|
||||
test('the preview body carries aria-busy=true when busy', () => {
|
||||
act(() => usePreviewStore.setState({ busy: true }));
|
||||
// The body element has aria-busy when the store says busy.
|
||||
const busyEl = container.querySelector('[aria-busy="true"]');
|
||||
expect(busyEl).not.toBeNull();
|
||||
});
|
||||
|
||||
test('aria-busy is absent when busy=false (no aria-busy="false" noise)', () => {
|
||||
// aria-busy="false" is technically valid but needlessly verbose; we omit it.
|
||||
expect(container.querySelector('[aria-busy]')).toBeNull();
|
||||
});
|
||||
|
||||
test('overlay disappears when busy returns to false', () => {
|
||||
act(() => usePreviewStore.setState({ busy: true }));
|
||||
expect(overlay()).not.toBeNull();
|
||||
act(() => usePreviewStore.setState({ busy: false }));
|
||||
expect(overlay()).toBeNull();
|
||||
test('a render in flight past ~1s shows the overlay and sets aria-busy', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await startSlowRender();
|
||||
expect(overlay()).not.toBeNull();
|
||||
expect(container.querySelector('[aria-busy="true"]')).not.toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
test('the overlay clears once the render settles', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
await startSlowRender();
|
||||
expect(overlay()).not.toBeNull();
|
||||
act(() => H.pending[0]()); // the parked embed resolves → busy cleared on settle
|
||||
await tick(0);
|
||||
expect(overlay()).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@
|
||||
* mode applied) → renderSpec (vega-embed). A render-generation token guards
|
||||
* against a slow render resolving after a newer one.
|
||||
*
|
||||
* The pane header carries the Fit control (4 sizing modes, §04). Render errors
|
||||
* are published to the shared PreviewStore so the editor pane mirrors them
|
||||
* (§03E); the preview shows the same message in place of the chart.
|
||||
* The pane header carries the Fit control (4 sizing modes, §04). A render or
|
||||
* parse error shows here in place of the chart (error xor chart) — its single
|
||||
* message home (arch 10 §1); the editor pinpoints the cause with a squiggle
|
||||
* (§03E) rather than repeating the text.
|
||||
*
|
||||
* M2 scope: inline-data specs, all four fit modes. Dataset reference resolution
|
||||
* (M3) plugs into prepareSpecForRender without changing this component.
|
||||
@@ -22,6 +23,7 @@ import type { Config } from 'vega-lite';
|
||||
import { referencedUploadedFonts } from '@core/chart-export';
|
||||
import type { FitMode } from '@core/rendering';
|
||||
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
|
||||
import { firstExpressionError } from '@core/spec-expressions';
|
||||
import {
|
||||
chartConfigForSelection,
|
||||
chartThemeOptions,
|
||||
@@ -33,10 +35,10 @@ import { useAppStore } from '../stores/AppStore';
|
||||
import { useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { useFontStore } from '../stores/FontStore';
|
||||
import { usePreviewStore } from '../stores/PreviewStore';
|
||||
import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||
import { ChartExport } from './ChartExport';
|
||||
import { CompositionWireframe } from './CompositionWireframe';
|
||||
import { DataInspector } from './DataInspector';
|
||||
import { InspectorSplitHandle } from './InspectorSplitHandle';
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||
@@ -197,10 +199,11 @@ export function LivePreview() {
|
||||
const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce);
|
||||
// Seed with a sentinel epoch so the very first paint counts as a load (immediate).
|
||||
const lastLoadRef = useRef({ bufferEpoch: -1, editorView });
|
||||
const error = usePreviewStore((s) => s.error);
|
||||
const setError = usePreviewStore((s) => s.setError);
|
||||
const busy = usePreviewStore((s) => s.busy);
|
||||
const setBusy = usePreviewStore((s) => s.setBusy);
|
||||
// Render status is local to this pane — it is both the only producer and the
|
||||
// only consumer, so it needs no store (arch 01). `error` is the render/parse
|
||||
// message (null = clean or blank); `busy` gates the >1s render overlay.
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Mirrors whether `handleRef` currently holds a live view, so the per-chart
|
||||
// export's image actions (which need the view) can enable/disable reactively —
|
||||
// a ref change alone wouldn't re-render. Set true on a successful render, false
|
||||
@@ -211,6 +214,13 @@ export function LivePreview() {
|
||||
// not `chartReady` — consecutive successful renders keep `chartReady` true, but
|
||||
// each one is new data the inspector must pick up.
|
||||
const [renderEpoch, setRenderEpoch] = useState(0);
|
||||
// Bumped (debounced, inside the handle) when an interactive selection changes
|
||||
// the inspected data without a re-render — the live data inspector (spec §04;
|
||||
// multi-view scope doc M5). Kept separate from `renderEpoch` so a brush pulse
|
||||
// re-reads the table without re-subscribing the listener; their sum is the
|
||||
// inspector's single refresh trigger (each event bumps exactly one, so the sum
|
||||
// is strictly monotonic — no collisions).
|
||||
const [liveEpoch, setLiveEpoch] = useState(0);
|
||||
|
||||
// Busy-indication timer ref: if a render exceeds ~1s we surface a non-blocking
|
||||
// overlay (arch §10.2 NN/g: >1s owes a busy indication; <1s shows nothing to
|
||||
@@ -245,7 +255,7 @@ export function LivePreview() {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (e) {
|
||||
if (mine === generationRef.current) setError(`Invalid JSON: ${(e as Error).message}`);
|
||||
if (mine === generationRef.current) setError(`Invalid JSON · ${(e as Error).message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -325,19 +335,25 @@ export function LivePreview() {
|
||||
setChartReady(false);
|
||||
setRenderEpoch((e) => e + 1);
|
||||
clearBusy();
|
||||
// A missing dataset reference is not a JSON/spec problem, so it gets a
|
||||
// tailored, fixable message instead of the generic syntax hint (council:
|
||||
// GOV.UK error-message + NN/g #9 — name the problem, give the real fix).
|
||||
// All render-failure messages share a line-led / noun-led terse shape
|
||||
// (`<location|noun> · <detail>`, arch 10 §1). A missing dataset and a
|
||||
// malformed expression are attributed precisely — naming the fixable cause
|
||||
// beats the generic "check your JSON" hint, which is wrong when the JSON is
|
||||
// valid (council: GOV.UK error-message "be specific" + name the real fix).
|
||||
if (e instanceof DatasetNotFoundError) {
|
||||
setError(
|
||||
`Dataset "${e.datasetName}" not found. Create it from Datasets ` +
|
||||
`(⌘/Ctrl+K), or check the dataset name in your spec.`,
|
||||
`Dataset "${e.datasetName}" not found · create it from Datasets (⌘/Ctrl+K)`,
|
||||
);
|
||||
} else {
|
||||
setError(
|
||||
`Rendering error: ${(e as Error).message}. ` +
|
||||
`Check your JSON syntax and that the spec is valid Vega-Lite.`,
|
||||
);
|
||||
// A malformed Vega expression is located by line (the editor also
|
||||
// squiggles it) and carries the same parser message the hover shows.
|
||||
// Scanned over the untrimmed buffer so the line matches the editor's.
|
||||
const exprError = firstExpressionError(shownText);
|
||||
if (exprError) {
|
||||
setError(`Line ${exprError.line} · ${exprError.message.replace(/\.$/, '')}`);
|
||||
} else {
|
||||
setError(`Render error · ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -397,6 +413,19 @@ export function LivePreview() {
|
||||
// on `renderEpoch`, so this need not depend on it (it always reads the latest handle).
|
||||
const getInspectData = useCallback(() => handleRef.current?.inspectData() ?? null, []);
|
||||
|
||||
// Live data inspection (spec §04): while the inspector is open, re-read the table
|
||||
// when an interactive selection changes the data it shows (a filtering brush). The
|
||||
// handle owns the Vega listeners + debounce; we just bump `liveEpoch` on each fire.
|
||||
// Re-subscribes whenever a render settles (`renderEpoch`) so it tracks the current
|
||||
// handle, and only while the inspector is open so a collapsed one costs nothing.
|
||||
// handleRef is a ref (read, not a dep); the cleanup unsubscribes.
|
||||
useEffect(() => {
|
||||
if (!inspectorOpen) return;
|
||||
const handle = handleRef.current;
|
||||
if (!handle) return;
|
||||
return handle.onDataChange(() => setLiveEpoch((e) => e + 1));
|
||||
}, [inspectorOpen, renderEpoch]);
|
||||
|
||||
// Re-fit the chart when its container resizes (e.g. a pane drag). Vega doesn't
|
||||
// observe the element, so we do: one observer on the stable host node for the
|
||||
// component's life. Only responsive fit modes depend on container size;
|
||||
@@ -413,16 +442,14 @@ export function LivePreview() {
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Finalize the live view on unmount, and clear the shared error + busy state so
|
||||
// stale transient state never outlives this pane.
|
||||
// Finalize the live view on unmount so its timers/listeners don't outlive the
|
||||
// pane. Render status is local state and dies with the component.
|
||||
useEffect(
|
||||
() => () => {
|
||||
handleRef.current?.destroy();
|
||||
handleRef.current = null;
|
||||
setChartReady(false);
|
||||
if (busyTimerRef.current !== null) clearTimeout(busyTimerRef.current);
|
||||
usePreviewStore.getState().setError(null);
|
||||
usePreviewStore.getState().setBusy(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -431,10 +458,12 @@ export function LivePreview() {
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.header}>
|
||||
<FitControl />
|
||||
{/* Right cluster: chart theme, export this chart, then the settings gear. */}
|
||||
{/* Right cluster: chart theme, export this chart, the structure wireframe,
|
||||
then the settings gear. */}
|
||||
<div className={styles.headerEnd}>
|
||||
<ChartThemeControl />
|
||||
<ChartExport chartReady={chartReady} getImageUrl={getImageUrl} />
|
||||
<CompositionWireframe />
|
||||
<PreviewSettings />
|
||||
</div>
|
||||
</div>
|
||||
@@ -451,10 +480,15 @@ export function LivePreview() {
|
||||
<div className={`${styles.frame} ${FIT_CLASS[fitMode]}`} hidden={error !== null}>
|
||||
<div className={styles.host} ref={hostRef} />
|
||||
</div>
|
||||
{/* Visual only — no live region. The same error is announced once by the
|
||||
editor pane's role="alert" (one producer, two subscribers; doc §10.1),
|
||||
so adding one here would double-announce it. */}
|
||||
{error !== null && <pre className={styles.error}>{error}</pre>}
|
||||
{/* The single home for a render/parse error (arch 10 §1): it sits where the
|
||||
chart would be — error XOR chart — and is the lone live region, assertive
|
||||
since the user just caused it. The editor pinpoints the spot via its squiggle,
|
||||
so the message lives here, not duplicated under the editor. */}
|
||||
{error !== null && (
|
||||
<pre className={styles.error} role="alert">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
{/*
|
||||
* Busy overlay: non-blocking, overlays only the chart body, never the header
|
||||
* or the editor (arch §10.2; spec §04/§10). Shown only after the ~1s threshold
|
||||
@@ -478,7 +512,7 @@ export function LivePreview() {
|
||||
<DataInspector
|
||||
id="preview-data-inspector"
|
||||
getData={getInspectData}
|
||||
renderEpoch={renderEpoch}
|
||||
renderEpoch={renderEpoch + liveEpoch}
|
||||
heightPx={inspectorOpen ? inspectorHeight : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,13 @@ import { Onboarding } from './Onboarding';
|
||||
// never touches vega-embed. A resolved no-op handle is enough — Onboarding only
|
||||
// finalizes it on unmount.
|
||||
vi.mock('../services/chart-renderer', () => ({
|
||||
renderSpec: () => Promise.resolve({ destroy() {}, resize() {}, inspectData: () => null }),
|
||||
renderSpec: () =>
|
||||
Promise.resolve({
|
||||
destroy() {},
|
||||
resize() {},
|
||||
inspectData: () => null,
|
||||
onDataChange: () => () => {},
|
||||
}),
|
||||
}));
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -137,8 +137,8 @@ export function Onboarding() {
|
||||
<div className={styles.inner}>
|
||||
<h2 className={styles.title}>Welcome to Astrolabe</h2>
|
||||
<p className={styles.tagline}>
|
||||
A local library for your Vega-Lite charts — authored as JSON, rendered live, and kept on
|
||||
your device.
|
||||
A local library for your Vega-Lite charts — write the spec as JSON, watch it render live,
|
||||
and keep it all on your device.
|
||||
</p>
|
||||
|
||||
<div className={styles.ctaRow}>
|
||||
|
||||
@@ -79,20 +79,3 @@
|
||||
background: var(--bg);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Inline render/parse error surface (spec §03E) — monospaced, distinct. */
|
||||
.error {
|
||||
flex: 0 0 auto;
|
||||
max-height: 30%;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-top: var(--border-width) solid var(--support-error);
|
||||
background: var(--layer-01);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--support-error);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,9 @@
|
||||
*
|
||||
* The pane header carries the Draft/Published toggle plus Publish and Revert
|
||||
* (spec §03D). The published view is read-only — it shows the last published
|
||||
* spec for reference; all editing happens on the draft. Render problems surface
|
||||
* inline near the editor (spec §03E), mirroring the preview via PreviewStore.
|
||||
* spec for reference; all editing happens on the draft. Render/parse problems
|
||||
* surface in the preview pane (arch 10 §1); the editor marks the spot with an
|
||||
* inline squiggle (spec §03E).
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useRef, type RefObject } from 'react';
|
||||
@@ -26,20 +27,44 @@ import '../infrastructure/monaco-env'; // side-effect: wire workers before creat
|
||||
import { configureVegaLiteJson } from '../infrastructure/monaco-schema';
|
||||
import { configureJsonFormatter, installFormatOnPaste } from '../infrastructure/monaco-format';
|
||||
import { parseChartSpecText } from '@core/chart-builder';
|
||||
import { openChartBuilderForEdit, openModal } from '../modals/ModalCoordinator';
|
||||
import { openChartBuilderForEdit } from '../modals/ModalCoordinator';
|
||||
import {
|
||||
installSpecConfigActions,
|
||||
runExtractConfig,
|
||||
runExtractConfigToTheme,
|
||||
runMergeChartTheme,
|
||||
} from '../services/spec-config-actions';
|
||||
import {
|
||||
configureSpecTransformCodeActions,
|
||||
installSpecTransformActions,
|
||||
installSpecTransformCodeLens,
|
||||
runMoveViewTo,
|
||||
runSimplifyStructure,
|
||||
runUnwrap,
|
||||
runWrap,
|
||||
runWrapContainer,
|
||||
runWrapViews,
|
||||
} from '../services/spec-transform-actions';
|
||||
import { configureSpecDatasetHints } from '../services/spec-dataset-hints';
|
||||
import {
|
||||
configureSpecExpressionHints,
|
||||
installExpressionMarkers,
|
||||
} from '../services/spec-expression-hints';
|
||||
import {
|
||||
configureSpecTransformScaffold,
|
||||
installSpecTransformScaffoldCodeLens,
|
||||
} from '../services/spec-transform-scaffold';
|
||||
import {
|
||||
configureSpecParamScaffold,
|
||||
installSpecParamScaffoldCodeLens,
|
||||
} from '../services/spec-param-scaffold';
|
||||
import { runExtract } from '../services/extract-action';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { confirm } from '../stores/ConfirmStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { hasInlineData } from '../stores/ExtractStore';
|
||||
import { hasExtractableData } from '../stores/ExtractStore';
|
||||
import { publishActiveSnippet } from '../services/snippet-actions';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { usePreviewStore } from '../stores/PreviewStore';
|
||||
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||
import { Icon } from './Icon';
|
||||
@@ -149,6 +174,17 @@ function EditorSettings() {
|
||||
configureVegaLiteJson();
|
||||
// Register the compact JSON formatter once (Format Document + format-on-paste, §03A).
|
||||
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();
|
||||
// Register the expression completion/signature-help/hover providers once (docs/architecture/08).
|
||||
configureSpecExpressionHints();
|
||||
// Register the data-transform step-scaffold completion once (docs/architecture/08).
|
||||
configureSpecTransformScaffold();
|
||||
// Register the parameter-scaffold completion once (docs/architecture/08).
|
||||
configureSpecParamScaffold();
|
||||
|
||||
/** The two spec↔config operations, surfaced as an overflow menu (council:
|
||||
* Carbon menu-buttons — overflow for additional options under space
|
||||
@@ -174,6 +210,26 @@ const CONFIG_ACTIONS = [
|
||||
|
||||
type ConfigActionId = (typeof CONFIG_ACTIONS)[number]['value'];
|
||||
|
||||
/** Composition restructures, 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. Named "Compose" so it reads distinctly from a Vega-Lite data `transform`
|
||||
* (the pipeline scaffolded by the editor completion — services/spec-transform-scaffold). */
|
||||
const COMPOSE_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 ComposeActionId = (typeof COMPOSE_ACTIONS)[number]['value'];
|
||||
|
||||
function EditorToolbar({
|
||||
editorRef,
|
||||
}: {
|
||||
@@ -190,10 +246,11 @@ function EditorToolbar({
|
||||
const draft = s.editorView === 'draft' ? s.draftText : active.draftSpec;
|
||||
return draft !== active.spec;
|
||||
});
|
||||
// Offer Extract only when the live draft carries top-level inline data to lift
|
||||
// out (spec §03F → hidden when the spec has no inline data).
|
||||
// Offer Extract only when the live draft carries data to lift out — inline
|
||||
// `values` in any view, or a reference to a self-defined `datasets` entry (spec
|
||||
// §03F → hidden when there is nothing extractable).
|
||||
const canExtract = useSnippetStore(
|
||||
(s) => s.activeSnippetId !== null && hasInlineData(s.draftText),
|
||||
(s) => s.activeSnippetId !== null && hasExtractableData(s.draftText),
|
||||
);
|
||||
|
||||
// Offer "Open in builder" only when the active snippet's published spec is
|
||||
@@ -216,6 +273,14 @@ function EditorToolbar({
|
||||
if (snippet) openChartBuilderForEdit(snippet);
|
||||
};
|
||||
|
||||
// Extract is scoped to the view at the cursor (services/extract-action), so it
|
||||
// goes through the editor handle like the wrap/config actions, not a bare
|
||||
// openModal — the service captures the focused binding before opening the modal.
|
||||
const handleExtract = () => {
|
||||
const editor = editorRef.current;
|
||||
if (editor) runExtract(editor);
|
||||
};
|
||||
|
||||
// Publish + its success toast live in one place (services/snippet-actions) so
|
||||
// the button and the Cmd/Ctrl+S shortcut (EventRouter) behave identically.
|
||||
const handlePublish = publishActiveSnippet;
|
||||
@@ -228,6 +293,13 @@ function EditorToolbar({
|
||||
else runExtractConfigToTheme(editor);
|
||||
};
|
||||
|
||||
const handleComposeAction = (action: ComposeActionId) => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
if (action === 'simplify') runUnwrap(editor);
|
||||
else runWrap(editor, action);
|
||||
};
|
||||
|
||||
const handleRevert = async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Revert draft',
|
||||
@@ -276,14 +348,24 @@ function EditorToolbar({
|
||||
{canExtract && (
|
||||
<Button
|
||||
className={styles.collapsible}
|
||||
onClick={() => openModal('extract')}
|
||||
title="Extract inline data into a reusable dataset"
|
||||
onClick={handleExtract}
|
||||
title="Extract embedded data into a reusable dataset"
|
||||
aria-label="Extract to Dataset"
|
||||
>
|
||||
<Icon name="dataset" className={styles.actionIcon} />
|
||||
<span className={styles.actionLabel}>Extract to Dataset</span>
|
||||
</Button>
|
||||
)}
|
||||
<SelectControl
|
||||
id="editor-compose-actions"
|
||||
label="Spec composition actions"
|
||||
heading="Compose"
|
||||
options={COMPOSE_ACTIONS}
|
||||
onSelect={handleComposeAction}
|
||||
triggerContent="Compose"
|
||||
triggerTitle="Composition restructures — wrap the focused view in a layer/concat/facet/repeat, or simplify one"
|
||||
disabled={activeId === null || editorView === 'published'}
|
||||
/>
|
||||
<SelectControl
|
||||
id="editor-config-actions"
|
||||
label="Spec config actions"
|
||||
@@ -325,7 +407,8 @@ export function SpecEditor() {
|
||||
const editorView = useSnippetStore((s) => s.editorView);
|
||||
const bufferEpoch = useSnippetStore((s) => s.bufferEpoch);
|
||||
const uiTheme = useAppStore((s) => s.uiTheme);
|
||||
const error = usePreviewStore((s) => s.error);
|
||||
const revealTarget = useAppStore((s) => s.revealTarget);
|
||||
const composeRequest = useAppStore((s) => s.composeRequest);
|
||||
// Editor preferences (spec §07 → Editor); applied live below as they change.
|
||||
const editorPrefs = useUserSettingsStore((s) => s.saved.editor);
|
||||
|
||||
@@ -376,6 +459,28 @@ export function SpecEditor() {
|
||||
// above, so the draft buffer stays in sync like any other edit.
|
||||
const configActionsSub = installSpecConfigActions(editor);
|
||||
|
||||
// Structural-transform actions in the F1 palette (the lightbulb is registered
|
||||
// once, globally, above; the toolbar Compose menu is the home). Per editor,
|
||||
// disposed below like the config actions.
|
||||
const transformActionsSub = installSpecTransformActions(editor);
|
||||
|
||||
// Cursor-aware composition CodeLens (add view above/below, reorder) — per
|
||||
// editor, because its commands need this editor's handle to apply the edit.
|
||||
const codeLensSub = installSpecTransformCodeLens(editor);
|
||||
|
||||
// Cursor-aware data-transform scaffold CodeLens (+ Add transform / + filter …)
|
||||
// — per editor for the same reason: its commands drive this editor's snippet edit.
|
||||
const scaffoldLensSub = installSpecTransformScaffoldCodeLens(editor);
|
||||
|
||||
// Cursor-aware parameter scaffold CodeLens (+ slider / + point …) — per editor,
|
||||
// its command splices the seeded param into this editor via the snippet engine.
|
||||
const paramScaffoldLensSub = installSpecParamScaffoldCodeLens(editor);
|
||||
|
||||
// Validate Vega expressions in the draft and squiggle the invalid ones — per
|
||||
// editor, because it writes markers to this model (docs/architecture/08).
|
||||
// Debounced internally; recomputes on edit and on a draft↔published toggle.
|
||||
const exprMarkersSub = installExpressionMarkers(editor);
|
||||
|
||||
// Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 →
|
||||
// "bind listeners in exactly one place"), which publishes before the
|
||||
// interactive-context gate so it works while the editor has focus. Monaco
|
||||
@@ -385,6 +490,11 @@ export function SpecEditor() {
|
||||
sub.dispose();
|
||||
pasteSub.dispose();
|
||||
configActionsSub.dispose();
|
||||
transformActionsSub.dispose();
|
||||
codeLensSub.dispose();
|
||||
scaffoldLensSub.dispose();
|
||||
paramScaffoldLensSub.dispose();
|
||||
exprMarkersSub.dispose();
|
||||
editor.dispose();
|
||||
editorRef.current = null;
|
||||
};
|
||||
@@ -425,6 +535,49 @@ export function SpecEditor() {
|
||||
monaco.editor.setTheme(effective === 'dark' ? 'vs-dark' : 'vs');
|
||||
}, [uiTheme, editorPrefs.theme]);
|
||||
|
||||
// Select + reveal a view's source range when the composition wireframe asks
|
||||
// (arch 08 → composition wireframe). The nonce makes a repeat request re-fire.
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor || !revealTarget) return;
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
const range = monaco.Range.fromPositions(
|
||||
model.getPositionAt(revealTarget.offset),
|
||||
model.getPositionAt(revealTarget.offset + revealTarget.length),
|
||||
);
|
||||
editor.setSelection(range);
|
||||
editor.revealRangeInCenterIfOutsideViewport(range);
|
||||
// Deliberately no focus(): the wireframe stays the active surface so the user
|
||||
// can keep browsing blocks while the editor scrolls/selects to follow.
|
||||
}, [revealTarget]);
|
||||
|
||||
// Apply a composition restructure when the wireframe asks (wireframe → editor;
|
||||
// the editor owns the undoable edit). The nonce makes a repeat request re-fire.
|
||||
useEffect(() => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor || !composeRequest) return;
|
||||
if (composeRequest.kind === 'move')
|
||||
runMoveViewTo(editor, composeRequest.arrayPath, composeRequest.from, composeRequest.to);
|
||||
else if (composeRequest.kind === 'simplify') runSimplifyStructure(editor);
|
||||
else if (composeRequest.kind === 'wrap-container')
|
||||
runWrapContainer(
|
||||
editor,
|
||||
composeRequest.containerPath,
|
||||
composeRequest.sourcePath,
|
||||
composeRequest.axis,
|
||||
composeRequest.side,
|
||||
);
|
||||
else
|
||||
runWrapViews(
|
||||
editor,
|
||||
composeRequest.targetPath,
|
||||
composeRequest.sourcePath,
|
||||
composeRequest.axis,
|
||||
composeRequest.side,
|
||||
);
|
||||
}, [composeRequest]);
|
||||
|
||||
return (
|
||||
<div className={styles.editorPane}>
|
||||
<EditorToolbar editorRef={editorRef} />
|
||||
@@ -432,14 +585,8 @@ export function SpecEditor() {
|
||||
{activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>}
|
||||
<div className={styles.editor} ref={hostRef} />
|
||||
</div>
|
||||
{/* The single live region for render/parse errors: assertive, since the
|
||||
user just caused it. The preview shows the same text visually but is
|
||||
not a live region, so the message is announced once (doc §10.1). */}
|
||||
{error !== null && (
|
||||
<pre className={styles.error} role="alert">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
{/* Render/parse errors surface in the preview pane (arch 10 §1), where the
|
||||
chart would be; the editor pinpoints the spot with its squiggle. */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ const okHandle = () => ({
|
||||
resize() {},
|
||||
toImageURL: () => Promise.resolve(''),
|
||||
inspectData: () => null,
|
||||
onDataChange: () => () => {},
|
||||
});
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
@@ -22,6 +22,7 @@ vi.mock('../services/chart-renderer', () => ({
|
||||
resize() {},
|
||||
toImageURL: () => Promise.resolve(''),
|
||||
inspectData: () => null,
|
||||
onDataChange: () => () => {},
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Mark → wireframe glyph ledger (composition wireframe leaves, arch 08). Maps a
|
||||
* unit view's Vega-Lite mark type to its glyph in the Icon vocabulary's `mark-*`
|
||||
* sub-family, collapsing synonyms (circle/square → point, trail → line, image →
|
||||
* rect) so the set stays small; anything unmapped or absent falls to `mark-generic`.
|
||||
*/
|
||||
|
||||
import type { IconName } from './Icon';
|
||||
|
||||
const MARK_ICON: Record<string, IconName> = {
|
||||
bar: 'mark-bar',
|
||||
line: 'mark-line',
|
||||
trail: 'mark-line',
|
||||
area: 'mark-area',
|
||||
point: 'mark-point',
|
||||
circle: 'mark-point',
|
||||
square: 'mark-point',
|
||||
tick: 'mark-tick',
|
||||
rect: 'mark-rect',
|
||||
image: 'mark-rect',
|
||||
arc: 'mark-arc',
|
||||
rule: 'mark-rule',
|
||||
text: 'mark-text',
|
||||
};
|
||||
|
||||
export const markIconName = (mark?: string): IconName =>
|
||||
(mark && MARK_ICON[mark]) || 'mark-generic';
|
||||
+4
-4
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* Project feedback channel.
|
||||
*
|
||||
* There is no server and no tracker (see the About modal — no telemetry of any
|
||||
* kind); feedback is a plain email the user composes and sends from their own
|
||||
* client. The address is a Cloudflare Email Routing alias that forwards to the
|
||||
* author, so it can be retired without exposing or churning a personal inbox.
|
||||
* The app sends no telemetry of its own (see the About modal); feedback is a
|
||||
* plain email the user composes and sends from their own client. The address is
|
||||
* a Cloudflare Email Routing alias that forwards to the author, so it can be
|
||||
* retired without exposing or churning a personal inbox.
|
||||
*
|
||||
* Shared by the Support modal and the About modal — a contact address is worth
|
||||
* a single source of truth so the two surfaces can't drift.
|
||||
|
||||
@@ -63,11 +63,12 @@ const MODAL_REGISTRY: Partial<Record<ModalName, ModalConfig>> = {
|
||||
},
|
||||
},
|
||||
|
||||
// Opened from the snippet editor with the active draft's inline data to lift out.
|
||||
// Opened from the snippet editor by `services/extract-action`, which seeds the
|
||||
// store with the focused view's inline data (`begin`) *before* opening — so no
|
||||
// `init` here, which would re-read top-level and clobber the view-scoped capture.
|
||||
extract: {
|
||||
name: 'extract',
|
||||
title: 'Extract to Dataset',
|
||||
init: () => useExtractStore.getState().init(),
|
||||
getState: () => ({ name: useExtractStore.getState().name }),
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
import { createDataset } from '@core/dataset';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
import { dataInfoAt } from './active-dataset';
|
||||
|
||||
/** Seed a library dataset, profiled from inline rows. */
|
||||
function seed(name: string, rows: Record<string, unknown>[]): void {
|
||||
useDatasetStore
|
||||
.getState()
|
||||
.add(createDataset({ name, data: rows, format: 'json', source: 'inline' }));
|
||||
}
|
||||
|
||||
/** An offset inside the first occurrence of `marker` in `text`. */
|
||||
function at(text: string, marker: string): number {
|
||||
return text.indexOf(marker) + 1;
|
||||
}
|
||||
|
||||
const names = (cols: ReadonlyArray<{ name: string }>) => cols.map((c) => c.name).sort();
|
||||
|
||||
beforeEach(() => {
|
||||
useDatasetStore.getState().reset();
|
||||
});
|
||||
|
||||
describe('dataInfoAt — view-scoped data context', () => {
|
||||
test('resolves a library binding case-insensitively', () => {
|
||||
seed('Sales', [{ region: 'W', revenue: 10 }]);
|
||||
const text = '{ "data": { "name": "sales" }, "encoding": { "x": { "field": "MK" } } }';
|
||||
const info = dataInfoAt(text, at(text, 'MK'));
|
||||
expect(info.name).toBe('Sales');
|
||||
expect(names(info.columnTypes)).toEqual(['region', 'revenue']);
|
||||
});
|
||||
|
||||
test('each layer sees its own dataset, not a sibling view’s', () => {
|
||||
seed('Sales', [{ region: 'W', revenue: 10 }]);
|
||||
seed('Regions', [{ region: 'W', population: 100 }]);
|
||||
const text = JSON.stringify(
|
||||
{
|
||||
layer: [
|
||||
{ data: { name: 'Sales' }, encoding: { x: { field: 'sales_mk' } } },
|
||||
{ data: { name: 'Regions' }, encoding: { y: { field: 'regions_mk' } } },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const inLayer0 = dataInfoAt(text, at(text, 'sales_mk'));
|
||||
const inLayer1 = dataInfoAt(text, at(text, 'regions_mk'));
|
||||
expect(inLayer0.name).toBe('Sales');
|
||||
expect(names(inLayer0.columnTypes)).toEqual(['region', 'revenue']);
|
||||
expect(inLayer1.name).toBe('Regions');
|
||||
expect(names(inLayer1.columnTypes)).toEqual(['population', 'region']);
|
||||
});
|
||||
|
||||
test('a child without its own data inherits the parent binding', () => {
|
||||
seed('Sales', [{ region: 'W', revenue: 10 }]);
|
||||
const text = JSON.stringify(
|
||||
{ data: { name: 'Sales' }, layer: [{ encoding: { x: { field: 'mk' } } }] },
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const info = dataInfoAt(text, at(text, '"mk"'));
|
||||
expect(info.name).toBe('Sales');
|
||||
expect(names(info.columnTypes)).toEqual(['region', 'revenue']);
|
||||
});
|
||||
|
||||
test('profiles an inline binding (ghost dataset, no name)', () => {
|
||||
const text =
|
||||
'{ "data": { "values": [{ "a": 1, "b": 2 }] }, "encoding": { "x": { "field": "MK" } } }';
|
||||
const info = dataInfoAt(text, at(text, 'MK'));
|
||||
expect(info.name).toBeNull();
|
||||
expect(names(info.columnTypes)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('profiles a self-defined top-level datasets binding', () => {
|
||||
const text = JSON.stringify(
|
||||
{
|
||||
datasets: { local: [{ c: 1, d: 2 }] },
|
||||
data: { name: 'local' },
|
||||
encoding: { x: { field: 'mk' } },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const info = dataInfoAt(text, at(text, '"mk"'));
|
||||
expect(info.name).toBeNull();
|
||||
expect(names(info.columnTypes)).toEqual(['c', 'd']);
|
||||
});
|
||||
|
||||
test('offers ancestor-derived fields, not a sibling view’s', () => {
|
||||
seed('Sales', [{ region: 'W', revenue: 10 }]);
|
||||
const text = JSON.stringify(
|
||||
{
|
||||
data: { name: 'Sales' },
|
||||
transform: [{ calculate: 'datum.revenue * 2', as: 'shared' }],
|
||||
layer: [
|
||||
{ transform: [{ calculate: 'x', as: 'inner' }], encoding: { x: { field: 'mk0' } } },
|
||||
{ transform: [{ calculate: 'y', as: 'sibling' }], encoding: { y: { field: 'mk1' } } },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
const fields = dataInfoAt(text, at(text, 'mk0')).fields.map((f) => f.name);
|
||||
expect(fields).toContain('shared'); // top-level (inherited)
|
||||
expect(fields).toContain('inner'); // this view
|
||||
expect(fields).not.toContain('sibling'); // the other layer
|
||||
});
|
||||
|
||||
test('a url/generator binding yields no static columns', () => {
|
||||
const url = '{ "data": { "url": "x.csv" }, "encoding": { "x": { "field": "MK" } } }';
|
||||
expect(dataInfoAt(url, at(url, 'MK')).columnTypes).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* The data context of the active draft **at the cursor** (docs/architecture/08 →
|
||||
* editor augmentation): the columns/types/stats the spec can reference where the
|
||||
* cursor sits, 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).
|
||||
*
|
||||
* View-scoped: a composed spec (layer/concat/facet/repeat) can bind a different
|
||||
* dataset per view, and Vega-Lite inherits a parent view's data into its children.
|
||||
* So the context is resolved at a JSON path — the data binding of the nearest
|
||||
* enclosing view (`dataBindingAtPath`), plus the fields derived by that view's and
|
||||
* its ancestors' transforms (`derivedFieldNamesAtPath`).
|
||||
*
|
||||
* Source columns, in order: a named **library dataset** the binding references
|
||||
* (its stored profile, matched case-insensitively like the renderer), else the
|
||||
* binding's **inline rows** profiled on the fly — inline `values` or a self-defined
|
||||
* top-level `datasets` entry (the "ghost dataset", `core/spec-inline-data` +
|
||||
* `core/profile`, nothing stored). URL and generator bindings have no static
|
||||
* columns.
|
||||
*
|
||||
* Profiling is memoized per (draft text, enclosing view) so the inlay provider —
|
||||
* which queries many field lines at one draft — never re-profiles the same inline
|
||||
* data. App layer — reads stores via `getState`, outside React.
|
||||
*/
|
||||
|
||||
import { type ColumnStats, profileData } from '@core/profile';
|
||||
import { pathAtOffset } from '@core/spec-cursor';
|
||||
import { dataBindingAtPath, libraryRefName, selfDefinedNames } from '@core/spec-data';
|
||||
import { derivedFieldNamesAtPath } from '@core/spec-fields';
|
||||
import { rowsForDataBinding } from '@core/spec-inline-data';
|
||||
import type { ParamField } from '@core/spec-params';
|
||||
import type { ColumnType } from '@core/type-inference';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
|
||||
/** 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 at a cursor. */
|
||||
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>;
|
||||
}
|
||||
|
||||
/** The source columns of one view's data binding (the expensive, cached part). */
|
||||
interface SourceColumns {
|
||||
name: string | null;
|
||||
columnTypes: ReadonlyArray<{ name: string; type: ColumnType }>;
|
||||
columnStats: ReadonlyArray<ColumnStats>;
|
||||
}
|
||||
|
||||
const EMPTY_SOURCE: SourceColumns = { name: null, columnTypes: [], columnStats: [] };
|
||||
const EMPTY: DataInfo = { ...EMPTY_SOURCE, fields: [] };
|
||||
|
||||
function safeParse(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// The parsed draft, memoized by text — both the binding walk and the derived-field
|
||||
// walk read it, and providers fire often at one stable draft.
|
||||
let parseCache: { text: string; spec: unknown } | null = null;
|
||||
function parsedSpec(text: string): unknown {
|
||||
if (parseCache?.text === text) return parseCache.spec;
|
||||
const spec = safeParse(text);
|
||||
parseCache = { text, spec };
|
||||
return spec;
|
||||
}
|
||||
|
||||
/** The stored profile of the library dataset a binding references, or null. */
|
||||
function libraryProfile(refName: string): SourceColumns | null {
|
||||
const lower = refName.toLowerCase();
|
||||
const ds = useDatasetStore.getState().datasets.find((d) => d.name.toLowerCase() === lower);
|
||||
if (ds && ds.columnTypes.length > 0) {
|
||||
return { name: ds.name, columnTypes: ds.columnTypes, columnStats: ds.columnStats };
|
||||
}
|
||||
return null; // unknown name, or a dataset with no profiled columns yet
|
||||
}
|
||||
|
||||
/** Source columns for one binding: library profile, else inline rows profiled. */
|
||||
function resolveSource(spec: unknown, data: unknown): SourceColumns {
|
||||
const refName = libraryRefName(data, selfDefinedNames(spec));
|
||||
if (refName !== null) return libraryProfile(refName) ?? EMPTY_SOURCE;
|
||||
const rows = rowsForDataBinding(spec, data);
|
||||
if (!rows) return EMPTY_SOURCE; // url / generator / library miss / CSV-string values
|
||||
const profile = profileData(rows, 0);
|
||||
return { name: null, columnTypes: profile.columnTypes, columnStats: profile.columnStats };
|
||||
}
|
||||
|
||||
// Source columns memoized by (text, enclosing view). Keyed by the binding's anchor
|
||||
// path so the inlay provider's many per-line queries within one view profile once.
|
||||
// A re-import that changes a dataset's columns under unchanged text serves stale
|
||||
// hints until the next keystroke — harmless, since hints are additive.
|
||||
let sourceCache: { text: string; byAnchor: Map<string, SourceColumns> } | null = null;
|
||||
function sourceColumns(
|
||||
text: string,
|
||||
spec: unknown,
|
||||
anchorKey: string,
|
||||
data: unknown,
|
||||
): SourceColumns {
|
||||
if (!sourceCache || sourceCache.text !== text) sourceCache = { text, byAnchor: new Map() };
|
||||
const hit = sourceCache.byAnchor.get(anchorKey);
|
||||
if (hit) return hit;
|
||||
const result = resolveSource(spec, data);
|
||||
sourceCache.byAnchor.set(anchorKey, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared resolve step: the source columns of the binding in scope at `offset`,
|
||||
* with the parsed spec and cursor path so callers can layer derived fields on top
|
||||
* without re-parsing or re-walking.
|
||||
*/
|
||||
function resolveAt(
|
||||
text: string,
|
||||
offset: number,
|
||||
): {
|
||||
source: SourceColumns;
|
||||
spec: unknown;
|
||||
path: ReadonlyArray<string | number>;
|
||||
} {
|
||||
const spec = parsedSpec(text);
|
||||
const path = pathAtOffset(text, offset);
|
||||
const binding = dataBindingAtPath(spec, path);
|
||||
const source = binding
|
||||
? sourceColumns(text, spec, JSON.stringify(binding.anchorPath), binding.data)
|
||||
: EMPTY_SOURCE;
|
||||
return { source, spec, path };
|
||||
}
|
||||
|
||||
/** Source columns of the binding in scope at `offset` — no derived-field merge. */
|
||||
function sourceAt(text: string, offset: number): SourceColumns {
|
||||
return text.trim() === '' ? EMPTY_SOURCE : resolveAt(text, offset).source;
|
||||
}
|
||||
|
||||
/** The data context at `offset` in the editor's draft `text`. */
|
||||
export function dataInfoAt(text: string, offset: number): DataInfo {
|
||||
if (text.trim() === '') return EMPTY;
|
||||
const { source, spec, path } = resolveAt(text, offset);
|
||||
|
||||
const fields: FieldHint[] = source.columnTypes.map((c) => ({
|
||||
name: c.name,
|
||||
type: c.type,
|
||||
derived: false,
|
||||
}));
|
||||
const seen = new Set(fields.map((f) => f.name));
|
||||
for (const derived of derivedFieldNamesAtPath(spec, path)) {
|
||||
if (!seen.has(derived)) {
|
||||
fields.push({ name: derived, type: null, derived: true });
|
||||
seen.add(derived);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: source.name,
|
||||
columnTypes: source.columnTypes,
|
||||
columnStats: source.columnStats,
|
||||
fields,
|
||||
};
|
||||
}
|
||||
|
||||
/** Source columns + derived fields available at `offset` (for completion). */
|
||||
export function availableFieldsAt(text: string, offset: number): ReadonlyArray<FieldHint> {
|
||||
return dataInfoAt(text, offset).fields;
|
||||
}
|
||||
|
||||
/** Source columns + inferred types in scope at `offset` (for facet/repeat defaults). */
|
||||
export function boundColumnsAt(
|
||||
text: string,
|
||||
offset: number,
|
||||
): ReadonlyArray<{ name: string; type: ColumnType }> {
|
||||
return sourceAt(text, offset).columnTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source columns with their numeric extent in scope at `offset` (for parameter
|
||||
* scaffolding — a slider's bounds come from the field's `numericExtent`). Zips the
|
||||
* profile's type and stat arrays by name; a non-numeric column carries a null extent.
|
||||
*/
|
||||
export function boundParamFieldsAt(text: string, offset: number): ReadonlyArray<ParamField> {
|
||||
const source = sourceAt(text, offset);
|
||||
const extentByName = new Map(source.columnStats.map((s) => [s.name, s.numericExtent]));
|
||||
return source.columnTypes.map((c) => ({
|
||||
name: c.name,
|
||||
type: c.type,
|
||||
extent: extentByName.get(c.name) ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** The inferred type of a source field `name` at `offset`, or null (for inlay). */
|
||||
export function fieldTypeAt(text: string, offset: number, name: string): ColumnType | null {
|
||||
return sourceAt(text, offset).columnTypes.find((c) => c.name === name)?.type ?? null;
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import type { VisualizationSpec } from 'vega-embed';
|
||||
import type { Config } from 'vega-lite';
|
||||
import { collectFontFamilies } from '@core/custom-theme';
|
||||
import { embedFontsInSvg } from '@core/chart-export';
|
||||
import { pickResultDataset, pickSourceDataset } from '@core/result-data';
|
||||
import { inspectViewLabel, inspectableViews } from '@core/inspect-views';
|
||||
import type { FontAsset } from '@core/font-asset';
|
||||
|
||||
/** Options for `RenderHandle.toImageURL` (spec §08 → Per-chart export). */
|
||||
@@ -44,15 +44,82 @@ interface ImageExportOptions {
|
||||
embedFonts?: ReadonlyArray<FontAsset>;
|
||||
}
|
||||
|
||||
/** The two ends of the chart's data pipeline, for the data inspector (spec §04). */
|
||||
export interface InspectedData {
|
||||
/** Parsed source rows, before the spec's transforms run — the input. */
|
||||
/** One inspectable drawn table — the two ends of its pipeline (spec §04). */
|
||||
export interface InspectableTable {
|
||||
/** Stable selection id — the resolved table's compiled name. */
|
||||
id: string;
|
||||
/** User-facing label (never a compiler name — `@core/inspect-views`). */
|
||||
label: string;
|
||||
/** Parsed source rows, before the view's transforms run — the input. */
|
||||
input: ReadonlyArray<Record<string, unknown>>;
|
||||
/** Post-transform rows the chart draws — the output (equals `input` when the
|
||||
* spec has no transforms). */
|
||||
/** Post-transform rows the marks draw — the output (equals `input` when the view
|
||||
* has no transforms). */
|
||||
resolved: ReadonlyArray<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The chart's inspectable data: one table per distinct table the marks draw, in
|
||||
* document order (a multi-view spec yields several). `tables` is empty when the
|
||||
* chart draws nothing inspectable, distinct from a `null` handle (no chart).
|
||||
*/
|
||||
export interface InspectedData {
|
||||
tables: InspectableTable[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounce for the live data inspector (spec §04; multi-view scope doc M5). A
|
||||
* brush drag pulses `addDataListener` continuously; coalescing to one re-read per
|
||||
* ~quiet-frame keeps the table feeling live without thrashing the grid on every
|
||||
* pixel of the drag.
|
||||
*/
|
||||
export const LIVE_INSPECT_DEBOUNCE_MS = 120;
|
||||
|
||||
/** The minimal Vega `View` surface the live-inspect watcher needs. */
|
||||
interface DataChangeView {
|
||||
addDataListener(name: string, handler: () => void): unknown;
|
||||
removeDataListener(name: string, handler: () => void): unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach debounced listeners to every table the inspector shows so an interactive
|
||||
* selection that recomputes a drawn table (a `filter: {param}` brush) re-reads the
|
||||
* inspector live — see `RenderHandle.onDataChange`. Watches the union of each
|
||||
* drawn table's resolved + input names (deduped); a highlight selection changes no
|
||||
* data, so none fire. Returns an unsubscribe that cancels any pending re-read and
|
||||
* detaches the listeners — but skips detaching once the view is finalized, since
|
||||
* `view.finalize()` has already dropped every listener (and the unmount cleanup
|
||||
* order can run this after the destroy). `isFinalized` is a getter, not a boolean,
|
||||
* so it reflects the view's state at unsubscribe time, not subscribe time.
|
||||
*/
|
||||
export function watchInspectableData(
|
||||
view: DataChangeView,
|
||||
vgSpec: unknown,
|
||||
onChange: () => void,
|
||||
isFinalized: () => boolean,
|
||||
): () => void {
|
||||
if (isFinalized()) return () => {};
|
||||
const names = [...new Set(inspectableViews(vgSpec).flatMap((v) => [v.resolved, v.input]))];
|
||||
if (names.length === 0) return () => {};
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const handler = (): void => {
|
||||
if (timer !== null) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
timer = null;
|
||||
onChange();
|
||||
}, LIVE_INSPECT_DEBOUNCE_MS);
|
||||
};
|
||||
for (const name of names) view.addDataListener(name, handler);
|
||||
|
||||
return () => {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (!isFinalized()) for (const name of names) view.removeDataListener(name, handler);
|
||||
};
|
||||
}
|
||||
|
||||
export interface RenderHandle {
|
||||
/** Finalize the underlying Vega view and clear the node. */
|
||||
destroy(): void;
|
||||
@@ -78,20 +145,29 @@ export interface RenderHandle {
|
||||
*/
|
||||
resize(): void;
|
||||
/**
|
||||
* The chart's input and resolved (post-transform) rows — for the data inspector
|
||||
* (spec §04). Reads the live view's compiled dataflow once: lists its datasets,
|
||||
* picks the most-upstream
|
||||
* source and most-downstream result (`@core/result-data`), and returns both
|
||||
* tables' rows. This is the one place besides export that reaches into the view,
|
||||
* so the embedding boundary holds (arch 05 §1–§2) — callers get rows, never the
|
||||
* `view`.
|
||||
* The chart's inspectable tables — for the data inspector (spec §04). Enumerates
|
||||
* the tables the marks draw from the compiled Vega spec (`@core/inspect-views`),
|
||||
* and for each reads its input + resolved rows from the live view. A multi-view
|
||||
* spec yields several tables; a unit spec yields one. This is the one place
|
||||
* besides export that reaches into the view, so the embedding boundary holds
|
||||
* (arch 05 §1–§2) — callers get rows, never the `view`.
|
||||
*
|
||||
* Returns `null` when there is nothing to inspect (the view was finalized, or
|
||||
* the spec produced no inspectable table). Either side can be `[]` when its
|
||||
* table is empty — a real signal (e.g. a filter removed every row on the
|
||||
* resolved side), kept distinct from "no chart" so the inspector can say which.
|
||||
* Returns `null` when the view was finalized (no chart). The result's `tables`
|
||||
* is empty when a chart draws nothing inspectable, and any table's `input`/
|
||||
* `resolved` can be `[]` (e.g. a filter removed every row) — kept distinct from
|
||||
* "no chart" so the inspector can say which.
|
||||
*/
|
||||
inspectData(): InspectedData | null;
|
||||
/**
|
||||
* Subscribe to live changes of the inspected tables, for the data inspector's
|
||||
* live mode (spec §04; multi-view scope doc M5). An interactive selection that
|
||||
* *filters* a downstream view recomputes that view's compiled table in place —
|
||||
* no re-embed — so a static inspector would show stale rows until the next full
|
||||
* render; this fires (debounced) so the caller can re-read via `inspectData()`.
|
||||
* A highlight selection (a `condition` encoding) changes no data, so it never
|
||||
* fires. Returns an unsubscribe; a no-op when the view is already finalized.
|
||||
*/
|
||||
onDataChange(listener: () => void): () => void;
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
@@ -297,22 +373,22 @@ export async function renderSpec(
|
||||
},
|
||||
inspectData() {
|
||||
if (finalized) return null;
|
||||
// Enumerate the dataflow's datasets once, then pick the input + result
|
||||
// tables. getState with a truthy `data` filter is Vega's documented way to
|
||||
// list datasets (vega/editor's Data Viewer does the same) — we read only the
|
||||
// keys. Rows come from view.data(name), which hands back the live array (no copy).
|
||||
const state = result.view.getState({
|
||||
data: () => true,
|
||||
signals: () => false,
|
||||
recurse: true,
|
||||
}) as { data?: Record<string, unknown> };
|
||||
const names = Object.keys(state.data ?? {});
|
||||
const sourceName = pickSourceDataset(names);
|
||||
const resultName = pickResultDataset(names);
|
||||
if (sourceName === null && resultName === null) return null;
|
||||
const rows = (name: string | null): ReadonlyArray<Record<string, unknown>> =>
|
||||
name === null ? [] : ((result.view.data(name) ?? []) as Record<string, unknown>[]);
|
||||
return { input: rows(sourceName), resolved: rows(resultName) };
|
||||
// The tables the marks draw + their input lineage come from the compiled Vega
|
||||
// spec (a byproduct of the embed, not recompiled); the rows come from
|
||||
// view.data(name), which hands back the live array (no copy).
|
||||
const views = inspectableViews(result.vgSpec);
|
||||
const rows = (name: string): ReadonlyArray<Record<string, unknown>> =>
|
||||
(result.view.data(name) ?? []) as Record<string, unknown>[];
|
||||
const tables = views.map((v, i) => ({
|
||||
id: v.resolved,
|
||||
label: inspectViewLabel(v.input, i),
|
||||
input: rows(v.input),
|
||||
resolved: rows(v.resolved),
|
||||
}));
|
||||
return { tables };
|
||||
},
|
||||
onDataChange(listener) {
|
||||
return watchInspectableData(result.view, result.vgSpec, listener, () => finalized);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Live-inspection watcher (`watchInspectableData`) — the wiring behind
|
||||
* `RenderHandle.onDataChange` (spec §04; multi-view scope doc M5). Verified against
|
||||
* a fake Vega view + fake timers; the real selection→filter→data recompute is an
|
||||
* integration behavior exercised manually (renderSpec is vega-embed-bound).
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { LIVE_INSPECT_DEBOUNCE_MS, watchInspectableData } from './chart-renderer';
|
||||
|
||||
/** A compiled-Vega-shape spec: one drawn table data_0, sourced from source_0. */
|
||||
const vgSpec = {
|
||||
data: [{ name: 'source_0' }, { name: 'data_0', source: 'source_0' }],
|
||||
marks: [{ type: 'symbol', from: { data: 'data_0' } }],
|
||||
};
|
||||
|
||||
function fakeView() {
|
||||
const listeners = new Map<string, Set<() => void>>();
|
||||
return {
|
||||
added: [] as string[],
|
||||
removed: [] as string[],
|
||||
addDataListener(name: string, handler: () => void) {
|
||||
const set = listeners.get(name) ?? new Set<() => void>();
|
||||
set.add(handler);
|
||||
listeners.set(name, set);
|
||||
this.added.push(name);
|
||||
},
|
||||
removeDataListener(name: string, handler: () => void) {
|
||||
listeners.get(name)?.delete(handler);
|
||||
this.removed.push(name);
|
||||
},
|
||||
fire(name: string) {
|
||||
for (const handler of listeners.get(name) ?? []) handler();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('watchInspectableData', () => {
|
||||
it('watches both the resolved and input table of each drawn view', () => {
|
||||
const view = fakeView();
|
||||
watchInspectableData(
|
||||
view,
|
||||
vgSpec,
|
||||
() => {},
|
||||
() => false,
|
||||
);
|
||||
expect(new Set(view.added)).toEqual(new Set(['data_0', 'source_0']));
|
||||
});
|
||||
|
||||
it('debounces a burst of changes into a single re-read', () => {
|
||||
const view = fakeView();
|
||||
const onChange = vi.fn();
|
||||
watchInspectableData(view, vgSpec, onChange, () => false);
|
||||
|
||||
view.fire('data_0');
|
||||
view.fire('data_0');
|
||||
view.fire('data_0'); // a brush drag pulsing
|
||||
expect(onChange).not.toHaveBeenCalled(); // still within the debounce window
|
||||
|
||||
vi.advanceTimersByTime(LIVE_INSPECT_DEBOUNCE_MS);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('unsubscribe detaches listeners and cancels a pending re-read', () => {
|
||||
const view = fakeView();
|
||||
const onChange = vi.fn();
|
||||
const stop = watchInspectableData(view, vgSpec, onChange, () => false);
|
||||
|
||||
view.fire('data_0');
|
||||
stop();
|
||||
vi.advanceTimersByTime(LIVE_INSPECT_DEBOUNCE_MS * 2);
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled(); // pending re-read cancelled
|
||||
expect(new Set(view.removed)).toEqual(new Set(['data_0', 'source_0']));
|
||||
});
|
||||
|
||||
it('is a no-op when the view is already finalized at subscribe', () => {
|
||||
const view = fakeView();
|
||||
const stop = watchInspectableData(
|
||||
view,
|
||||
vgSpec,
|
||||
() => {},
|
||||
() => true,
|
||||
);
|
||||
expect(view.added).toEqual([]);
|
||||
stop(); // safe
|
||||
});
|
||||
|
||||
it('after finalize, unsubscribe cancels the timer but does not touch the dead view', () => {
|
||||
const view = fakeView();
|
||||
const onChange = vi.fn();
|
||||
let finalized = false;
|
||||
const stop = watchInspectableData(view, vgSpec, onChange, () => finalized);
|
||||
|
||||
view.fire('data_0');
|
||||
finalized = true; // view.finalize() ran (dropping its own listeners) before cleanup
|
||||
stop();
|
||||
vi.advanceTimersByTime(LIVE_INSPECT_DEBOUNCE_MS * 2);
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(view.removed).toEqual([]); // didn't call removeDataListener on a dead view
|
||||
});
|
||||
|
||||
it('does nothing for a spec that draws no inspectable table', () => {
|
||||
const view = fakeView();
|
||||
const stop = watchInspectableData(
|
||||
view,
|
||||
{ data: [], marks: [] },
|
||||
() => {},
|
||||
() => false,
|
||||
);
|
||||
expect(view.added).toEqual([]);
|
||||
stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* The cursor-aware CodeLens skeleton shared by the editor's structural surfaces
|
||||
* (docs/architecture/08 → editor augmentation). A "cursor lens" follows the view the
|
||||
* cursor sits in — recomputing only when the *enclosing* thing changes, not on every
|
||||
* keystroke — and is installed **per editor** because its lens commands need the
|
||||
* editor handle to apply the edit. The composition CodeLens (`spec-transform-actions`),
|
||||
* the data-transform scaffold (`spec-transform-scaffold`), and the parameter scaffold
|
||||
* (`spec-param-scaffold`) are all this one shape; only three things vary between them:
|
||||
* - `resolve(text, offset)` — the site the cursor is in (or null when in none);
|
||||
* - `keyOf(site)` — a string that changes exactly when the lenses should refresh
|
||||
* (so typing *within* the resolved site doesn't churn them);
|
||||
* - `lensesOf(site, model, lens)` — the lenses for that site, built with the shared
|
||||
* `lens` factory and closing over the caller's editor commands.
|
||||
*
|
||||
* The draft gate (scaffolding only edits the active draft; the published view is a
|
||||
* read-only reference) and the position guard live here too, since every consumer
|
||||
* shares them. App-layer glue — imports `monaco` and reads `useSnippetStore`.
|
||||
*/
|
||||
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
|
||||
/** Builds a zero-width CodeLens at the start of `line` invoking command `id` with `args`. */
|
||||
export type LensFactory = (
|
||||
line: number,
|
||||
title: string,
|
||||
id: string | null,
|
||||
args: unknown[],
|
||||
) => monaco.languages.CodeLens;
|
||||
|
||||
/** What varies between one cursor-lens consumer and another. */
|
||||
export interface CursorLensSpec<Site> {
|
||||
/** The site the cursor is in, or null when it is in none. */
|
||||
resolve(text: string, offset: number): Site | null;
|
||||
/** A key that changes exactly when the lenses should refresh. */
|
||||
keyOf(site: Site): string;
|
||||
/** The lenses for `site`, built with `lens` (which closes over the caller's commands). */
|
||||
lensesOf(
|
||||
site: Site,
|
||||
model: monaco.editor.ITextModel,
|
||||
lens: LensFactory,
|
||||
): monaco.languages.CodeLens[];
|
||||
}
|
||||
|
||||
const NO_LENSES: monaco.languages.CodeLensList = { lenses: [], dispose() {} };
|
||||
|
||||
/** True when the active draft is being edited (the only place scaffolding acts). */
|
||||
function onDraft(): boolean {
|
||||
return useSnippetStore.getState().editorView === 'draft';
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a cursor-aware CodeLens provider on `editor`, disposed with it. The provider
|
||||
* refreshes when `keyOf(resolve(...))` changes under the cursor; between refreshes
|
||||
* Monaco reuses the last lenses. Returns a disposable that tears down the cursor
|
||||
* subscription, the refresh emitter, and the provider registration together.
|
||||
*/
|
||||
export function installCursorLens<Site>(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
spec: CursorLensSpec<Site>,
|
||||
): monaco.IDisposable {
|
||||
const lens: LensFactory = (line, title, id, args) => ({
|
||||
range: new monaco.Range(line, 1, line, 1),
|
||||
command: { id: id ?? '', title, arguments: args },
|
||||
});
|
||||
|
||||
/** Resolve the site under the cursor, gated to the draft. */
|
||||
const siteUnderCursor = (model: monaco.editor.ITextModel | null): Site | null => {
|
||||
const pos = editor.getPosition();
|
||||
if (!model || !pos || !onDraft()) return null;
|
||||
return spec.resolve(model.getValue(), model.getOffsetAt(pos));
|
||||
};
|
||||
|
||||
const onDidChange = new monaco.Emitter<monaco.languages.CodeLensProvider>();
|
||||
const provider: monaco.languages.CodeLensProvider = {
|
||||
onDidChange: onDidChange.event,
|
||||
provideCodeLenses(model) {
|
||||
const site = siteUnderCursor(model);
|
||||
if (!site) return NO_LENSES;
|
||||
return { lenses: spec.lensesOf(site, model, lens), dispose() {} };
|
||||
},
|
||||
};
|
||||
const registration = monaco.languages.registerCodeLensProvider('json', provider);
|
||||
|
||||
let lastKey = '';
|
||||
const cursorSub = editor.onDidChangeCursorPosition(() => {
|
||||
const site = siteUnderCursor(editor.getModel());
|
||||
const key = site ? spec.keyOf(site) : '';
|
||||
if (key !== lastKey) {
|
||||
lastKey = key;
|
||||
onDidChange.fire(provider);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
dispose() {
|
||||
cursorSub.dispose();
|
||||
onDidChange.dispose();
|
||||
registration.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Monaco snippet glue shared by the scaffold services (`spec-transform-scaffold`,
|
||||
* `spec-param-scaffold`; docs/architecture/08 → editor augmentation): inserting a
|
||||
* `${n:default}` template as a live, Tab-through session, and the completion-side
|
||||
* plumbing — the slot a scaffold suggestion replaces and the suggestion item itself.
|
||||
* The pure text math (offsets, comma affixing) is `core/spec-snippet`; this file is
|
||||
* only what needs Monaco types or the editor handle.
|
||||
*/
|
||||
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
|
||||
import { arrayAffixes } from '@core/spec-snippet';
|
||||
|
||||
/** Monaco's snippet contribution — the public entry to insert a `${n:…}` template with tab stops. */
|
||||
interface SnippetInserter extends monaco.editor.IEditorContribution {
|
||||
insert(template: string, opts?: { adjustWhitespace?: boolean }): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert `template` at `offset` through Monaco's snippet engine, so its
|
||||
* `${n:default}` tab stops become a live, Tab-through session. Places the cursor first
|
||||
* (the controller inserts at the selection). `adjustWhitespace: false` keeps the engine
|
||||
* from re-basing our explicit indentation to the insertion line's — it otherwise leaves
|
||||
* a multi-line block's closing bracket under-indented. No-op if the contribution is
|
||||
* absent (it ships in `edcore.main`, so this is just defensive).
|
||||
*/
|
||||
export function insertSnippetAt(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
offset: number,
|
||||
template: string,
|
||||
): void {
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
editor.setPosition(model.getPositionAt(offset));
|
||||
editor.focus();
|
||||
editor.getContribution<SnippetInserter>('snippetController2')?.insert(template, {
|
||||
adjustWhitespace: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** The array-element slot a scaffold completion fills. */
|
||||
export interface ScaffoldSlot {
|
||||
/** The range the suggestion replaces (the bare partial the user typed). */
|
||||
range: monaco.Range;
|
||||
/** Comma affixes that keep the surrounding array valid around the inserted entry. */
|
||||
lead: string;
|
||||
trail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The completion slot at `position` for an array-element scaffold: the bare alphabetic
|
||||
* partial before the cursor, parsed from the line — never `getWordUntilPosition`, whose
|
||||
* JSON wordPattern reaches across punctuation (docs/architecture/08 → completion
|
||||
* ranges) — plus the comma affixes for the element the suggestion becomes.
|
||||
*/
|
||||
export function scaffoldSlotAt(
|
||||
model: monaco.editor.ITextModel,
|
||||
position: monaco.Position,
|
||||
text: string,
|
||||
offset: number,
|
||||
): ScaffoldSlot {
|
||||
const before = model.getLineContent(position.lineNumber).slice(0, position.column - 1);
|
||||
const partial = /[A-Za-z]*$/.exec(before)?.[0] ?? '';
|
||||
const range = new monaco.Range(
|
||||
position.lineNumber,
|
||||
position.column - partial.length,
|
||||
position.lineNumber,
|
||||
position.column,
|
||||
);
|
||||
const { lead, trail } = arrayAffixes(text, offset - partial.length, offset);
|
||||
return { range, lead, trail };
|
||||
}
|
||||
|
||||
/**
|
||||
* A catalog entry as a snippet completion in `slot` — comma-affixed to keep the array
|
||||
* valid, and sorted by catalog index above the schema's generic items.
|
||||
*/
|
||||
export function scaffoldSuggestion(
|
||||
slot: ScaffoldSlot,
|
||||
index: number,
|
||||
item: { label: string; detail: string; body: string; documentation?: monaco.IMarkdownString },
|
||||
): monaco.languages.CompletionItem {
|
||||
return {
|
||||
label: item.label,
|
||||
kind: monaco.languages.CompletionItemKind.Snippet,
|
||||
detail: item.detail,
|
||||
documentation: item.documentation,
|
||||
insertText: `${slot.lead}${item.body}${slot.trail}`,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range: slot.range,
|
||||
sortText: `0${String(index).padStart(2, '0')}`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Extract-to-Dataset as an editor action (spec §03F; docs/architecture/08 →
|
||||
* editor augmentation) — the data-facing counterpart to the wrap/config actions.
|
||||
*
|
||||
* Resolves the embedded data of the **view the cursor sits in** (the nearest
|
||||
* enclosing `data` binding, honoring Vega-Lite's parent→child inheritance), seeds
|
||||
* the Extract modal with it, and opens the modal. The cursor picks which view to
|
||||
* extract; a single-view spec resolves to the root binding from any cursor, so the
|
||||
* common case needs no thought. Two binding shapes are liftable — a view's inline
|
||||
* `data.values`, and a `{ name }` reference to the spec's own top-level `datasets`
|
||||
* (the data lives in the `datasets` map; we pre-fill the modal with its name).
|
||||
*
|
||||
* The toolbar offers the action whenever *some* view has extractable data
|
||||
* (`hasExtractableData`); when the focused view itself has none — it references a
|
||||
* library dataset, a url, a generator — there is nothing to lift here, so guide the
|
||||
* user to a view that does rather than silently extract the wrong one.
|
||||
*/
|
||||
|
||||
import type * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
|
||||
import { pathAtOffset } from '@core/spec-cursor';
|
||||
import { dataBindingAtPath } from '@core/spec-data';
|
||||
import { inlineValuesOf, selfDefinedPayloadOf } from '@core/spec-inline-data';
|
||||
import { openModal } from '../modals/ModalCoordinator';
|
||||
import { type ExtractTarget, useExtractStore } from '../stores/ExtractStore';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
|
||||
// TODO(ux-second-pass): toolbar-only — no F1-palette accelerator like the sibling
|
||||
// wrap/config actions, since this opens a modal rather than an in-place edit.
|
||||
/** Open Extract-to-Dataset scoped to the view at the cursor. */
|
||||
export function runExtract(editor: monaco.editor.IStandaloneCodeEditor): void {
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
const text = model.getValue();
|
||||
|
||||
let spec: unknown;
|
||||
try {
|
||||
spec = JSON.parse(text);
|
||||
} catch {
|
||||
notify({
|
||||
kind: 'info',
|
||||
title: 'Can’t extract yet',
|
||||
message: 'Fix the JSON so the spec parses, then extract.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const position = editor.getPosition();
|
||||
const offset = position ? model.getOffsetAt(position) : 0;
|
||||
const binding = dataBindingAtPath(spec, pathAtOffset(text, offset));
|
||||
|
||||
// Inline `values` → rewrite this view's data block; a self-defined `datasets`
|
||||
// reference → lift the named entry and pre-fill the modal with that name.
|
||||
const inline = binding && inlineValuesOf(binding.data);
|
||||
const selfDefined = binding && !inline && selfDefinedPayloadOf(spec, binding.data);
|
||||
let seed: {
|
||||
source: ReturnType<typeof inlineValuesOf>;
|
||||
target: ExtractTarget;
|
||||
name?: string;
|
||||
} | null = null;
|
||||
if (binding && inline) {
|
||||
seed = { source: inline, target: { kind: 'inline', anchorPath: binding.anchorPath } };
|
||||
} else if (binding && selfDefined) {
|
||||
const datasetName = (binding.data as { name: string }).name;
|
||||
seed = {
|
||||
source: selfDefined,
|
||||
target: { kind: 'self-defined', datasetName },
|
||||
name: datasetName,
|
||||
};
|
||||
}
|
||||
|
||||
if (!seed || !seed.source) {
|
||||
notify({
|
||||
kind: 'info',
|
||||
title: 'No data to extract here',
|
||||
message: 'Place the cursor in a view with inline or embedded data to extract it.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
useExtractStore.getState().begin({ source: seed.source, target: seed.target, name: seed.name });
|
||||
openModal('extract');
|
||||
}
|
||||
@@ -40,11 +40,16 @@ import { useCustomThemeStore } from '../stores/CustomThemeStore';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
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;
|
||||
try {
|
||||
parsed = JSON.parse(model.getValue());
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
notify({
|
||||
kind: 'error',
|
||||
@@ -57,7 +62,7 @@ function parseSpecObject(model: monaco.editor.ITextModel): Record<string, unknow
|
||||
notify({
|
||||
kind: 'error',
|
||||
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;
|
||||
}
|
||||
@@ -85,7 +90,7 @@ function replaceDocument(
|
||||
export function runMergeChartTheme(editor: monaco.editor.IStandaloneCodeEditor): void {
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
const spec = parseSpecObject(model);
|
||||
const spec = parseSpecObject(model.getValue());
|
||||
if (!spec) return;
|
||||
|
||||
const { chartTheme, uiTheme } = useAppStore.getState();
|
||||
@@ -122,7 +127,7 @@ function extractableConfig(editor: monaco.editor.IStandaloneCodeEditor): {
|
||||
} | null {
|
||||
const model = editor.getModel();
|
||||
if (!model) return null;
|
||||
const spec = parseSpecObject(model);
|
||||
const spec = parseSpecObject(model.getValue());
|
||||
if (!spec) return null;
|
||||
|
||||
const { spec: rest, config } = extractConfigFromSpec(spec);
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 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). Monaco merges this with the schema's own hovers. (Expression-string
|
||||
* hovers and completion live in services/spec-expression-hints.)
|
||||
* - **Inlay hints** — a faint `·<data-type>` beside each `field` (the column's
|
||||
* raw type: number/string/date/boolean), annotation without touching the text.
|
||||
* Deliberately the *data* type, not the encoding `type` — they share the line,
|
||||
* so a `: quantitative` here would read as annotating the adjacent `"type"`,
|
||||
* which the hint never describes.
|
||||
*
|
||||
* 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 { valueKeyAtOffset } from '@core/spec-cursor';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import {
|
||||
availableFieldsAt,
|
||||
dataInfoAt,
|
||||
fieldTypeAt,
|
||||
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']);
|
||||
|
||||
/** 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_` }];
|
||||
}
|
||||
// The column's raw data type (number/string/date/boolean) — same vocabulary as
|
||||
// the inlay hint, not the Vega-Lite encoding `type` the user declares.
|
||||
const lines = [`**${hint.name}** · \`${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 text = model.getValue();
|
||||
const offset = model.getOffsetAt(position);
|
||||
const key = valueKeyAtOffset(text, offset);
|
||||
if (key === null || !FIELD_KEYS.has(key)) return { suggestions: [] };
|
||||
const fields = availableFieldsAt(text, offset);
|
||||
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' : f.type,
|
||||
insertText: f.name,
|
||||
range,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// All three providers annotate the draft only: they derive their field set from
|
||||
// the draft buffer (dataInfoAt), 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 a field name: its type + stats, resolved at this view's data binding.
|
||||
// (Expression-string hovers live in services/spec-expression-hints.)
|
||||
const word = model.getWordAtPosition(position);
|
||||
if (word) {
|
||||
const info = dataInfoAt(text, offset);
|
||||
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 text = model.getValue();
|
||||
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;
|
||||
// Resolve the type at this field's own data binding — views in a
|
||||
// composition can bind different datasets. The offset points inside the
|
||||
// field's value so the path resolves to its enclosing view.
|
||||
const valueCol = match.index + match[0].length - match[1].length;
|
||||
const offset = model.getOffsetAt({ lineNumber: line, column: valueCol });
|
||||
const type = fieldTypeAt(text, offset, match[1]);
|
||||
if (!type) continue; // unknown or derived (no type to annotate)
|
||||
hints.push({
|
||||
position: { lineNumber: line, column: match.index + match[0].length + 1 },
|
||||
// The column's raw data type (number/string/date/boolean), not the
|
||||
// Vega-Lite encoding `type`: a `: quantitative` here would read as an
|
||||
// annotation of the adjacent `"type"`, which the hint never describes.
|
||||
// The leading `·` marks it as a data fact about the field, not JSON syntax.
|
||||
label: `·${type}`,
|
||||
kind: monaco.languages.InlayHintKind.Type,
|
||||
paddingLeft: true,
|
||||
});
|
||||
}
|
||||
return { hints, dispose() {} };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Expression-aware editor intelligence (docs/architecture/08 → editor
|
||||
* augmentation) — the help the Vega-Lite JSON schema *structurally can't* give,
|
||||
* because the expression language lives inside opaque JSON strings (`calculate`,
|
||||
* `filter`, `expr`, `test`):
|
||||
*
|
||||
* - **Completion** — after `datum.` / `datum['…'`, the bound view's real column
|
||||
* names; otherwise the expression language's functions (`if`, `datetime`, …)
|
||||
* and constants (`PI`, `E`), names derived from the parser itself
|
||||
* (core/vega-expr-catalog).
|
||||
* - **Signature help** — parameter hints for the curated common functions, with
|
||||
* the active argument tracked as you type past each comma.
|
||||
* - **Hover** — over an expression string, a live validity check
|
||||
* (core/expr-validate, the same parser the chart uses).
|
||||
* - **Markers** — every expression string is parsed; a malformed one squiggles
|
||||
* in place. This is the app's only editor-marker source besides the JSON
|
||||
* worker, so it owns a distinct marker namespace (`vega-expr`).
|
||||
*
|
||||
* The three language providers register **once, globally for JSON** (like the
|
||||
* schema, formatter, and dataset hints); the marker pass is **per editor**
|
||||
* (it writes to a specific model and is torn down with it). All read the draft
|
||||
* buffer and are gated to the draft view — the published view is a read-only
|
||||
* reference, where expression authoring help is marginal.
|
||||
*/
|
||||
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
|
||||
import { activeCall, validateExpression } from '@core/expr-validate';
|
||||
import { valueKeyAtOffset, stringValueAtOffset } from '@core/spec-cursor';
|
||||
import { EXPRESSION_KEYS, expressionStringsIn } from '@core/spec-expressions';
|
||||
import {
|
||||
EXPR_CONSTANT_NAMES,
|
||||
EXPR_FUNCTION_NAMES,
|
||||
EXPR_SIGNATURES,
|
||||
signatureLabel,
|
||||
} from '@core/vega-expr-catalog';
|
||||
import { availableFieldsAt, type FieldHint } from './active-dataset';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
|
||||
/** A name that can follow `datum.`; others (with spaces, etc.) need bracket access. */
|
||||
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
||||
/** Cursor sitting in `datum.<partial>` — completing a field by dot access. */
|
||||
const DATUM_DOT = /datum\.([A-Za-z0-9_$]*)$/;
|
||||
/** Cursor sitting in `datum['<partial>` — completing a field by bracket access. */
|
||||
const DATUM_BRACKET = /datum\[\s*['"]([^'"]*)$/;
|
||||
/** Debounce for the marker recompute — responsive without thrashing on every key. */
|
||||
const MARKER_DEBOUNCE_MS = 300;
|
||||
/** Marker namespace, kept distinct from the JSON worker's own markers. */
|
||||
const MARKER_OWNER = 'vega-expr';
|
||||
|
||||
/**
|
||||
* The expression text from the start of the cursor's expression string up to the
|
||||
* cursor, or null when the cursor is not inside one (or not on the draft view).
|
||||
* Drives both completion ("what am I typing?") and signature help ("which call am
|
||||
* I in?"). The containing string is located via the core enumerator so the prefix
|
||||
* excludes the JSON `"key": "` framing.
|
||||
*/
|
||||
function expressionPrefixAt(
|
||||
model: monaco.editor.ITextModel,
|
||||
position: monaco.Position,
|
||||
): string | null {
|
||||
if (useSnippetStore.getState().editorView !== 'draft') return null;
|
||||
const text = model.getValue();
|
||||
const offset = model.getOffsetAt(position);
|
||||
const key = valueKeyAtOffset(text, offset);
|
||||
if (key === null || !EXPRESSION_KEYS.has(key)) return null;
|
||||
for (const span of expressionStringsIn(text)) {
|
||||
if (offset >= span.offset && offset <= span.offset + span.length) {
|
||||
return text.slice(span.offset, offset);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A completion item for a data field (a real column or a transform-derived one). */
|
||||
function fieldItem(field: FieldHint, range: monaco.IRange): monaco.languages.CompletionItem {
|
||||
return {
|
||||
label: field.name,
|
||||
kind:
|
||||
field.derived || field.type === null
|
||||
? monaco.languages.CompletionItemKind.Variable
|
||||
: monaco.languages.CompletionItemKind.Field,
|
||||
detail: field.derived || field.type === null ? 'derived field' : field.type,
|
||||
insertText: field.name,
|
||||
range,
|
||||
};
|
||||
}
|
||||
|
||||
/** A completion item for an expression function — curated signature as the detail. */
|
||||
function functionItem(name: string, range: monaco.IRange): monaco.languages.CompletionItem {
|
||||
const sig = EXPR_SIGNATURES[name];
|
||||
const noArgs = sig !== undefined && sig.params.length === 0;
|
||||
return {
|
||||
label: name,
|
||||
kind: monaco.languages.CompletionItemKind.Function,
|
||||
detail: sig ? signatureLabel(sig) : undefined,
|
||||
documentation: sig ? { value: sig.doc } : undefined,
|
||||
// Land the cursor inside the parens (snippet `$1`), unless the function takes
|
||||
// no arguments — then close the call outright.
|
||||
insertText: noArgs ? `${name}()` : `${name}($1)`,
|
||||
insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
range,
|
||||
};
|
||||
}
|
||||
|
||||
/** A completion item for an expression constant (`PI`, `E`, …). */
|
||||
function constantItem(name: string, range: monaco.IRange): monaco.languages.CompletionItem {
|
||||
return {
|
||||
label: name,
|
||||
kind: monaco.languages.CompletionItemKind.Constant,
|
||||
insertText: name,
|
||||
range,
|
||||
};
|
||||
}
|
||||
|
||||
let registered = false;
|
||||
|
||||
/** Register the expression completion / signature-help / hover providers once. */
|
||||
export function configureSpecExpressionHints(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
|
||||
monaco.languages.registerCompletionItemProvider('json', {
|
||||
// `.` opens field completion after `datum`; the quote characters open it inside
|
||||
// `datum['…']`; quick-suggest (strings:true) covers the function-name case.
|
||||
triggerCharacters: ['.', '"', "'"],
|
||||
provideCompletionItems(model, position) {
|
||||
const prefix = expressionPrefixAt(model, position);
|
||||
if (prefix === null) return { suggestions: [] };
|
||||
|
||||
// Build the replace range from the partial WE parse out of the prefix, never
|
||||
// from Monaco's JSON word: that language's wordPattern treats `.` and `(` as
|
||||
// word characters, so getWordUntilPosition after `datum.` (or `fn(`) returns
|
||||
// the whole `datum.`/`fn(` token — which would both mis-target the edit and
|
||||
// filter every suggestion out (none start with `datum.`).
|
||||
const replaceRange = (partialLength: number): monaco.Range =>
|
||||
new monaco.Range(
|
||||
position.lineNumber,
|
||||
position.column - partialLength,
|
||||
position.lineNumber,
|
||||
position.column,
|
||||
);
|
||||
|
||||
const dot = DATUM_DOT.exec(prefix);
|
||||
const bracket = dot ? null : DATUM_BRACKET.exec(prefix);
|
||||
if (dot || bracket) {
|
||||
const fields = availableFieldsAt(model.getValue(), model.getOffsetAt(position));
|
||||
if (fields.length === 0) return { suggestions: [] };
|
||||
// The partial typed after `datum.` / `datum['` — replace only that, leaving
|
||||
// the `datum` token before it intact.
|
||||
const partial = dot ? dot[1] : bracket![1];
|
||||
const range = replaceRange(partial.length);
|
||||
// Only identifier-safe names are usable after a dot; brackets take any name.
|
||||
const candidates = dot ? fields.filter((f) => IDENTIFIER.test(f.name)) : fields;
|
||||
return { suggestions: candidates.map((f) => fieldItem(f, range)) };
|
||||
}
|
||||
|
||||
// Function / constant context: the partial is the trailing identifier (Monaco's
|
||||
// JSON word would reach back across a preceding `(` and break filtering).
|
||||
const ident = /[A-Za-z_$][A-Za-z0-9_$]*$/.exec(prefix);
|
||||
const range = replaceRange(ident ? ident[0].length : 0);
|
||||
const suggestions: monaco.languages.CompletionItem[] = [
|
||||
...EXPR_FUNCTION_NAMES.map((name) => functionItem(name, range)),
|
||||
...EXPR_CONSTANT_NAMES.map((name) => constantItem(name, range)),
|
||||
{
|
||||
label: 'datum',
|
||||
kind: monaco.languages.CompletionItemKind.Keyword,
|
||||
detail: 'the current data record',
|
||||
insertText: 'datum',
|
||||
range,
|
||||
},
|
||||
];
|
||||
return { suggestions };
|
||||
},
|
||||
});
|
||||
|
||||
monaco.languages.registerSignatureHelpProvider('json', {
|
||||
signatureHelpTriggerCharacters: ['(', ','],
|
||||
signatureHelpRetriggerCharacters: [','],
|
||||
provideSignatureHelp(model, position) {
|
||||
const prefix = expressionPrefixAt(model, position);
|
||||
if (prefix === null) return null;
|
||||
const call = activeCall(prefix);
|
||||
if (!call) return null;
|
||||
const sig = EXPR_SIGNATURES[call.name];
|
||||
if (!sig || sig.params.length === 0) return null;
|
||||
|
||||
const info: monaco.languages.SignatureInformation = {
|
||||
label: signatureLabel(sig),
|
||||
documentation: { value: sig.doc },
|
||||
parameters: sig.params.map((p, i) => ({
|
||||
// The label must be a substring of the signature label so Monaco can
|
||||
// highlight the active parameter — `...rest` for the variadic tail.
|
||||
label: sig.variadic && i === sig.params.length - 1 ? `...${p}` : p,
|
||||
})),
|
||||
};
|
||||
// A variadic tail keeps highlighting its last parameter past the final comma.
|
||||
const activeParameter = sig.variadic
|
||||
? Math.min(call.activeParam, sig.params.length - 1)
|
||||
: call.activeParam;
|
||||
return { value: { signatures: [info], activeSignature: 0, activeParameter }, dispose() {} };
|
||||
},
|
||||
});
|
||||
|
||||
monaco.languages.registerHoverProvider('json', {
|
||||
provideHover(model, position) {
|
||||
if (useSnippetStore.getState().editorView !== 'draft') return null;
|
||||
const text = model.getValue();
|
||||
const offset = model.getOffsetAt(position);
|
||||
const key = valueKeyAtOffset(text, offset);
|
||||
if (key === null || !EXPRESSION_KEYS.has(key)) return null;
|
||||
const expr = stringValueAtOffset(text, offset);
|
||||
if (expr === null) return null;
|
||||
const result = validateExpression(expr);
|
||||
return {
|
||||
contents: [
|
||||
{
|
||||
value: result.valid
|
||||
? '✓ Valid Vega expression'
|
||||
: `✗ ${result.error ?? 'Invalid expression'}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate every expression string in this editor's model and squiggle the invalid
|
||||
* ones (per editor — it writes markers to one model). Recomputes debounced on edit,
|
||||
* and on a draft↔published toggle so the draft-only gate is honored even when the
|
||||
* two buffers are identical. Disposed with the editor; clears its markers on the
|
||||
* way out.
|
||||
*/
|
||||
export function installExpressionMarkers(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
): monaco.IDisposable {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const recompute = (): void => {
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
// The published view is a read-only reference — no authoring markers there.
|
||||
if (useSnippetStore.getState().editorView !== 'draft') {
|
||||
monaco.editor.setModelMarkers(model, MARKER_OWNER, []);
|
||||
return;
|
||||
}
|
||||
const text = model.getValue();
|
||||
const markers: monaco.editor.IMarkerData[] = [];
|
||||
for (const span of expressionStringsIn(text)) {
|
||||
if (span.length === 0) continue; // an empty expression isn't an error
|
||||
const result = validateExpression(span.value);
|
||||
if (result.valid) continue;
|
||||
const start = model.getPositionAt(span.offset);
|
||||
const end = model.getPositionAt(span.offset + span.length);
|
||||
markers.push({
|
||||
severity: monaco.MarkerSeverity.Error,
|
||||
message: result.error ?? 'Invalid Vega expression.',
|
||||
startLineNumber: start.lineNumber,
|
||||
startColumn: start.column,
|
||||
endLineNumber: end.lineNumber,
|
||||
endColumn: end.column,
|
||||
});
|
||||
}
|
||||
monaco.editor.setModelMarkers(model, MARKER_OWNER, markers);
|
||||
};
|
||||
|
||||
const schedule = (): void => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(recompute, MARKER_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
const contentSub = editor.onDidChangeModelContent(schedule);
|
||||
const viewSub = useSnippetStore.subscribe((s, prev) => {
|
||||
if (s.editorView !== prev.editorView) recompute();
|
||||
});
|
||||
recompute(); // initial pass
|
||||
|
||||
return {
|
||||
dispose() {
|
||||
if (timer) clearTimeout(timer);
|
||||
contentSub.dispose();
|
||||
viewSub();
|
||||
const model = editor.getModel();
|
||||
if (model) monaco.editor.setModelMarkers(model, MARKER_OWNER, []);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Parameter scaffolding for the editor (docs/architecture/08 → editor augmentation) —
|
||||
* the "quick parameter" affordance, sibling of `spec-transform-scaffold`. It offers
|
||||
* Vega-Lite parameters as ready-to-fill snippets: **variable** widgets (slider,
|
||||
* dropdown, radio, checkbox — bound via `bind`) and **selection** parameters (point,
|
||||
* interval — via `select`), each seeded from the data in scope (a slider's bounds from
|
||||
* the numeric field's extent, a point selection's field from a categorical column).
|
||||
* The facts the schema can't supply — *where each family legally attaches* and *what a
|
||||
* seeded skeleton looks like* — are pure core (`core/spec-params`); this is the Monaco
|
||||
* glue.
|
||||
*
|
||||
* **Two surfaces, mirroring `spec-transform-scaffold`:**
|
||||
* - the **CodeLens** (`installSpecParamScaffoldCodeLens`, per editor) is the
|
||||
* discoverable home. Because the two families attach to different homes — variable
|
||||
* parameters are document-global (only legal in the root `params[]`), selections
|
||||
* belong to the unit whose marks they read — the *kind* is chosen at the lens, and
|
||||
* the lens is cursor-scoped by family: at the top level (or a single-view spec) the
|
||||
* variable widgets show; inside a unit the selections show; a single-view spec, where
|
||||
* the root *is* the unit, shows both on the one line. Clicking creates `params: []`
|
||||
* with the entry, or appends to an existing array.
|
||||
* - the **completion** (`configureSpecParamScaffold`, global-once) is the
|
||||
* type-to-filter accelerator, offering both families in the root array and
|
||||
* selections only in a nested unit's.
|
||||
*
|
||||
* Both are gated to the active draft. This is the home the schema leaves bare: the
|
||||
* schema completes the `params` key and its enum values, but never a seeded slider with
|
||||
* your column's real range or a `select` wired to the view.
|
||||
*/
|
||||
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
|
||||
import { appendEntryEdit, createArrayPropertyEdit } from '@core/spec-snippet';
|
||||
import {
|
||||
PARAMS,
|
||||
type ParamFamily,
|
||||
type ParamHost,
|
||||
type ParamSite,
|
||||
paramPlacementAt,
|
||||
paramSiteAt,
|
||||
} from '@core/spec-params';
|
||||
import { boundParamFieldsAt } from './active-dataset';
|
||||
import { installCursorLens } from './editor-cursor-lens';
|
||||
import { insertSnippetAt, scaffoldSlotAt, scaffoldSuggestion } from './editor-snippet';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
|
||||
/** The kinds surfaced as CodeLens buttons — the common few per family; the completion has the rest. */
|
||||
const COMMON_VARIABLE_IDS = ['slider', 'dropdown'] as const;
|
||||
const COMMON_SELECTION_IDS = ['point', 'interval'] as const;
|
||||
|
||||
let registered = false;
|
||||
|
||||
/** Register the `params[]` scaffold completion provider once. */
|
||||
export function configureSpecParamScaffold(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
|
||||
monaco.languages.registerCompletionItemProvider('json', {
|
||||
provideCompletionItems(model, position) {
|
||||
// Scaffolding only edits the draft; the published view is read-only.
|
||||
if (useSnippetStore.getState().editorView !== 'draft') return { suggestions: [] };
|
||||
|
||||
const text = model.getValue();
|
||||
const offset = model.getOffsetAt(position);
|
||||
const placement = paramPlacementAt(text, offset);
|
||||
if (placement.kind !== 'slot') return { suggestions: [] };
|
||||
|
||||
const slot = scaffoldSlotAt(model, position, text, offset);
|
||||
const fields = boundParamFieldsAt(text, offset);
|
||||
// The root array takes both families; a nested unit's takes selections only.
|
||||
const kinds = placement.atRoot ? PARAMS : PARAMS.filter((p) => p.family === 'selection');
|
||||
|
||||
const suggestions = kinds.map((p, i) =>
|
||||
scaffoldSuggestion(slot, i, { label: p.label, detail: p.detail, body: p.param(fields) }),
|
||||
);
|
||||
return { suggestions };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a parameter of `id`/`family` to its home — the root (variable) or the unit
|
||||
* (selection) at `anchorOffset`. Re-resolves the site from live text (the lens args may
|
||||
* be a version stale), then appends to an existing `params[]` or creates one with this
|
||||
* entry as its first element. Both go through Monaco's snippet engine so the entry's tab
|
||||
* stops are Tab-through.
|
||||
*/
|
||||
function runAddParam(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
id: string,
|
||||
family: ParamFamily,
|
||||
anchorOffset: number,
|
||||
): void {
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
const text = model.getValue();
|
||||
const site = paramSiteAt(text, anchorOffset + 1);
|
||||
const host = family === 'variable' ? site?.root : site?.unit;
|
||||
const kind = PARAMS.find((p) => p.id === id);
|
||||
if (!host || !kind) return;
|
||||
|
||||
const entry = kind.param(boundParamFieldsAt(text, anchorOffset + 1));
|
||||
|
||||
// Append to the existing `params[]`, or create one with this entry as its first
|
||||
// element — placement, indentation, and commas are the core edits' tested math.
|
||||
const edit = host.array
|
||||
? appendEntryEdit(text, host.array, entry)
|
||||
: createArrayPropertyEdit(
|
||||
text,
|
||||
host.view.offset,
|
||||
model.getOptions().tabSize || 2,
|
||||
'params',
|
||||
entry,
|
||||
);
|
||||
insertSnippetAt(editor, edit.offset, edit.snippet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the cursor-aware parameter-scaffold CodeLens (per editor — its command needs
|
||||
* this editor's handle to apply the edit). Variable widgets show at the root unless the
|
||||
* cursor is inside a nested unit; selections show on the unit the cursor is in. Returns
|
||||
* a disposable; dispose on unmount.
|
||||
*/
|
||||
export function installSpecParamScaffoldCodeLens(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
): monaco.IDisposable {
|
||||
const addParamCmd = editor.addCommand(
|
||||
0,
|
||||
(_a, id: string, family: ParamFamily, anchorOffset: number) =>
|
||||
runAddParam(editor, id, family, anchorOffset),
|
||||
);
|
||||
|
||||
return installCursorLens<ParamSite>(editor, {
|
||||
resolve: paramSiteAt,
|
||||
// Refresh when either home's presence or param count changes, not on every keystroke.
|
||||
keyOf: (site) =>
|
||||
JSON.stringify([
|
||||
site.root.view.offset,
|
||||
site.root.array?.count ?? -1,
|
||||
site.unit?.view.offset ?? -1,
|
||||
site.unit?.array?.count ?? -1,
|
||||
]),
|
||||
lensesOf: (site, model, lens) => {
|
||||
// Anchor on the params array's line when it exists, else the object's opening line.
|
||||
const lineFor = (host: ParamHost) =>
|
||||
model.getPositionAt(host.array ? host.array.offset : host.view.offset).lineNumber;
|
||||
|
||||
const lenses: monaco.languages.CodeLens[] = [];
|
||||
// Variable widgets are root-only; hide them when the cursor is in a nested unit,
|
||||
// where only selections are legal.
|
||||
const inNestedUnit = !!site.unit && site.unit.view.offset !== site.root.view.offset;
|
||||
if (!inNestedUnit) {
|
||||
const line = lineFor(site.root);
|
||||
for (const id of COMMON_VARIABLE_IDS) {
|
||||
lenses.push(
|
||||
lens(line, `$(add) ${id}`, addParamCmd, [id, 'variable', site.root.view.offset]),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (site.unit) {
|
||||
const line = lineFor(site.unit);
|
||||
for (const id of COMMON_SELECTION_IDS) {
|
||||
lenses.push(
|
||||
lens(line, `$(add) ${id}`, addParamCmd, [id, 'selection', site.unit.view.offset]),
|
||||
);
|
||||
}
|
||||
}
|
||||
return lenses;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
/**
|
||||
* 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 **Compose 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 {
|
||||
type CompositionTarget,
|
||||
compositionTargetAt,
|
||||
elementOffset,
|
||||
insertView,
|
||||
moveView,
|
||||
moveViewTo,
|
||||
type SpecPath,
|
||||
} from '@core/spec-insert';
|
||||
import { simplifyStructure, wrapContainer, wrapViews, type DropAxis } from '@core/spec-restructure';
|
||||
import {
|
||||
unwrapSingleton,
|
||||
wrapInConcat,
|
||||
wrapInFacet,
|
||||
wrapInLayer,
|
||||
wrapInRepeat,
|
||||
} from '@core/spec-transforms';
|
||||
import { notify } from '../stores/NotificationStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { boundColumnsAt } from './active-dataset';
|
||||
import { installCursorLens } from './editor-cursor-lens';
|
||||
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;
|
||||
/** Whole-document offset of the focused view, for resolving its data binding. */
|
||||
offset: number;
|
||||
/** 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(), offset: 0, 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. `offset` is the
|
||||
* cursor/selection-start position in the whole document, used to resolve the
|
||||
* focused view's data binding (facet/repeat field defaults).
|
||||
*/
|
||||
function resolveScope(model: monaco.editor.ITextModel, range: monaco.IRange | null): Scope {
|
||||
if (!range) return wholeDocument(model);
|
||||
const offset = model.getOffsetAt({
|
||||
lineNumber: range.startLineNumber,
|
||||
column: range.startColumn,
|
||||
});
|
||||
if (!isEmptyRange(range)) {
|
||||
const r = monaco.Range.lift(range);
|
||||
return { range: r, text: model.getValueInRange(r), offset, baseCol: r.startColumn - 1 };
|
||||
}
|
||||
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), offset, 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(text: string, offset: number): { field: string; type: string } {
|
||||
const cols = boundColumnsAt(text, offset);
|
||||
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,
|
||||
text: string,
|
||||
offset: number,
|
||||
): { fields: string[]; channel: string | null } {
|
||||
const cols = boundColumnsAt(text, offset);
|
||||
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 (scoped) spec. `text`/`offset` are
|
||||
* the whole document and the focused view's position, used to resolve that view's
|
||||
* data binding for the facet/repeat field defaults.
|
||||
*/
|
||||
function buildNext(spec: JsonObject, kind: WrapKind, text: string, offset: number): 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(text, offset);
|
||||
return wrapInFacet(spec, field, type);
|
||||
}
|
||||
case 'repeat': {
|
||||
const { fields, channel } = defaultRepeat(spec, text, offset);
|
||||
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). `focus`
|
||||
* returns focus to the editor afterwards — true for editor-originated actions,
|
||||
* false when the wireframe drove the edit and must keep its own focus (APG: a
|
||||
* keyboard reorder stays on the moved item for consecutive moves).
|
||||
*/
|
||||
function writeBack(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
range: monaco.Range,
|
||||
text: string,
|
||||
focus = true,
|
||||
): void {
|
||||
editor.pushUndoStop();
|
||||
editor.executeEdits('spec-transform', [{ range, text }]);
|
||||
editor.pushUndoStop();
|
||||
if (focus) 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;
|
||||
const next = buildNext(spec, kind, model.getValue(), scope.offset);
|
||||
writeBack(editor, scope.range, formatScoped(model, scope, next));
|
||||
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.',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a structural array edit (insert / move) as one undoable whole-document
|
||||
* rewrite, then put the cursor on the affected view so the lenses re-anchor to it
|
||||
* and a repeated click keeps acting on the same view. `followIndex` is the view's
|
||||
* index in `arrayPath` *after* the edit. Reformatted in the app's compact style,
|
||||
* which is idempotent on an already-formatted draft.
|
||||
*
|
||||
* `successTitle` of null suppresses the success toast (the wireframe gives its own
|
||||
* feedback — visible move + live-region announcement — so a per-move toast would
|
||||
* double up); `focusEditor` of false keeps focus off the editor for the same path.
|
||||
*/
|
||||
function applyArrayEdit(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
build: (spec: JsonObject) => JsonObject | null,
|
||||
arrayPath: SpecPath,
|
||||
followIndex: number,
|
||||
successTitle: string | null,
|
||||
focusEditor = true,
|
||||
): void {
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
const spec = parseSpecObject(model.getValue());
|
||||
if (!spec) return;
|
||||
const next = build(spec);
|
||||
if (!next) {
|
||||
notify({
|
||||
kind: 'info',
|
||||
title: 'Could not change the composition',
|
||||
message: 'The composition changed — try the affordance again.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const formatted = formatScoped(model, wholeDocument(model), next);
|
||||
writeBack(editor, model.getFullModelRange(), formatted, focusEditor);
|
||||
const offset = elementOffset(formatted, arrayPath, followIndex);
|
||||
if (offset !== null) {
|
||||
const pos = model.getPositionAt(offset);
|
||||
editor.setPosition(pos);
|
||||
editor.revealPositionInCenterIfOutsideViewport(pos);
|
||||
}
|
||||
if (successTitle)
|
||||
notify({ kind: 'success', title: successTitle, message: 'Undo with ⌘/Ctrl+Z.' });
|
||||
}
|
||||
|
||||
/** Insert an empty view at `index` of the composition at `arrayPath`. */
|
||||
function runAddView(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
arrayPath: SpecPath,
|
||||
index: number,
|
||||
): void {
|
||||
applyArrayEdit(editor, (s) => insertView(s, arrayPath, index), arrayPath, index, 'View added');
|
||||
}
|
||||
|
||||
/** Swap the view at `index` with its sibling `delta` steps away (±1). */
|
||||
function runMoveView(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
arrayPath: SpecPath,
|
||||
index: number,
|
||||
delta: number,
|
||||
): void {
|
||||
applyArrayEdit(
|
||||
editor,
|
||||
(s) => moveView(s, arrayPath, index, delta),
|
||||
arrayPath,
|
||||
index + delta,
|
||||
delta < 0 ? 'View moved up' : 'View moved down',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorder the view at `from` to `to` within the composition at `arrayPath` — the
|
||||
* composition wireframe's drag/keyboard reorder. No success toast and no editor
|
||||
* focus-steal: the wireframe owns the feedback and keeps focus on the moved box.
|
||||
*/
|
||||
export function runMoveViewTo(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
arrayPath: SpecPath,
|
||||
from: number,
|
||||
to: number,
|
||||
): void {
|
||||
applyArrayEdit(editor, (s) => moveViewTo(s, arrayPath, from, to), arrayPath, to, null, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a whole-spec structural edit: parse the draft, `build` the next spec, and
|
||||
* write it back as one undoable edit — or surface the "could not restructure" info
|
||||
* toast when `build` returns null (a degenerate or stale drop). The whole-spec sibling
|
||||
* of `applyArrayEdit`, for the wireframe's drag/simplify family — no cursor-follow and
|
||||
* no success toast, since the wireframe gives its own feedback. `notifyOnNull` of false
|
||||
* stays silent: a no-op (e.g. Simplify with nothing redundant) is not a failure.
|
||||
*/
|
||||
function applyWholeSpecEdit(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
build: (spec: JsonObject) => JsonObject | null,
|
||||
notifyOnNull = true,
|
||||
): void {
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
const spec = parseSpecObject(model.getValue());
|
||||
if (!spec) return;
|
||||
const next = build(spec);
|
||||
if (!next) {
|
||||
if (notifyOnNull)
|
||||
notify({
|
||||
kind: 'info',
|
||||
title: 'Could not restructure',
|
||||
message: 'That drop isn’t possible here — the composition may have changed.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
writeBack(
|
||||
editor,
|
||||
model.getFullModelRange(),
|
||||
formatScoped(model, wholeDocument(model), next),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restructure the spec by pairing the dragged `sourcePath` view beside the drop
|
||||
* `targetPath` view in a new concat of `axis` — the wireframe's cross-container drag
|
||||
* (wrap, move-in, collapse, all in core/spec-restructure).
|
||||
*/
|
||||
export function runWrapViews(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
targetPath: SpecPath,
|
||||
sourcePath: SpecPath,
|
||||
axis: DropAxis,
|
||||
side: 'before' | 'after',
|
||||
): void {
|
||||
applyWholeSpecEdit(editor, (s) => wrapViews(s, targetPath, sourcePath, axis, side));
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the dragged `sourcePath` view out into a new full-span row/column around the
|
||||
* whole `containerPath` container — the wireframe's frame-margin drag (core
|
||||
* `wrapContainer`, the complement to the edge-drop `wrapViews`).
|
||||
*/
|
||||
export function runWrapContainer(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
containerPath: SpecPath,
|
||||
sourcePath: SpecPath,
|
||||
axis: DropAxis,
|
||||
side: 'before' | 'after',
|
||||
): void {
|
||||
applyWholeSpecEdit(editor, (s) => wrapContainer(s, containerPath, sourcePath, axis, side));
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse every redundant single-child composition in the spec (core
|
||||
* `simplifyStructure`) — the wireframe's Simplify action, offered when it detects a
|
||||
* `{hconcat: [oneView]}`-style wrapper. A no-op (silent) when nothing is redundant.
|
||||
*/
|
||||
export function runSimplifyStructure(editor: monaco.editor.IStandaloneCodeEditor): void {
|
||||
applyWholeSpecEdit(editor, simplifyStructure, false);
|
||||
}
|
||||
|
||||
/** Insert a view above/below the one the cursor is in (the keyboard path). */
|
||||
function runInsertRelative(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
where: 'above' | 'below',
|
||||
): void {
|
||||
const model = editor.getModel();
|
||||
const pos = editor.getPosition();
|
||||
if (!model || !pos) return;
|
||||
const target = compositionTargetAt(model.getValue(), model.getOffsetAt(pos));
|
||||
if (!target) {
|
||||
notify({
|
||||
kind: 'info',
|
||||
title: 'No composition here',
|
||||
message: 'Place the cursor in a view inside a layer or concat.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!target.element) {
|
||||
runAddView(editor, target.arrayPath, 0); // empty composition → its first view
|
||||
return;
|
||||
}
|
||||
runAddView(
|
||||
editor,
|
||||
target.arrayPath,
|
||||
where === 'above' ? target.element.index : target.element.index + 1,
|
||||
);
|
||||
}
|
||||
|
||||
/** Move the view the cursor is in up/down among its siblings (the keyboard path). */
|
||||
function runMoveRelative(editor: monaco.editor.IStandaloneCodeEditor, delta: number): void {
|
||||
const model = editor.getModel();
|
||||
const pos = editor.getPosition();
|
||||
if (!model || !pos) return;
|
||||
const target = compositionTargetAt(model.getValue(), model.getOffsetAt(pos));
|
||||
if (!target?.element) {
|
||||
notify({
|
||||
kind: 'info',
|
||||
title: 'No view to move',
|
||||
message: 'Place the cursor in a view inside a layer or concat.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
const to = target.element.index + delta;
|
||||
if (to < 0 || to >= target.count) {
|
||||
notify({
|
||||
kind: 'info',
|
||||
title: delta < 0 ? 'Already first' : 'Already last',
|
||||
message: 'This view is at the edge of its composition.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
runMoveView(editor, target.arrayPath, target.element.index, delta);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the cursor-aware composition CodeLens (per editor — the lens commands
|
||||
* need this editor's handle to apply the edit). Over the view the cursor sits in
|
||||
* it offers `+ Add view above/below` at the view's edges and `↑/↓ Move` to
|
||||
* reorder it among its siblings; an empty composition gets a single `+ Add view`.
|
||||
* Returns a disposable; dispose on unmount.
|
||||
*/
|
||||
export function installSpecTransformCodeLens(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
): monaco.IDisposable {
|
||||
const addCmd = editor.addCommand(0, (_a, path: SpecPath, index: number) =>
|
||||
runAddView(editor, path, index),
|
||||
);
|
||||
const moveCmd = editor.addCommand(0, (_a, path: SpecPath, index: number, delta: number) =>
|
||||
runMoveView(editor, path, index, delta),
|
||||
);
|
||||
|
||||
return installCursorLens<CompositionTarget>(editor, {
|
||||
resolve: compositionTargetAt,
|
||||
// Refresh when the enclosing view or the sibling count changes, not on every keystroke.
|
||||
keyOf: (target) =>
|
||||
JSON.stringify([target.arrayPath, target.element?.index ?? -1, target.count]),
|
||||
lensesOf: (target, model, lens) => {
|
||||
if (!target.element) {
|
||||
// Off a view, only an empty composition gets an affordance — the array's
|
||||
// own line stays clean otherwise.
|
||||
if (target.count !== 0) return [];
|
||||
const line = model.getPositionAt(target.arrayOffset).lineNumber;
|
||||
return [lens(line, '$(add) Add view', addCmd, [target.arrayPath, 0])];
|
||||
}
|
||||
const { index, offset, length } = target.element;
|
||||
// "above" actions sit on the view's first line, "below" on the line after
|
||||
// its last — so up-actions group at the top, down-actions at the bottom.
|
||||
const topLine = model.getPositionAt(offset).lineNumber;
|
||||
const bottomLine = Math.min(
|
||||
model.getLineCount(),
|
||||
model.getPositionAt(offset + length).lineNumber + 1,
|
||||
);
|
||||
const lenses: monaco.languages.CodeLens[] = [
|
||||
lens(topLine, '$(add) Add view above', addCmd, [target.arrayPath, index]),
|
||||
];
|
||||
if (index > 0)
|
||||
lenses.push(lens(topLine, '$(arrow-up) Move up', moveCmd, [target.arrayPath, index, -1]));
|
||||
lenses.push(lens(bottomLine, '$(add) Add view below', addCmd, [target.arrayPath, index + 1]));
|
||||
if (index < target.count - 1)
|
||||
lenses.push(
|
||||
lens(bottomLine, '$(arrow-down) Move down', moveCmd, [target.arrayPath, index, 1]),
|
||||
);
|
||||
return lenses;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
}),
|
||||
editor.addAction({
|
||||
id: 'astrolabe.insert-view-above',
|
||||
label: 'Insert View Above',
|
||||
precondition: '!editorReadonly',
|
||||
run: () => runInsertRelative(editor, 'above'),
|
||||
}),
|
||||
editor.addAction({
|
||||
id: 'astrolabe.insert-view-below',
|
||||
label: 'Insert View Below',
|
||||
precondition: '!editorReadonly',
|
||||
run: () => runInsertRelative(editor, 'below'),
|
||||
}),
|
||||
editor.addAction({
|
||||
id: 'astrolabe.move-view-up',
|
||||
label: 'Move View Up',
|
||||
precondition: '!editorReadonly',
|
||||
run: () => runMoveRelative(editor, -1),
|
||||
}),
|
||||
editor.addAction({
|
||||
id: 'astrolabe.move-view-down',
|
||||
label: 'Move View Down',
|
||||
precondition: '!editorReadonly',
|
||||
run: () => runMoveRelative(editor, 1),
|
||||
}),
|
||||
];
|
||||
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 viewSpec = spec;
|
||||
|
||||
const fullText = model.getValue();
|
||||
const build = (kind: WrapKind) => buildNext(viewSpec, kind, fullText, scope.offset);
|
||||
const actions: monaco.languages.CodeAction[] = [
|
||||
editAction(model, scope, 'Wrap view in a layer', build('layer')),
|
||||
editAction(model, scope, 'Wrap view in horizontal concat', build('hconcat')),
|
||||
editAction(model, scope, 'Wrap view in vertical concat', build('vconcat')),
|
||||
editAction(model, scope, 'Wrap view in a facet', build('facet')),
|
||||
editAction(model, scope, 'Wrap view in a repeat', build('repeat')),
|
||||
];
|
||||
const collapsed = unwrapSingleton(spec);
|
||||
if (collapsed)
|
||||
actions.push(editAction(model, scope, 'Simplify single-child composition', collapsed));
|
||||
|
||||
return { actions, dispose() {} };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* Data-transform scaffolding for the editor (docs/architecture/08 → editor
|
||||
* augmentation) — the "quick transform" affordance. It offers the popular Vega-Lite
|
||||
* transforms (filter, calculate, aggregate, bin, timeUnit, …) as ready-to-fill
|
||||
* snippets, each seeded with a type-appropriate column from the data in scope. The
|
||||
* facts the schema can't supply — *where a view's pipeline is* and *what a field-typed
|
||||
* skeleton looks like* — are pure core (`core/spec-data-transforms`); this is the thin
|
||||
* Monaco glue.
|
||||
*
|
||||
* **Two surfaces, mirroring the composition transforms (`spec-transform-actions`):**
|
||||
* - the **CodeLens** (`installSpecTransformScaffoldCodeLens`, per editor — its
|
||||
* commands need the editor handle) is the *discoverable* home: an `+ Add
|
||||
* transform` lens on a view with no pipeline, and per-step `+ filter`/`+
|
||||
* aggregate`/… lenses on the array once it exists. Clicking splices the step in
|
||||
* via Monaco's snippet engine, so the field-typed placeholders are Tab-through.
|
||||
* - the **completion** (`configureSpecTransformScaffold`, global-once) is the
|
||||
* type-to-filter *accelerator* on the same catalog, for when the cursor is
|
||||
* already in a `transform[]` slot.
|
||||
*
|
||||
* Both are gated to the active draft — the published view is a read-only reference.
|
||||
* The scaffold is deliberately the one home the schema leaves bare: the schema already
|
||||
* completes the inline channel transforms (`bin`/`timeUnit` on an encoding field) and
|
||||
* the `transform` key itself, so this adds no second source there.
|
||||
*/
|
||||
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
|
||||
import {
|
||||
DATA_TRANSFORMS,
|
||||
transformPlacementAt,
|
||||
transformSiteAt,
|
||||
type TransformSite,
|
||||
} from '@core/spec-data-transforms';
|
||||
import { appendEntryEdit, createArrayPropertyEdit } from '@core/spec-snippet';
|
||||
import { boundColumnsAt } from './active-dataset';
|
||||
import { installCursorLens } from './editor-cursor-lens';
|
||||
import { insertSnippetAt, scaffoldSlotAt, scaffoldSuggestion } from './editor-snippet';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
|
||||
/** The transforms surfaced as CodeLens buttons — the common few; the completion has the rest. */
|
||||
const COMMON_STEP_IDS = ['filter', 'aggregate', 'calculate', 'bin', 'timeUnit'] as const;
|
||||
|
||||
let registered = false;
|
||||
|
||||
/** Register the `transform[]` step-scaffold completion provider once. */
|
||||
export function configureSpecTransformScaffold(): void {
|
||||
if (registered) return;
|
||||
registered = true;
|
||||
|
||||
monaco.languages.registerCompletionItemProvider('json', {
|
||||
provideCompletionItems(model, position) {
|
||||
// Scaffolding only edits the draft; the published view is read-only.
|
||||
if (useSnippetStore.getState().editorView !== 'draft') return { suggestions: [] };
|
||||
|
||||
const text = model.getValue();
|
||||
const offset = model.getOffsetAt(position);
|
||||
const placement = transformPlacementAt(text, offset);
|
||||
if (placement.kind !== 'step') return { suggestions: [] };
|
||||
|
||||
const slot = scaffoldSlotAt(model, position, text, offset);
|
||||
const fields = boundColumnsAt(text, offset);
|
||||
const shared = placement.scope === 'shared';
|
||||
|
||||
const suggestions = DATA_TRANSFORMS.map((t, i) =>
|
||||
scaffoldSuggestion(slot, i, {
|
||||
label: t.label,
|
||||
detail: t.detail,
|
||||
body: t.step(fields),
|
||||
// A shared step (on a composition parent) transforms the data feeding every
|
||||
// child view, not just one — surface that so the placement isn't a surprise.
|
||||
documentation: shared
|
||||
? {
|
||||
value:
|
||||
'_Applies to all views below — this `transform` sits on a composition parent._',
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
);
|
||||
return { suggestions };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Splice a field-typed step into the `transform` array at `arrayOffset`, via Monaco's
|
||||
* snippet engine so the placeholders are Tab-through. Re-resolves the array from the
|
||||
* live text (the lens args may be a version stale) and skips silently if it's gone.
|
||||
*/
|
||||
function runAddStep(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
id: string,
|
||||
arrayOffset: number,
|
||||
): void {
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
const text = model.getValue();
|
||||
const site = transformSiteAt(text, arrayOffset + 1);
|
||||
const transform = DATA_TRANSFORMS.find((t) => t.id === id);
|
||||
if (!site?.array || !transform) return;
|
||||
|
||||
const fields = boundColumnsAt(text, site.array.offset);
|
||||
const edit = appendEntryEdit(text, site.array, transform.step(fields));
|
||||
insertSnippetAt(editor, edit.offset, edit.snippet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an empty `transform: []` as the view's first property and drop the cursor
|
||||
* between its brackets (`$0`), so the per-step lenses appear next and the first step
|
||||
* fills the array in place. Placement, indentation, and the comma are
|
||||
* `createArrayPropertyEdit`'s tested math.
|
||||
*/
|
||||
function runAddTransform(editor: monaco.editor.IStandaloneCodeEditor, viewOffset: number): void {
|
||||
const model = editor.getModel();
|
||||
if (!model) return;
|
||||
const text = model.getValue();
|
||||
const site = transformSiteAt(text, viewOffset + 1);
|
||||
if (!site || site.array) return; // gone, or already has a pipeline
|
||||
|
||||
const tab = model.getOptions().tabSize || 2;
|
||||
const edit = createArrayPropertyEdit(text, site.view.offset, tab, 'transform', '$0');
|
||||
insertSnippetAt(editor, edit.offset, edit.snippet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the cursor-aware transform-scaffold CodeLens (per editor — its commands
|
||||
* need this editor's handle to apply the edit, exactly like the composition CodeLens
|
||||
* in `spec-transform-actions`). Over the view the cursor sits in it shows `+ Add
|
||||
* transform` when there is no pipeline, or `+ filter`/`+ aggregate`/… on the array
|
||||
* when there is. Returns a disposable; dispose on unmount.
|
||||
*/
|
||||
export function installSpecTransformScaffoldCodeLens(
|
||||
editor: monaco.editor.IStandaloneCodeEditor,
|
||||
): monaco.IDisposable {
|
||||
const addTransformCmd = editor.addCommand(0, (_a, viewOffset: number) =>
|
||||
runAddTransform(editor, viewOffset),
|
||||
);
|
||||
const addStepCmd = editor.addCommand(0, (_a, id: string, arrayOffset: number) =>
|
||||
runAddStep(editor, id, arrayOffset),
|
||||
);
|
||||
|
||||
return installCursorLens<TransformSite>(editor, {
|
||||
resolve: transformSiteAt,
|
||||
// Refresh when the enclosing view or its step count changes, not on every keystroke.
|
||||
keyOf: (site) => JSON.stringify([site.view.offset, site.array?.count ?? -1]),
|
||||
lensesOf: (site, model, lens) => {
|
||||
if (!site.array) {
|
||||
const line = model.getPositionAt(site.view.offset).lineNumber;
|
||||
return [lens(line, '$(add) Add transform', addTransformCmd, [site.view.offset])];
|
||||
}
|
||||
const line = model.getPositionAt(site.array.offset).lineNumber;
|
||||
return COMMON_STEP_IDS.map((id) =>
|
||||
lens(line, `$(add) ${id}`, addStepCmd, [id, site.array!.offset]),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -42,3 +42,19 @@ describe('data inspector height (divider math)', () => {
|
||||
expect(inspectorHeightValue(96, 150)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestRevealView (wireframe → editor)', () => {
|
||||
beforeEach(() => useAppStore.setState({ revealTarget: null }));
|
||||
|
||||
test('carries the requested range', () => {
|
||||
store().requestRevealView(40, 12);
|
||||
expect(store().revealTarget).toMatchObject({ offset: 40, length: 12 });
|
||||
});
|
||||
|
||||
test('bumps the nonce on a repeat request for the same range, so it re-fires', () => {
|
||||
store().requestRevealView(40, 12);
|
||||
const first = store().revealTarget!.nonce;
|
||||
store().requestRevealView(40, 12);
|
||||
expect(store().revealTarget!.nonce).toBe(first + 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import type { FitMode } from '@core/rendering';
|
||||
import type { SpecPath } from '@core/spec-insert';
|
||||
import type { DropAxis } from '@core/spec-restructure';
|
||||
import type { UiTheme } from '@core/theme';
|
||||
import type { ChartThemeSelection } from '@core/vega-themes';
|
||||
import type { ModalName } from '../modals/types';
|
||||
@@ -65,6 +67,40 @@ export interface AppState {
|
||||
dataInspectorHeight: number;
|
||||
/** The currently open modal, or null. */
|
||||
activeModal: ModalName | null;
|
||||
/**
|
||||
* A request from the composition wireframe to select and reveal a view's source
|
||||
* range in the editor (wireframe → editor; arch 08 → editor augmentation). The
|
||||
* nonce makes a repeat request for the same range re-fire. Null until the first.
|
||||
*/
|
||||
revealTarget: { offset: number; length: number; nonce: number } | null;
|
||||
/**
|
||||
* A request from the composition wireframe to restructure — reorder a view
|
||||
* within its array (`move`), pair the dragged view beside a drop target in a new
|
||||
* concat (`wrap`, the cross-container drag), or stack it against a whole container
|
||||
* pulled out into a new full-span row/column (`wrap-container`, the frame-margin
|
||||
* drag). Applied by the editor (which owns the undoable edit) as one ⌘Z step; the
|
||||
* nonce makes a repeat re-fire. Null until the first.
|
||||
*/
|
||||
composeRequest:
|
||||
| { kind: 'move'; arrayPath: SpecPath; from: number; to: number; nonce: number }
|
||||
| {
|
||||
kind: 'wrap';
|
||||
targetPath: SpecPath;
|
||||
sourcePath: SpecPath;
|
||||
axis: DropAxis;
|
||||
side: 'before' | 'after';
|
||||
nonce: number;
|
||||
}
|
||||
| {
|
||||
kind: 'wrap-container';
|
||||
containerPath: SpecPath;
|
||||
sourcePath: SpecPath;
|
||||
axis: DropAxis;
|
||||
side: 'before' | 'after';
|
||||
nonce: number;
|
||||
}
|
||||
| { kind: 'simplify'; nonce: number }
|
||||
| null;
|
||||
|
||||
setTheme: (theme: UiTheme) => void;
|
||||
/** Flip between light and dark — the header ThemeToggle's action. */
|
||||
@@ -84,6 +120,26 @@ export interface AppState {
|
||||
* which calls this; arrives with the modal system in M3.
|
||||
*/
|
||||
setActiveModal: (modal: ModalName | null) => void;
|
||||
/** Ask the editor to select + reveal a view's source range (composition wireframe). */
|
||||
requestRevealView: (offset: number, length: number) => void;
|
||||
/** Ask the editor to reorder a view within its composition array (wireframe drag/keyboard). */
|
||||
requestComposeMove: (arrayPath: SpecPath, from: number, to: number) => void;
|
||||
/** Ask the editor to pair a dragged view beside a drop target in a new concat (wireframe drag). */
|
||||
requestComposeWrap: (
|
||||
targetPath: SpecPath,
|
||||
sourcePath: SpecPath,
|
||||
axis: DropAxis,
|
||||
side: 'before' | 'after',
|
||||
) => void;
|
||||
/** Ask the editor to pull a dragged view out into a new full-span row/column around a container (wireframe margin drag). */
|
||||
requestComposeWrapContainer: (
|
||||
containerPath: SpecPath,
|
||||
sourcePath: SpecPath,
|
||||
axis: DropAxis,
|
||||
side: 'before' | 'after',
|
||||
) => void;
|
||||
/** Ask the editor to collapse redundant single-child compositions (wireframe Simplify). */
|
||||
requestComposeSimplify: () => void;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
@@ -93,6 +149,8 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
dataInspectorOpen: false,
|
||||
dataInspectorHeight: DATA_INSPECTOR_DEFAULT_HEIGHT,
|
||||
activeModal: null,
|
||||
revealTarget: null,
|
||||
composeRequest: null,
|
||||
|
||||
setTheme: (uiTheme) => set({ uiTheme }),
|
||||
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
|
||||
@@ -101,4 +159,42 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
setDataInspectorOpen: (dataInspectorOpen) => set({ dataInspectorOpen }),
|
||||
setDataInspectorHeight: (dataInspectorHeight) => set({ dataInspectorHeight }),
|
||||
setActiveModal: (activeModal) => set({ activeModal }),
|
||||
requestRevealView: (offset, length) =>
|
||||
set((s) => ({ revealTarget: { offset, length, nonce: (s.revealTarget?.nonce ?? 0) + 1 } })),
|
||||
requestComposeMove: (arrayPath, from, to) =>
|
||||
set((s) => ({
|
||||
composeRequest: {
|
||||
kind: 'move',
|
||||
arrayPath,
|
||||
from,
|
||||
to,
|
||||
nonce: (s.composeRequest?.nonce ?? 0) + 1,
|
||||
},
|
||||
})),
|
||||
requestComposeWrap: (targetPath, sourcePath, axis, side) =>
|
||||
set((s) => ({
|
||||
composeRequest: {
|
||||
kind: 'wrap',
|
||||
targetPath,
|
||||
sourcePath,
|
||||
axis,
|
||||
side,
|
||||
nonce: (s.composeRequest?.nonce ?? 0) + 1,
|
||||
},
|
||||
})),
|
||||
requestComposeWrapContainer: (containerPath, sourcePath, axis, side) =>
|
||||
set((s) => ({
|
||||
composeRequest: {
|
||||
kind: 'wrap-container',
|
||||
containerPath,
|
||||
sourcePath,
|
||||
axis,
|
||||
side,
|
||||
nonce: (s.composeRequest?.nonce ?? 0) + 1,
|
||||
},
|
||||
})),
|
||||
requestComposeSimplify: () =>
|
||||
set((s) => ({
|
||||
composeRequest: { kind: 'simplify', nonce: (s.composeRequest?.nonce ?? 0) + 1 },
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import { beforeEach, describe, expect, test } from 'vitest';
|
||||
import { createSnippet } from '@core/snippet';
|
||||
import { useDatasetStore } from './DatasetStore';
|
||||
import { hasExtractableData, useExtractStore } from './ExtractStore';
|
||||
import { useSnippetStore } from './SnippetStore';
|
||||
|
||||
const extract = () => useExtractStore.getState();
|
||||
const snippets = () => useSnippetStore.getState();
|
||||
const datasets = () => useDatasetStore.getState();
|
||||
|
||||
/** Load a single active snippet whose live draft buffer is `draft`. */
|
||||
function activeDraft(draft: string): void {
|
||||
const s = createSnippet({ id: 's', spec: draft, now: new Date('2026-01-01T00:00:00Z') });
|
||||
useSnippetStore.getState().hydrate([s], 's');
|
||||
useSnippetStore.getState().updateDraft(draft);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useExtractStore.getState().reset();
|
||||
useSnippetStore.getState().reset();
|
||||
useDatasetStore.getState().reset();
|
||||
});
|
||||
|
||||
describe('hasExtractableData', () => {
|
||||
test('true for inline data, false for a library reference or bad JSON', () => {
|
||||
expect(hasExtractableData('{"data":{"values":[{"a":1}]},"mark":"bar"}')).toBe(true);
|
||||
expect(hasExtractableData('{"data":{"name":"sales"}}')).toBe(false);
|
||||
expect(hasExtractableData('not json')).toBe(false);
|
||||
});
|
||||
|
||||
test('true when inline data lives only in a nested view', () => {
|
||||
expect(
|
||||
hasExtractableData('{"layer":[{"data":{"name":"sales"}},{"data":{"values":[{"a":1}]}}]}'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test('true for a reference to a self-defined datasets entry', () => {
|
||||
expect(hasExtractableData('{"datasets":{"sales":[{"a":1}]},"data":{"name":"sales"}}')).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirm — inline rewrite', () => {
|
||||
test('extracts the root binding and rewrites it to a by-name reference', () => {
|
||||
activeDraft('{"data":{"values":[{"a":1},{"a":2}]},"mark":"bar"}');
|
||||
extract().begin({
|
||||
source: { values: [{ a: 1 }, { a: 2 }], format: 'json' },
|
||||
target: { kind: 'inline', anchorPath: [] },
|
||||
});
|
||||
extract().setName('Sales');
|
||||
|
||||
expect(extract().confirm(new Date('2026-02-01T00:00:00Z'))).toBe(true);
|
||||
|
||||
const ds = datasets().datasets.find((d) => d.name === 'Sales');
|
||||
expect(ds?.data).toEqual([{ a: 1 }, { a: 2 }]);
|
||||
|
||||
const draft = JSON.parse(snippets().draftText) as { data: unknown; mark: unknown };
|
||||
expect(draft.data).toEqual({ name: 'Sales' });
|
||||
expect(draft.mark).toBe('bar');
|
||||
});
|
||||
|
||||
test('rewrites only the focused view in a composition, leaving siblings intact', () => {
|
||||
activeDraft(
|
||||
JSON.stringify({
|
||||
layer: [{ data: { name: 'sales' }, mark: 'line' }, { data: { values: [{ a: 1 }] } }],
|
||||
}),
|
||||
);
|
||||
extract().begin({
|
||||
source: { values: [{ a: 1 }], format: 'json' },
|
||||
target: { kind: 'inline', anchorPath: ['layer', 1] },
|
||||
});
|
||||
extract().setName('Overlay');
|
||||
expect(extract().confirm()).toBe(true);
|
||||
|
||||
const draft = JSON.parse(snippets().draftText) as { layer: Array<{ data: unknown }> };
|
||||
expect(draft.layer[0].data).toEqual({ name: 'sales' }); // sibling untouched
|
||||
expect(draft.layer[1].data).toEqual({ name: 'Overlay' }); // focused view rewritten
|
||||
});
|
||||
|
||||
test("rewrites a lookup transform's inline from.data (the cursor sits inside it)", () => {
|
||||
activeDraft(
|
||||
JSON.stringify({
|
||||
data: { name: 'Library' },
|
||||
transform: [
|
||||
{ lookup: 'k', from: { data: { values: [{ k: 1, v: 9 }] }, key: 'k', fields: ['v'] } },
|
||||
],
|
||||
mark: 'bar',
|
||||
}),
|
||||
);
|
||||
extract().begin({
|
||||
source: { values: [{ k: 1, v: 9 }], format: 'json' },
|
||||
target: { kind: 'inline', anchorPath: ['transform', 0, 'from'] },
|
||||
});
|
||||
extract().setName('Lookup');
|
||||
expect(extract().confirm()).toBe(true);
|
||||
|
||||
const draft = JSON.parse(snippets().draftText) as {
|
||||
transform: Array<{ from: { data: unknown } }>;
|
||||
};
|
||||
expect(draft.transform[0].from.data).toEqual({ name: 'Lookup' });
|
||||
});
|
||||
|
||||
test('preserves a CSV string payload as a raw-text dataset', () => {
|
||||
activeDraft('{"data":{"values":"a,b\\n1,2","format":{"type":"csv"}},"mark":"bar"}');
|
||||
extract().begin({
|
||||
source: { values: 'a,b\n1,2', format: 'csv' },
|
||||
target: { kind: 'inline', anchorPath: [] },
|
||||
});
|
||||
extract().setName('Raw');
|
||||
expect(extract().confirm()).toBe(true);
|
||||
|
||||
const ds = datasets().datasets.find((d) => d.name === 'Raw');
|
||||
expect(ds?.format).toBe('csv');
|
||||
expect(ds?.data).toBe('a,b\n1,2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirm — self-defined datasets rewrite', () => {
|
||||
test('keeping the name drops the datasets entry; the reference resolves to the library', () => {
|
||||
activeDraft('{"datasets":{"sales":[{"a":1},{"a":2}]},"data":{"name":"sales"},"mark":"bar"}');
|
||||
extract().begin({
|
||||
source: { values: [{ a: 1 }, { a: 2 }], format: 'json' },
|
||||
target: { kind: 'self-defined', datasetName: 'sales' },
|
||||
name: 'sales',
|
||||
});
|
||||
expect(extract().confirm()).toBe(true);
|
||||
|
||||
const ds = datasets().datasets.find((d) => d.name === 'sales');
|
||||
expect(ds?.data).toEqual([{ a: 1 }, { a: 2 }]);
|
||||
|
||||
const draft = JSON.parse(snippets().draftText) as { datasets?: unknown; data: unknown };
|
||||
expect(draft.datasets).toBeUndefined(); // map emptied → removed
|
||||
expect(draft.data).toEqual({ name: 'sales' }); // reference unchanged, now a library ref
|
||||
});
|
||||
|
||||
test('renaming rewrites every reference and keeps other datasets entries', () => {
|
||||
activeDraft(
|
||||
JSON.stringify({
|
||||
datasets: { sales: [{ a: 1 }], other: [{ b: 2 }] },
|
||||
layer: [
|
||||
{ data: { name: 'sales' } },
|
||||
{ data: { name: 'sales' } },
|
||||
{ data: { name: 'other' } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
extract().begin({
|
||||
source: { values: [{ a: 1 }], format: 'json' },
|
||||
target: { kind: 'self-defined', datasetName: 'sales' },
|
||||
name: 'Sales 2024',
|
||||
});
|
||||
expect(extract().confirm()).toBe(true);
|
||||
|
||||
const draft = JSON.parse(snippets().draftText) as {
|
||||
datasets: Record<string, unknown>;
|
||||
layer: Array<{ data: unknown }>;
|
||||
};
|
||||
expect(draft.layer[0].data).toEqual({ name: 'Sales 2024' }); // both sales refs rewritten
|
||||
expect(draft.layer[1].data).toEqual({ name: 'Sales 2024' });
|
||||
expect(draft.layer[2].data).toEqual({ name: 'other' }); // untouched
|
||||
expect(draft.datasets).toEqual({ other: [{ b: 2 }] }); // sales entry dropped, other kept
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirm — guards', () => {
|
||||
test('rejects a duplicate name and creates nothing', () => {
|
||||
activeDraft('{"data":{"values":[{"a":1}]}}');
|
||||
extract().begin({
|
||||
source: { values: [{ a: 1 }], format: 'json' },
|
||||
target: { kind: 'inline', anchorPath: [] },
|
||||
});
|
||||
extract().setName('Sales');
|
||||
extract().confirm();
|
||||
const countAfterFirst = datasets().datasets.length;
|
||||
|
||||
activeDraft('{"data":{"values":[{"b":2}]}}');
|
||||
extract().begin({
|
||||
source: { values: [{ b: 2 }], format: 'json' },
|
||||
target: { kind: 'inline', anchorPath: [] },
|
||||
});
|
||||
extract().setName('Sales');
|
||||
expect(extract().confirm()).toBe(false);
|
||||
expect(extract().error).toMatch(/already exists/);
|
||||
expect(datasets().datasets.length).toBe(countAfterFirst);
|
||||
});
|
||||
|
||||
test('refuses when the anchor path no longer resolves, creating nothing', () => {
|
||||
activeDraft('{"data":{"values":[{"a":1}]}}'); // no layer array
|
||||
extract().begin({
|
||||
source: { values: [{ a: 1 }], format: 'json' },
|
||||
target: { kind: 'inline', anchorPath: ['layer', 3] },
|
||||
});
|
||||
extract().setName('Ghost');
|
||||
expect(extract().confirm()).toBe(false);
|
||||
expect(extract().error).toMatch(/Could not locate/);
|
||||
expect(datasets().datasets.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,92 +1,86 @@
|
||||
/**
|
||||
* Extract-inline-data → Dataset state (spec §03F).
|
||||
* Extract-embedded-data → Dataset state (spec §03F).
|
||||
*
|
||||
* Backs the Extract modal: the reverse of a named reference. It captures the
|
||||
* active snippet draft's inline data, takes a dataset name, and on confirm saves
|
||||
* the data as a new dataset and rewrites the draft so the inline data is replaced
|
||||
* by a by-name reference (`{ "data": { "name": … } }`).
|
||||
* embedded data of the **view the cursor sits in**, takes a dataset name, and on
|
||||
* confirm saves the data as a new library dataset and rewrites the spec so the
|
||||
* embedded data is replaced by a by-name reference.
|
||||
*
|
||||
* Scope (M3): the **top-level** `data` block of the draft spec — the common case
|
||||
* for a single-view chart. Inline data nested inside layers/concats is left for a
|
||||
* later pass; `hasInlineData` reflects exactly what `confirm` can lift, so the
|
||||
* editor only offers the action when this store can act on it.
|
||||
* Two shapes of embedded data, captured as a `target` (spec §03F; multi-view scope
|
||||
* doc M3):
|
||||
* - **inline** — a view's `data.values`. Confirm rewrites that view's `data`
|
||||
* block at its anchor path to `{ name }`.
|
||||
* - **self-defined** — a `{ name: X }` reference to the spec's own top-level
|
||||
* `datasets.X`. Confirm removes the `datasets` entry (and the map when it
|
||||
* empties); the reference resolves to the new library dataset, renamed when the
|
||||
* name changes (`core/spec-refs` → `promoteSelfDefinedDataset`).
|
||||
*
|
||||
* The store is the editor-free side of the flow: it never reads the cursor or the
|
||||
* Monaco model — `services/extract-action` resolves the focused binding and seeds
|
||||
* it via `begin`, so this stays a plain, testable store.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import type { DataFormat } from '@core/format-detection';
|
||||
import { createDataset } from '@core/dataset';
|
||||
import { formatSpec } from '@core/json-format';
|
||||
import { isNameTaken } from '@core/naming';
|
||||
import { setDataBindingAtPath } from '@core/spec-data';
|
||||
import { type InlinePayload, specHasExtractableData } from '@core/spec-inline-data';
|
||||
import { promoteSelfDefinedDataset } from '@core/spec-refs';
|
||||
import { useDatasetStore } from './DatasetStore';
|
||||
import { notify } from './NotificationStore';
|
||||
import { useSnippetStore } from './SnippetStore';
|
||||
|
||||
/** The inline `data` block of a parsed spec, if it carries `values`. */
|
||||
interface InlineData {
|
||||
values: unknown;
|
||||
format: DataFormat;
|
||||
}
|
||||
/** The path of the view whose `data` block Extract rewrites (`[]` = root). */
|
||||
type AnchorPath = ReadonlyArray<string | number>;
|
||||
|
||||
/**
|
||||
* Read the top-level inline data from a draft spec's text, or null when there is
|
||||
* none (no snippet, unparseable, or no `data.values`). The format comes from an
|
||||
* explicit `data.format.type` when present (raw CSV/TSV strings), else JSON.
|
||||
*/
|
||||
function readInlineData(draftText: string): InlineData | null {
|
||||
let parsed: unknown;
|
||||
/** What confirm rewrites — an inline `data` block, or a self-defined `datasets` entry. */
|
||||
export type ExtractTarget =
|
||||
| { kind: 'inline'; anchorPath: AnchorPath }
|
||||
| { kind: 'self-defined'; datasetName: string };
|
||||
|
||||
/** True when the active snippet's draft has data Extract can lift, in any view. */
|
||||
export function hasExtractableData(draftText: string): boolean {
|
||||
try {
|
||||
parsed = JSON.parse(draftText);
|
||||
return specHasExtractableData(JSON.parse(draftText));
|
||||
} catch {
|
||||
return null;
|
||||
return false;
|
||||
}
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const data = (parsed as Record<string, unknown>).data;
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const values = (data as Record<string, unknown>).values;
|
||||
if (values === undefined) return null;
|
||||
const declared = (data as Record<string, unknown>).format;
|
||||
const type =
|
||||
declared && typeof declared === 'object'
|
||||
? (declared as Record<string, unknown>).type
|
||||
: undefined;
|
||||
const format: DataFormat =
|
||||
type === 'csv' || type === 'tsv' || type === 'topojson' ? type : 'json';
|
||||
return { values, format };
|
||||
}
|
||||
|
||||
/** True when the active snippet's draft has top-level inline data to extract. */
|
||||
export function hasInlineData(draftText: string): boolean {
|
||||
return readInlineData(draftText) !== null;
|
||||
}
|
||||
|
||||
export interface ExtractState {
|
||||
/** Proposed dataset name (required, unique). */
|
||||
name: string;
|
||||
/** The inline data captured at open, for the read-only preview. */
|
||||
source: InlineData | null;
|
||||
/** The focused view's embedded data captured at open, for the read-only preview. */
|
||||
source: InlinePayload | null;
|
||||
/** Where `confirm` writes the by-name reference. */
|
||||
target: ExtractTarget | null;
|
||||
/** Inline validation message, or null. */
|
||||
error: string | null;
|
||||
|
||||
/** Capture the active snippet's inline data and reset the form. */
|
||||
init: () => void;
|
||||
/** Seed the form with the focused view's captured data (extract-action). */
|
||||
begin: (captured: { source: InlinePayload; target: ExtractTarget; name?: string }) => void;
|
||||
setName: (name: string) => void;
|
||||
/**
|
||||
* Validate, create the dataset, and rewrite the active draft to reference it by
|
||||
* name. Returns whether it committed; on failure `error` is set. `now`
|
||||
* injectable for tests.
|
||||
* Validate, create the dataset, and rewrite the spec to reference it by name.
|
||||
* Returns whether it committed; on failure `error` is set. `now` injectable for
|
||||
* tests.
|
||||
*/
|
||||
confirm: (now?: Date) => boolean;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const INITIAL = { name: '', source: null as InlineData | null, error: null as string | null };
|
||||
const INITIAL = {
|
||||
name: '',
|
||||
source: null as InlinePayload | null,
|
||||
target: null as ExtractTarget | null,
|
||||
error: null as string | null,
|
||||
};
|
||||
|
||||
export const useExtractStore = create<ExtractState>((set, get) => ({
|
||||
...INITIAL,
|
||||
|
||||
init: () => {
|
||||
const draft = useSnippetStore.getState().draftText;
|
||||
set({ name: '', source: readInlineData(draft), error: null });
|
||||
},
|
||||
begin: ({ source, target, name = '' }) => set({ name, source, target, error: null }),
|
||||
|
||||
setName: (name) => set({ name, error: null }),
|
||||
|
||||
@@ -100,13 +94,42 @@ export const useExtractStore = create<ExtractState>((set, get) => ({
|
||||
set({ error: `A dataset named "${name}" already exists. Choose a different name.` });
|
||||
return false;
|
||||
}
|
||||
const source = get().source;
|
||||
if (!source) {
|
||||
set({ error: 'No inline data to extract.' });
|
||||
const { source, target } = get();
|
||||
if (!source || !target) {
|
||||
set({ error: 'No data to extract.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
// JSON/TopoJSON store the parsed value; CSV/TSV keep raw text — but inline
|
||||
// Resolve the rewrite before any side effect: parse the live draft and apply the
|
||||
// target's rewrite to a fresh copy. Both can fail (the draft is no longer valid
|
||||
// JSON, or its shape changed under the modal); bail with a message and create
|
||||
// nothing rather than leave a half-done extraction.
|
||||
const draftText = useSnippetStore.getState().draftText;
|
||||
let spec: unknown;
|
||||
try {
|
||||
spec = JSON.parse(draftText);
|
||||
} catch {
|
||||
set({ error: 'The spec is no longer valid JSON. Close and reopen Extract.' });
|
||||
return false;
|
||||
}
|
||||
|
||||
let rewritten: unknown;
|
||||
if (target.kind === 'inline') {
|
||||
if (!setDataBindingAtPath(spec, target.anchorPath, { name })) {
|
||||
set({ error: 'Could not locate the data to replace. Close and reopen Extract.' });
|
||||
return false;
|
||||
}
|
||||
rewritten = spec;
|
||||
} else {
|
||||
const next = promoteSelfDefinedDataset(spec, target.datasetName, name);
|
||||
if (!next) {
|
||||
set({ error: 'Could not locate the data to replace. Close and reopen Extract.' });
|
||||
return false;
|
||||
}
|
||||
rewritten = next;
|
||||
}
|
||||
|
||||
// JSON/TopoJSON store the parsed value; CSV/TSV keep raw text — but the captured
|
||||
// `values` is already the right runtime shape for each, so store it directly.
|
||||
const dataset = createDataset({
|
||||
name,
|
||||
@@ -117,20 +140,17 @@ export const useExtractStore = create<ExtractState>((set, get) => ({
|
||||
});
|
||||
useDatasetStore.getState().add(dataset);
|
||||
|
||||
// Rewrite the top-level data block to a by-name reference, preserving the rest
|
||||
// of the spec and its pretty-printed text shape.
|
||||
const draftText = useSnippetStore.getState().draftText;
|
||||
const spec = JSON.parse(draftText) as Record<string, unknown>;
|
||||
spec.data = { name };
|
||||
useSnippetStore.getState().replaceActiveDraft(JSON.stringify(spec, null, 2), now);
|
||||
// Re-serialize in the app's house style (json-format), preserving the spec and
|
||||
// only swapping the captured data for the reference.
|
||||
useSnippetStore.getState().replaceActiveDraft(formatSpec(rewritten), now);
|
||||
|
||||
// Success confirmation (spec §03F). Title states the action; the message
|
||||
// adds the consequence — the spec was rewritten to reference the new dataset
|
||||
// by name (council toast-copy rule, docs/architecture/10 → Toast copy).
|
||||
// Success confirmation (spec §03F). Title states the action; the message adds
|
||||
// the consequence — the spec was rewritten to reference the new dataset by name
|
||||
// (council toast-copy rule, docs/architecture/10 → Toast copy).
|
||||
notify({
|
||||
kind: 'success',
|
||||
title: 'Dataset created',
|
||||
message: `The spec now references "${name}" instead of its inline data.`,
|
||||
message: `The spec now references "${name}" instead of its embedded data.`,
|
||||
});
|
||||
set(INITIAL);
|
||||
return true;
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { usePreviewStore } from './PreviewStore';
|
||||
|
||||
const store = () => usePreviewStore.getState();
|
||||
|
||||
afterEach(() => {
|
||||
// Reset to a known clean state between tests so store leaks don't affect order.
|
||||
store().setError(null);
|
||||
store().setBusy(false);
|
||||
});
|
||||
|
||||
describe('PreviewStore — error slice', () => {
|
||||
it('starts with null error', () => {
|
||||
expect(store().error).toBeNull();
|
||||
});
|
||||
|
||||
it('setError stores the provided message', () => {
|
||||
store().setError('Rendering error: something went wrong.');
|
||||
expect(store().error).toBe('Rendering error: something went wrong.');
|
||||
});
|
||||
|
||||
it('setError(null) clears the message', () => {
|
||||
store().setError('an error');
|
||||
store().setError(null);
|
||||
expect(store().error).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PreviewStore — busy slice', () => {
|
||||
it('starts with busy=false', () => {
|
||||
expect(store().busy).toBe(false);
|
||||
});
|
||||
|
||||
it('setBusy(true) sets busy to true', () => {
|
||||
store().setBusy(true);
|
||||
expect(store().busy).toBe(true);
|
||||
});
|
||||
|
||||
it('setBusy(false) clears busy', () => {
|
||||
store().setBusy(true);
|
||||
store().setBusy(false);
|
||||
expect(store().busy).toBe(false);
|
||||
});
|
||||
|
||||
it('busy and error are independent — setting one does not affect the other', () => {
|
||||
store().setBusy(true);
|
||||
store().setError('some error');
|
||||
expect(store().busy).toBe(true);
|
||||
expect(store().error).toBe('some error');
|
||||
|
||||
store().setBusy(false);
|
||||
expect(store().error).toBe('some error'); // error unchanged by clearing busy
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* Preview render status — the bridge between the Live Preview (which owns
|
||||
* rendering) and the two panes that surface its outcome.
|
||||
*
|
||||
* Both the editor and the preview must show the same render problem: spec §03E
|
||||
* puts a readable error in the **editor** pane, and spec §04 puts one in the
|
||||
* **preview** pane, for the very same failure (invalid JSON, or valid JSON that
|
||||
* fails to render as Vega-Lite — including an unresolved dataset reference). The
|
||||
* Live Preview is the single producer; it writes the current error here and both
|
||||
* panes subscribe. `null` means the current spec rendered cleanly (or is blank).
|
||||
*
|
||||
* `busy` tracks whether a render is in flight long enough to warrant a visible
|
||||
* indicator (arch §10.2: >~1s owes a non-blocking busy overlay). LivePreview arms
|
||||
* a 1 s timer when a render starts and sets `busy = true` only if the render has
|
||||
* not settled by then; it clears `busy` on settle or error regardless.
|
||||
*
|
||||
* Kept as its own tiny store rather than folded into the SnippetStore: this is
|
||||
* transient render state, not durable domain data, and it must not be persisted.
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
|
||||
export interface PreviewState {
|
||||
/** The current render error message, or null when the spec renders cleanly. */
|
||||
error: string | null;
|
||||
/** Set (or clear) the current render error. */
|
||||
setError: (error: string | null) => void;
|
||||
/**
|
||||
* True while a render has been in flight for longer than the ~1s NN/g threshold
|
||||
* (arch §10.2). The LivePreview overlay reads this to show a non-blocking busy
|
||||
* indication; aria-busy on the preview region mirrors it.
|
||||
*/
|
||||
busy: boolean;
|
||||
/** Set or clear the busy flag. */
|
||||
setBusy: (busy: boolean) => void;
|
||||
}
|
||||
|
||||
export const usePreviewStore = create<PreviewState>((set) => ({
|
||||
error: null,
|
||||
setError: (error) => set({ error }),
|
||||
busy: false,
|
||||
setBusy: (busy) => set({ busy }),
|
||||
}));
|
||||
@@ -114,7 +114,7 @@ export interface SnippetState {
|
||||
/**
|
||||
* Replace the active snippet's draft spec with new text and reload the editor
|
||||
* on the draft view (bumps `bufferEpoch`). Used by programmatic rewrites such
|
||||
* as Extract-to-Dataset (spec §03F), which substitutes inline data for a
|
||||
* as Extract-to-Dataset (spec §03F), which substitutes embedded data for a
|
||||
* by-name reference. No-op when no snippet is active. `now` injectable.
|
||||
*/
|
||||
replaceActiveDraft: (text: string, now?: Date) => void;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateExpression, referencedFields } from './expr-validate';
|
||||
import { validateExpression, referencedFields, activeCall } from './expr-validate';
|
||||
|
||||
describe('validateExpression', () => {
|
||||
it('accepts a well-formed Vega expression', () => {
|
||||
@@ -37,3 +37,35 @@ describe('referencedFields', () => {
|
||||
expect(referencedFields('datum.price *')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activeCall', () => {
|
||||
it('returns null when the cursor is not inside any call', () => {
|
||||
expect(activeCall('datum.price + ')).toBeNull();
|
||||
expect(activeCall('')).toBeNull();
|
||||
});
|
||||
|
||||
it('names the enclosing call and reports the first argument', () => {
|
||||
expect(activeCall('clamp(')).toEqual({ name: 'clamp', activeParam: 0 });
|
||||
expect(activeCall('if(datum.x > 0')).toEqual({ name: 'if', activeParam: 0 });
|
||||
});
|
||||
|
||||
it('counts commas to find the active argument', () => {
|
||||
expect(activeCall('clamp(datum.x, 0, ')).toEqual({ name: 'clamp', activeParam: 2 });
|
||||
});
|
||||
|
||||
it('reports the innermost call when calls are nested', () => {
|
||||
expect(activeCall('if(datum.x > 0, min(1, ')).toEqual({ name: 'min', activeParam: 1 });
|
||||
});
|
||||
|
||||
it('keeps a nested array argument on its outer call argument', () => {
|
||||
expect(activeCall('clamp(datum.x, [1, 2')).toEqual({ name: 'clamp', activeParam: 1 });
|
||||
});
|
||||
|
||||
it('does not count commas inside string literals', () => {
|
||||
expect(activeCall("if(test(regexp('a,b'), datum.s), ")).toEqual({ name: 'if', activeParam: 1 });
|
||||
});
|
||||
|
||||
it('treats a bare grouping paren as not a call', () => {
|
||||
expect(activeCall('(datum.x + 1) * ')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,6 +85,63 @@ export function referencedFields(expr: string): string[] {
|
||||
return fields;
|
||||
}
|
||||
|
||||
/** The function call a cursor sits inside, and which argument it is on. */
|
||||
export interface ActiveCall {
|
||||
/** The called function's name (the identifier before the open paren). */
|
||||
name: string;
|
||||
/** Zero-based index of the argument the cursor is in (commas seen so far). */
|
||||
activeParam: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the expression text from its start up to the cursor, the innermost
|
||||
* function call the cursor sits inside — its name and the argument index — or
|
||||
* `null` when the cursor is not within a call. Used to drive editor signature
|
||||
* help. A forward scan keeps a stack of bracket frames (parens and square
|
||||
* brackets), skips string literals, and counts commas per frame; the nearest
|
||||
* unclosed frame whose open paren follows an identifier is the active call, and
|
||||
* that frame's comma count is the active argument.
|
||||
*/
|
||||
export function activeCall(prefix: string): ActiveCall | null {
|
||||
interface Frame {
|
||||
name: string | null;
|
||||
commas: number;
|
||||
}
|
||||
const stack: Frame[] = [];
|
||||
for (let i = 0; i < prefix.length; i++) {
|
||||
const c = prefix[i];
|
||||
if (c === '\\') {
|
||||
i++; // an escape consumes the next character
|
||||
continue;
|
||||
}
|
||||
if (c === '"' || c === "'") {
|
||||
// Skip a string literal so its parens/commas don't disturb the scan.
|
||||
const quote = c;
|
||||
i++;
|
||||
while (i < prefix.length && prefix[i] !== quote) {
|
||||
if (prefix[i] === '\\') i++;
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (c === '(') {
|
||||
const name = /([A-Za-z_$][A-Za-z0-9_$]*)\s*$/.exec(prefix.slice(0, i));
|
||||
stack.push({ name: name ? name[1] : null, commas: 0 });
|
||||
} else if (c === '[') {
|
||||
stack.push({ name: null, commas: 0 });
|
||||
} else if (c === ')' || c === ']') {
|
||||
stack.pop();
|
||||
} else if (c === ',' && stack.length > 0) {
|
||||
stack[stack.length - 1].commas++;
|
||||
}
|
||||
}
|
||||
for (let i = stack.length - 1; i >= 0; i--) {
|
||||
const frame = stack[i];
|
||||
if (frame.name !== null) return { name: frame.name, activeParam: frame.commas };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The static field name of a `datum.<name>` / `datum['name']` member access, or
|
||||
* `null` when the node isn't such an access (a different object, computed-dynamic
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import { inspectViewLabel, inspectableViews } from './inspect-views';
|
||||
|
||||
// Fixtures mirror the shapes Vega-Lite 6 actually compiles to (verified by
|
||||
// compiling each composition and dumping `vgSpec.data` + `vgSpec.marks`).
|
||||
|
||||
describe('inspectableViews', () => {
|
||||
test('a single unit: one drawn table, resolved + input ends of its pipeline', () => {
|
||||
const vg = {
|
||||
data: [
|
||||
{ name: 'source_0', values: [] },
|
||||
{ name: 'data_0', source: 'source_0' },
|
||||
],
|
||||
marks: [{ type: 'rect', name: 'marks', from: { data: 'data_0' } }],
|
||||
};
|
||||
expect(inspectableViews(vg)).toEqual([{ resolved: 'data_0', input: 'source_0' }]);
|
||||
});
|
||||
|
||||
test('vconcat sharing one source: two tables, same input, different resolved', () => {
|
||||
const vg = {
|
||||
data: [
|
||||
{ name: 'source_0', values: [] },
|
||||
{ name: 'data_1', source: 'source_0' },
|
||||
{ name: 'data_2', source: 'source_0' },
|
||||
],
|
||||
marks: [
|
||||
{
|
||||
type: 'group',
|
||||
name: 'concat_0_group',
|
||||
marks: [{ type: 'rect', name: 'concat_0_marks', from: { data: 'data_1' } }],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
name: 'concat_1_group',
|
||||
marks: [{ type: 'rect', name: 'concat_1_marks', from: { data: 'data_2' } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(inspectableViews(vg)).toEqual([
|
||||
{ resolved: 'data_1', input: 'source_0' },
|
||||
{ resolved: 'data_2', input: 'source_0' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('layers binding different data: each table traces to its own named source', () => {
|
||||
const vg = {
|
||||
data: [
|
||||
{ name: 'a', values: [] },
|
||||
{ name: 'b', values: [] },
|
||||
{ name: 'data_0', source: 'a' },
|
||||
{ name: 'data_1', source: 'b' },
|
||||
],
|
||||
marks: [
|
||||
{ type: 'line', name: 'layer_0_marks', from: { data: 'data_0' } },
|
||||
{ type: 'symbol', name: 'layer_1_marks', from: { data: 'data_1' } },
|
||||
],
|
||||
};
|
||||
expect(inspectableViews(vg)).toEqual([
|
||||
{ resolved: 'data_0', input: 'a' },
|
||||
{ resolved: 'data_1', input: 'b' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('repeat: one drawn table per repeated child', () => {
|
||||
const vg = {
|
||||
data: [
|
||||
{ name: 'source_0', values: [] },
|
||||
{ name: 'data_1', source: 'source_0' },
|
||||
{ name: 'data_2', source: 'source_0' },
|
||||
],
|
||||
marks: [
|
||||
{
|
||||
type: 'group',
|
||||
name: 'child__a_group',
|
||||
marks: [{ type: 'rect', name: 'child__a_marks', from: { data: 'data_1' } }],
|
||||
},
|
||||
{
|
||||
type: 'group',
|
||||
name: 'child__b_group',
|
||||
marks: [{ type: 'rect', name: 'child__b_marks', from: { data: 'data_2' } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(inspectableViews(vg).map((v) => v.resolved)).toEqual(['data_1', 'data_2']);
|
||||
});
|
||||
|
||||
test('facet: the cell data via from.facet.data; layout-helper tables excluded', () => {
|
||||
const vg = {
|
||||
data: [
|
||||
{ name: 'source_0', values: [] },
|
||||
{ name: 'data_0', source: 'source_0' },
|
||||
{ name: 'facet_domain', source: 'data_0' },
|
||||
{ name: 'facet_domain_row' },
|
||||
{ name: 'facet_domain_column' },
|
||||
],
|
||||
marks: [
|
||||
{ type: 'group', name: 'facet-title' },
|
||||
{ type: 'group', name: 'row_header', from: { data: 'facet_domain_row' } },
|
||||
{ type: 'group', name: 'column_footer', from: { data: 'facet_domain_column' } },
|
||||
{
|
||||
type: 'group',
|
||||
name: 'cell',
|
||||
from: { facet: { data: 'data_0' } },
|
||||
marks: [{ type: 'rect', name: 'child_marks', from: { data: 'facet' } }],
|
||||
},
|
||||
],
|
||||
};
|
||||
// Only the faceted cell data is inspectable; facet_domain* are layout, and the
|
||||
// child's `from: { data: 'facet' }` names no real table.
|
||||
expect(inspectableViews(vg)).toEqual([{ resolved: 'data_0', input: 'source_0' }]);
|
||||
});
|
||||
|
||||
test('input equals resolved when the drawn table is itself the source (no transforms)', () => {
|
||||
const vg = {
|
||||
data: [{ name: 'source_0', values: [] }],
|
||||
marks: [{ type: 'rect', name: 'marks', from: { data: 'source_0' } }],
|
||||
};
|
||||
expect(inspectableViews(vg)).toEqual([{ resolved: 'source_0', input: 'source_0' }]);
|
||||
});
|
||||
|
||||
test('a line+point unit shows two tables (Vega-Lite desugars point into a layer)', () => {
|
||||
// Documented consequence of enumerating drawn tables: point overlay → two
|
||||
// near-identical tables (data_1 derives from data_0), both tracing to source_0.
|
||||
const vg = {
|
||||
data: [
|
||||
{ name: 'source_0', values: [] },
|
||||
{ name: 'data_0', source: 'source_0' },
|
||||
{ name: 'data_1', source: 'data_0' },
|
||||
],
|
||||
marks: [
|
||||
{ type: 'line', name: 'layer_0_marks', from: { data: 'data_0' } },
|
||||
{ type: 'symbol', name: 'layer_1_marks', from: { data: 'data_1' } },
|
||||
],
|
||||
};
|
||||
expect(inspectableViews(vg)).toEqual([
|
||||
{ resolved: 'data_0', input: 'source_0' },
|
||||
{ resolved: 'data_1', input: 'source_0' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('deduplicates a table drawn by more than one mark', () => {
|
||||
const vg = {
|
||||
data: [
|
||||
{ name: 'source_0', values: [] },
|
||||
{ name: 'data_0', source: 'source_0' },
|
||||
],
|
||||
marks: [
|
||||
{ type: 'rect', name: 'm1', from: { data: 'data_0' } },
|
||||
{ type: 'text', name: 'm2', from: { data: 'data_0' } },
|
||||
],
|
||||
};
|
||||
expect(inspectableViews(vg)).toEqual([{ resolved: 'data_0', input: 'source_0' }]);
|
||||
});
|
||||
|
||||
test('returns [] for malformed or data-less specs', () => {
|
||||
expect(inspectableViews(null)).toEqual([]);
|
||||
expect(inspectableViews({})).toEqual([]);
|
||||
expect(inspectableViews({ data: [], marks: [] })).toEqual([]);
|
||||
expect(inspectableViews({ marks: [{ type: 'rect', from: { data: 'ghost' } }] })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inspectViewLabel', () => {
|
||||
test('compiler-generated names become an ordinal "View N"', () => {
|
||||
expect(inspectViewLabel('source_0', 0)).toBe('View 1');
|
||||
expect(inspectViewLabel('data_2', 1)).toBe('View 2');
|
||||
});
|
||||
|
||||
test('a user-authored dataset name is shown verbatim', () => {
|
||||
expect(inspectViewLabel('sales', 0)).toBe('sales');
|
||||
expect(inspectViewLabel('data_foo', 2)).toBe('data_foo'); // no trailing digits → not compiler
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Inspectable drawn tables of a compiled chart (the data inspector — spec §04;
|
||||
* docs/architecture/05 → "the data inspector rides the boundary").
|
||||
*
|
||||
* Portable core: pure analysis of a **compiled Vega spec** (the byproduct
|
||||
* `vega-embed` already produces to render the chart — nothing is compiled here).
|
||||
* A multi-view Vega-Lite spec (layer/concat/facet/repeat) compiles to several
|
||||
* data tables, and the inspector lets the user pick which one to look at — "what
|
||||
* data am I actually visualizing?" — to debug in-spec transforms.
|
||||
*
|
||||
* What counts as inspectable is the set of tables the **marks actually draw**, read
|
||||
* from the compiled marks tree's `from.data` (and a facet cell's `from.facet.data`).
|
||||
* That is the honest answer to the question and it sidesteps a mapping that cannot
|
||||
* be made reliable: Vega-Lite *desugars* some marks into layers (e.g. a `line` with
|
||||
* `point: true` becomes two marks over two tables), so a compiled table cannot be
|
||||
* traced back to a single authored view. We therefore enumerate by drawn table, not
|
||||
* by authored view — one consequence being that a point-overlay shows as two nearly
|
||||
* identical tables (the line's and the point's), which is literally what is drawn.
|
||||
*
|
||||
* For each drawn table we report two ends, mirroring the inspector's Input |
|
||||
* Resolved toggle: `resolved` is the table the marks draw (after the view's
|
||||
* transforms), and `input` is its most-upstream source (before them), found by
|
||||
* following each table's `source` link — the documented, stable Vega dataflow
|
||||
* format.
|
||||
*/
|
||||
|
||||
/** A drawn table the user can inspect, with the two ends of its pipeline. */
|
||||
export interface InspectableView {
|
||||
/** The compiled dataset the marks draw — the post-transform "Resolved" rows. */
|
||||
resolved: string;
|
||||
/** The most-upstream source of `resolved` — the "Input" rows before transforms. */
|
||||
input: string;
|
||||
}
|
||||
|
||||
interface VgData {
|
||||
name?: unknown;
|
||||
source?: unknown;
|
||||
}
|
||||
|
||||
interface VgMark {
|
||||
from?: { data?: unknown; facet?: { data?: unknown } };
|
||||
marks?: unknown;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/** Vega's compiler-generated table names (`source_0`, `data_2`) — jargon, never shown. */
|
||||
function isCompilerName(name: string): boolean {
|
||||
return /^(source|data)_\d+$/.test(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* A user-facing label for an inspectable view: its input source's name when that
|
||||
* is user-authored (a named dataset like `sales`), else an ordinal `View N`
|
||||
* (1-based, by document order). Compiler-generated names never surface — they are
|
||||
* jargon to the user (council: NN/g #2 "speak the users' language";
|
||||
* docs/architecture/10). The richer recognition cue — columns + row count — is the
|
||||
* UI's `detail` line, built from the rows.
|
||||
*/
|
||||
export function inspectViewLabel(input: string, index: number): string {
|
||||
return isCompilerName(input) ? `View ${index + 1}` : input;
|
||||
}
|
||||
|
||||
/** Facet layout helper tables (domains/headers), never a table the user draws into. */
|
||||
function isLayoutHelper(name: string): boolean {
|
||||
return name.startsWith('facet_domain');
|
||||
}
|
||||
|
||||
/** The table a mark draws, from `from.data` or a facet cell's `from.facet.data`. */
|
||||
function drawnTable(mark: VgMark): string | null {
|
||||
const from = mark.from;
|
||||
if (!isObject(from)) return null;
|
||||
if (typeof from.data === 'string') return from.data;
|
||||
if (isObject(from.facet) && typeof from.facet.data === 'string') return from.facet.data;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The inspectable drawn tables of a compiled Vega spec, in document order, one per
|
||||
* distinct table the marks render. Returns `[]` for a spec with no drawable data
|
||||
* (or a malformed input).
|
||||
*/
|
||||
export function inspectableViews(vgSpec: unknown): InspectableView[] {
|
||||
if (!isObject(vgSpec)) return [];
|
||||
const dataList: VgData[] = Array.isArray(vgSpec.data) ? (vgSpec.data as VgData[]) : [];
|
||||
const sourceOf = new Map<string, string | undefined>();
|
||||
for (const d of dataList) {
|
||||
if (isObject(d) && typeof d.name === 'string') {
|
||||
sourceOf.set(d.name, typeof d.source === 'string' ? d.source : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
// The most-upstream ancestor of `name` via the `source` chain (cycle-guarded).
|
||||
const rootSourceOf = (name: string): string => {
|
||||
const seen = new Set<string>();
|
||||
let current = name;
|
||||
while (sourceOf.has(current) && !seen.has(current)) {
|
||||
const next = sourceOf.get(current);
|
||||
if (next === undefined) break;
|
||||
seen.add(current);
|
||||
current = next;
|
||||
}
|
||||
return current;
|
||||
};
|
||||
|
||||
const views: InspectableView[] = [];
|
||||
const seen = new Set<string>();
|
||||
const visit = (node: unknown): void => {
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) visit(item);
|
||||
return;
|
||||
}
|
||||
if (!isObject(node)) return;
|
||||
const mark = node as VgMark;
|
||||
const table = drawnTable(mark);
|
||||
if (table !== null && sourceOf.has(table) && !isLayoutHelper(table) && !seen.has(table)) {
|
||||
seen.add(table);
|
||||
views.push({ resolved: table, input: rootSourceOf(table) });
|
||||
}
|
||||
if (Array.isArray(mark.marks)) visit(mark.marks);
|
||||
};
|
||||
visit(vgSpec.marks);
|
||||
return views;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseLesson, type LessonBlock } from './lesson-parse';
|
||||
import { injectDatasets, parseLesson, type LessonBlock } from './lesson-parse';
|
||||
|
||||
const LESSON = `---
|
||||
slug: binning
|
||||
@@ -105,6 +105,12 @@ describe('parseLesson — block model', () => {
|
||||
expect(() => parseLesson(`---\nslug: x\ntagline: y\n---\nhi`)).toThrow(/missing "title"/);
|
||||
});
|
||||
|
||||
it('throws on a slug outside [a-z0-9-] (router/shell contract)', () => {
|
||||
expect(() => parseLesson(`---\nslug: Bad_Slug\ntitle: t\ntagline: g\n---\nhi`)).toThrow(
|
||||
/slug must be lowercase/,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws with stage context on invalid JSON', () => {
|
||||
const bad = `---\nslug: x\ntitle: t\ntagline: g\n---\n:::progression\n## a\n\`\`\`vega-lite\n{ bad }\n\`\`\`\n:::`;
|
||||
expect(() => parseLesson(bad)).toThrow(/stage "a": invalid JSON/);
|
||||
@@ -124,4 +130,76 @@ describe('parseLesson — block model', () => {
|
||||
'callout',
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults to no datasets when there is no :::data block', () => {
|
||||
expect(parseLesson(LESSON).datasets).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLesson — :::data blocks', () => {
|
||||
const withData = `---\nslug: x\ntitle: t\ntagline: g\n---
|
||||
:::data
|
||||
{ "ev": [{ "arm": "A", "n": 1 }, { "arm": "B", "n": 2 }] }
|
||||
:::
|
||||
|
||||
A chart that references it by name:
|
||||
|
||||
\`\`\`vega-lite
|
||||
{ "data": { "name": "ev" }, "mark": "bar" }
|
||||
\`\`\``;
|
||||
|
||||
it('collects named rows into lesson.datasets, not as a block', () => {
|
||||
const lesson = parseLesson(withData);
|
||||
expect(lesson.datasets).toEqual({
|
||||
ev: [
|
||||
{ arm: 'A', n: 1 },
|
||||
{ arm: 'B', n: 2 },
|
||||
],
|
||||
});
|
||||
// The data directive is metadata — only the prose + chart are blocks.
|
||||
expect(types(lesson.blocks)).toEqual(['prose', 'chart']);
|
||||
});
|
||||
|
||||
it('merges multiple :::data blocks', () => {
|
||||
const two = `---\nslug: x\ntitle: t\ntagline: g\n---
|
||||
:::data
|
||||
{ "a": [{ "v": 1 }] }
|
||||
:::
|
||||
:::data
|
||||
{ "b": [{ "v": 2 }] }
|
||||
:::
|
||||
Words.`;
|
||||
expect(parseLesson(two).datasets).toEqual({ a: [{ v: 1 }], b: [{ v: 2 }] });
|
||||
});
|
||||
|
||||
it('throws on a non-object :::data body', () => {
|
||||
const bad = `---\nslug: x\ntitle: t\ntagline: g\n---\n:::data\n[1, 2]\n:::`;
|
||||
expect(() => parseLesson(bad)).toThrow(/:::data: body must be a JSON object/);
|
||||
});
|
||||
|
||||
it('throws when a dataset value is not an array', () => {
|
||||
const bad = `---\nslug: x\ntitle: t\ntagline: g\n---\n:::data\n{ "ev": 5 }\n:::\nhi`;
|
||||
expect(() => parseLesson(bad)).toThrow(/:::data "ev": value must be an array/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('injectDatasets', () => {
|
||||
const datasets = { ev: [{ n: 1 }] };
|
||||
|
||||
it('merges lesson datasets into a clone, leaving the source spec untouched', () => {
|
||||
const spec = { data: { name: 'ev' }, mark: 'bar' };
|
||||
const out = injectDatasets(spec, datasets);
|
||||
expect(out).toEqual({ data: { name: 'ev' }, mark: 'bar', datasets: { ev: [{ n: 1 }] } });
|
||||
expect(spec).not.toHaveProperty('datasets');
|
||||
});
|
||||
|
||||
it("lets a spec's own datasets win on a name clash", () => {
|
||||
const spec = { datasets: { ev: [{ n: 99 }] }, mark: 'bar' };
|
||||
expect(injectDatasets(spec, datasets).datasets).toEqual({ ev: [{ n: 99 }] });
|
||||
});
|
||||
|
||||
it('returns the spec unchanged when there are no datasets', () => {
|
||||
const spec = { mark: 'bar' };
|
||||
expect(injectDatasets(spec, {})).toBe(spec);
|
||||
});
|
||||
});
|
||||
|
||||
+81
-15
@@ -22,11 +22,15 @@
|
||||
*
|
||||
* Free intro prose (markdown)…
|
||||
*
|
||||
* :::data
|
||||
* { "ev": [ {…row…}, {…row…} ] }
|
||||
* :::
|
||||
*
|
||||
* :::progression
|
||||
* ## raw counts
|
||||
* Note prose for this stage (markdown)…
|
||||
* ```vega-lite
|
||||
* { …spec… }
|
||||
* { "data": { "name": "ev" }, …spec… }
|
||||
* ```
|
||||
* ## + bin
|
||||
* …
|
||||
@@ -42,10 +46,13 @@
|
||||
* A callout (markdown)…
|
||||
* :::
|
||||
*
|
||||
* A `:::progression` block holds `##`-delimited stages (heading → tab label, prose
|
||||
* → note, fenced `vega-lite` → spec). A bare fenced `vega-lite` block is a
|
||||
* standalone chart. Any other `:::name` block is a callout. Everything else is
|
||||
* prose, emitted in the order it appears.
|
||||
* A `:::data` block names datasets once for the whole lesson (body: a JSON object
|
||||
* of `name → rows`); specs reference them by `{ "name": … }` and the rows are
|
||||
* injected at render time, so they aren't repeated per stage. A `:::progression`
|
||||
* block holds `##`-delimited stages (heading → tab label, prose → note, fenced
|
||||
* `vega-lite` → spec). A bare fenced `vega-lite` block is a standalone chart. Any
|
||||
* other `:::name` block is a callout. Everything else is prose, emitted in the
|
||||
* order it appears.
|
||||
*/
|
||||
|
||||
export interface ProgressionStage {
|
||||
@@ -81,6 +88,12 @@ export interface ParsedLesson {
|
||||
slug: string;
|
||||
title: string;
|
||||
tagline: string;
|
||||
/**
|
||||
* Lesson-level named datasets, collected from `:::data` blocks. Specs reference
|
||||
* a dataset by `{ "data": { "name": … } }`; {@link injectDatasets} merges these
|
||||
* in at render time so the rows aren't repeated in every fenced spec.
|
||||
*/
|
||||
datasets: Record<string, unknown[]>;
|
||||
/** The lesson body, in document order. */
|
||||
blocks: LessonBlock[];
|
||||
}
|
||||
@@ -100,14 +113,25 @@ export function parseLesson(source: string): ParsedLesson {
|
||||
if (!fm) throw new Error('lesson: missing `---` frontmatter block');
|
||||
const meta = parseFrontmatter(fm[1]);
|
||||
const slug = required(meta, 'slug');
|
||||
// The slug is the lesson's URL segment (`/learn/<slug>/`) and the route key; keep it
|
||||
// to the charset the router and the page-shell generator both assume, so a typo fails
|
||||
// loudly here rather than silently serving the wrong page.
|
||||
if (!/^[a-z0-9-]+$/.test(slug)) {
|
||||
throw new Error(`lesson "${slug}": slug must be lowercase letters, digits, and hyphens`);
|
||||
}
|
||||
const title = required(meta, 'title');
|
||||
const tagline = required(meta, 'tagline');
|
||||
|
||||
return { slug, title, tagline, blocks: parseBlocks(text.slice(fm[0].length), slug) };
|
||||
const { blocks, datasets } = parseBlocks(text.slice(fm[0].length), slug);
|
||||
return { slug, title, tagline, datasets, blocks };
|
||||
}
|
||||
|
||||
function parseBlocks(body: string, slug: string): LessonBlock[] {
|
||||
function parseBlocks(
|
||||
body: string,
|
||||
slug: string,
|
||||
): { blocks: LessonBlock[]; datasets: Record<string, unknown[]> } {
|
||||
const blocks: LessonBlock[] = [];
|
||||
const datasets: Record<string, unknown[]> = {};
|
||||
const pushProse = (text: string): void => {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed) blocks.push({ type: 'prose', markdown: trimmed });
|
||||
@@ -121,13 +145,15 @@ function parseBlocks(body: string, slug: string): LessonBlock[] {
|
||||
if (m[1] !== undefined) {
|
||||
const variant = m[1];
|
||||
const inner = m[2];
|
||||
// TODO: if lessons multiply, add a `:::data` directive whose values stages
|
||||
// inherit — today a lesson's inline data is repeated in every fenced spec.
|
||||
blocks.push(
|
||||
variant === 'progression'
|
||||
? { type: 'progression', stages: parseStages(inner, slug) }
|
||||
: { type: 'callout', variant, markdown: inner.trim() },
|
||||
);
|
||||
if (variant === 'progression') {
|
||||
blocks.push({ type: 'progression', stages: parseStages(inner, slug) });
|
||||
} else if (variant === 'data') {
|
||||
// Lesson metadata, not a rendered block: fold the named rows into the
|
||||
// lesson's dataset map for render-time injection.
|
||||
Object.assign(datasets, parseDatasets(inner, slug));
|
||||
} else {
|
||||
blocks.push({ type: 'callout', variant, markdown: inner.trim() });
|
||||
}
|
||||
} else {
|
||||
blocks.push({ type: 'chart', spec: parseSpec(m[3], slug, 'chart') });
|
||||
}
|
||||
@@ -136,7 +162,47 @@ function parseBlocks(body: string, slug: string): LessonBlock[] {
|
||||
pushProse(body.slice(cursor));
|
||||
|
||||
if (blocks.length === 0) throw new Error(`lesson "${slug}": no content`);
|
||||
return blocks;
|
||||
return { blocks, datasets };
|
||||
}
|
||||
|
||||
/** Parse a `:::data` body — a JSON object of `name → rows[]` — into named datasets. */
|
||||
function parseDatasets(inner: string, slug: string): Record<string, unknown[]> {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(inner.trim());
|
||||
} catch (e) {
|
||||
throw new Error(`lesson "${slug}", :::data: invalid JSON — ${(e as Error).message}`, {
|
||||
cause: e,
|
||||
});
|
||||
}
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error(`lesson "${slug}", :::data: body must be a JSON object of { name: rows }`);
|
||||
}
|
||||
const out: Record<string, unknown[]> = {};
|
||||
for (const [name, rows] of Object.entries(parsed)) {
|
||||
if (!Array.isArray(rows)) {
|
||||
throw new Error(`lesson "${slug}", :::data "${name}": value must be an array of rows`);
|
||||
}
|
||||
out[name] = rows;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge lesson-level named datasets into a spec for *rendering* — the authored
|
||||
* spec (shown in the source pane and diffed) keeps its `{ "data": { "name": … } }`
|
||||
* reference and never carries the rows. Returns a clone with the spec's own
|
||||
* `datasets` winning on a name clash; mirrors the clone-before-embed rule the app's
|
||||
* other render-time spec tweaks follow (never mutate the authored spec). A no-op
|
||||
* (same reference) when there are no datasets to inject.
|
||||
*/
|
||||
export function injectDatasets(
|
||||
spec: Record<string, unknown>,
|
||||
datasets: Record<string, unknown[]>,
|
||||
): Record<string, unknown> {
|
||||
if (Object.keys(datasets).length === 0) return spec;
|
||||
const own = (spec.datasets as Record<string, unknown> | undefined) ?? {};
|
||||
return { ...spec, datasets: { ...datasets, ...own } };
|
||||
}
|
||||
|
||||
function parseStages(inner: string, slug: string): ProgressionStage[] {
|
||||
|
||||
@@ -203,6 +203,26 @@ describe('prepareSpecForRender — dataset resolution (spec §04 Rendering Contr
|
||||
expect(out.data).toEqual({ name: 'local' });
|
||||
});
|
||||
|
||||
test('native Vega-Lite data with a name label is left untouched (not resolved, never throws)', () => {
|
||||
// A name on inline/url data, or a generator, is native Vega-Lite — even when
|
||||
// the name is absent from the library. Resolution must not touch or reject it.
|
||||
const inline = { data: { name: 'pts', values: [{ a: 1 }] }, mark: 'point' };
|
||||
expect(prepareSpecForRender(inline, { datasets }).data).toEqual({
|
||||
name: 'pts',
|
||||
values: [{ a: 1 }],
|
||||
});
|
||||
const url = { data: { name: 'remote', url: 'https://x/y.csv' } };
|
||||
expect(prepareSpecForRender(url, { datasets }).data).toEqual({
|
||||
name: 'remote',
|
||||
url: 'https://x/y.csv',
|
||||
});
|
||||
const gen = { data: { name: 'seq', sequence: { start: 0, stop: 5 } } };
|
||||
expect(prepareSpecForRender(gen, { datasets }).data).toEqual({
|
||||
name: 'seq',
|
||||
sequence: { start: 0, stop: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
test('resolution runs before fit-mode: a ref + fit mode produce both transforms', () => {
|
||||
const spec = { data: { name: 'JsonDs' }, mark: 'bar' };
|
||||
const out = prepareSpecForRender(spec, { datasets, fitMode: 'full' }) as unknown as {
|
||||
|
||||
+26
-33
@@ -10,13 +10,15 @@
|
||||
* 1. Dataset reference resolution — implemented in M3 (see below).
|
||||
* 2. Fit-mode sizing — implemented in M2.
|
||||
*
|
||||
* Step 1 (spec §04 → Rendering Contract): every named-data reference
|
||||
* Step 1 (spec §04 → Rendering Contract): every library reference
|
||||
* (`{ data: { name } }`) is replaced in-place with the referenced library
|
||||
* dataset's actual contents, shaped by source and format. A name the spec defines
|
||||
* for itself via a top-level `datasets` object is left untouched (Vega-Lite
|
||||
* resolves it natively); an unknown library name throws `DatasetNotFoundError`.
|
||||
* Resolution recurses into the same nested sub-specs as fit-mode, runs before
|
||||
* sizing, and operates only on the copy.
|
||||
* dataset's actual contents, shaped by source and format. What counts as a library
|
||||
* reference is decided by `core/spec-data` (named data, not self-defined), so a
|
||||
* `name` riding on inline `values`/`url`/a generator is native Vega-Lite data and
|
||||
* is left untouched; a name the spec defines for itself via a top-level `datasets`
|
||||
* object is left untouched too (Vega-Lite resolves it natively); an unknown library
|
||||
* name throws `DatasetNotFoundError`. Resolution recurses into the same nested
|
||||
* sub-specs as fit-mode, runs before sizing, and operates only on the copy.
|
||||
*
|
||||
* The copy-not-mutate invariant and the call site the renderer depends on are
|
||||
* fixed.
|
||||
@@ -24,6 +26,7 @@
|
||||
|
||||
import type { DataFormat } from './format-detection';
|
||||
import type { DataSource } from './dataset';
|
||||
import { libraryRefName, selfDefinedNames } from './spec-data';
|
||||
|
||||
/** Preview sizing modes (spec §04 → Fit / Sizing Modes). `default` = Original. */
|
||||
export type FitMode = 'default' | 'width' | 'height' | 'full';
|
||||
@@ -113,16 +116,6 @@ function applyFitMode(node: unknown, mode: FitMode): void {
|
||||
if (isSpecNode(node.spec)) applyFitMode(node.spec, mode);
|
||||
}
|
||||
|
||||
/** The set of dataset names a spec defines for itself via top-level `datasets`. */
|
||||
function selfDefinedDatasetNames(spec: unknown): Set<string> {
|
||||
const names = new Set<string>();
|
||||
if (isSpecNode(spec)) {
|
||||
const datasets = spec.datasets;
|
||||
if (isSpecNode(datasets)) for (const key of Object.keys(datasets)) names.add(key);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the replacement `data` object for one resolved reference (spec §04 →
|
||||
* Rendering Contract, step 1). `rest` is the reference's other keys (e.g. a
|
||||
@@ -157,13 +150,16 @@ function resolvedData(dataset: ResolvableDataset, rest: SpecNode): SpecNode {
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace every named-data reference in `node` with its library dataset's
|
||||
* contents, recursing through the spec (arrays and objects) so refs anywhere are
|
||||
* resolved — matching `extractDatasetRefs`, including pruning the `data` and
|
||||
* top-level `datasets` payload keys so resolution never descends into user data
|
||||
* rows. A self-defined name is left untouched; an unknown library name throws
|
||||
* `DatasetNotFoundError`. Matching is case-insensitive, mirroring naming.ts.
|
||||
* Mutates in place; the caller already works on a copy.
|
||||
* Replace every library reference in `node` with its dataset's contents, recursing
|
||||
* through the spec (arrays and objects) so refs anywhere are resolved — matching
|
||||
* `extractDatasetRefs`, including pruning the `data` and top-level `datasets`
|
||||
* payload keys so resolution never descends into user data rows. What counts as a
|
||||
* library reference is decided by the shared `libraryRefName` (core/spec-data):
|
||||
* named data the spec does not define for itself. A `name` riding on inline
|
||||
* `values`, a `url`, or a generator is native Vega-Lite data and is left untouched;
|
||||
* an unknown library name throws `DatasetNotFoundError`. Matching is
|
||||
* case-insensitive, mirroring naming.ts. Mutates in place; the caller already works
|
||||
* on a copy.
|
||||
*/
|
||||
function resolveDatasetRefs(
|
||||
node: unknown,
|
||||
@@ -176,15 +172,12 @@ function resolveDatasetRefs(
|
||||
}
|
||||
if (!isSpecNode(node)) return;
|
||||
|
||||
const data = node.data;
|
||||
if (isSpecNode(data) && typeof data.name === 'string') {
|
||||
const name = data.name;
|
||||
if (!selfDefined.has(name)) {
|
||||
const dataset = byName.get(name.toLowerCase());
|
||||
if (!dataset) throw new DatasetNotFoundError(name);
|
||||
const { name: _drop, ...rest } = data;
|
||||
node.data = resolvedData(dataset, rest);
|
||||
}
|
||||
const refName = libraryRefName(node.data, selfDefined);
|
||||
if (refName !== null) {
|
||||
const dataset = byName.get(refName.toLowerCase());
|
||||
if (!dataset) throw new DatasetNotFoundError(refName);
|
||||
const { name: _drop, ...rest } = node.data as SpecNode;
|
||||
node.data = resolvedData(dataset, rest);
|
||||
}
|
||||
|
||||
// Recurse into every key except the two that hold data payloads (`data` —
|
||||
@@ -229,7 +222,7 @@ export function prepareSpecForRender<T>(spec: T, options: PrepareOptions = {}):
|
||||
const datasets = options.datasets ?? [];
|
||||
const byName = new Map<string, ResolvableDataset>();
|
||||
for (const d of datasets) byName.set(d.name.toLowerCase(), d);
|
||||
resolveDatasetRefs(copy, byName, selfDefinedDatasetNames(copy));
|
||||
resolveDatasetRefs(copy, byName, selfDefinedNames(copy));
|
||||
|
||||
// 2. Fit-mode sizing.
|
||||
applyFitMode(copy, options.fitMode ?? 'default');
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { pickResultDataset, pickSourceDataset } from './result-data';
|
||||
|
||||
describe('pickResultDataset', () => {
|
||||
it('prefers the most-downstream transform output over the source', () => {
|
||||
expect(pickResultDataset(['source_0', 'data_0', 'marks', 'root'])).toBe('data_0');
|
||||
});
|
||||
|
||||
it('picks the highest data_<n> numerically, not lexically', () => {
|
||||
expect(pickResultDataset(['source_0', 'data_0', 'data_1', 'data_2'])).toBe('data_2');
|
||||
// 10 must beat 2 — guards against string ordering ('data_10' < 'data_2').
|
||||
expect(pickResultDataset(['data_2', 'data_10', 'data_9'])).toBe('data_10');
|
||||
});
|
||||
|
||||
it('falls back to the most-downstream source when there is no data_<n>', () => {
|
||||
expect(pickResultDataset(['source_0', 'source_1', 'marks'])).toBe('source_1');
|
||||
});
|
||||
|
||||
it('ignores dataflow internals when choosing', () => {
|
||||
expect(pickResultDataset(['root', 'marks', 'layout', 'cell', 'source_0'])).toBe('source_0');
|
||||
expect(pickResultDataset(['brush_store', '_facet', 'a:b', 'data_0'])).toBe('data_0');
|
||||
});
|
||||
|
||||
it('returns a remaining non-internal table when no source/data convention matches', () => {
|
||||
expect(pickResultDataset(['root', 'marks', 'my_named_source'])).toBe('my_named_source');
|
||||
});
|
||||
|
||||
it('returns null when nothing is inspectable', () => {
|
||||
expect(pickResultDataset([])).toBeNull();
|
||||
expect(pickResultDataset(['root', 'marks', 'layout', 'brush_store'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickSourceDataset', () => {
|
||||
it('prefers the most-upstream source — the input before transforms', () => {
|
||||
expect(pickSourceDataset(['source_0', 'source_1', 'data_0', 'data_1'])).toBe('source_0');
|
||||
});
|
||||
|
||||
it('picks the lowest source_<n> numerically', () => {
|
||||
expect(pickSourceDataset(['source_2', 'source_10', 'source_1'])).toBe('source_1');
|
||||
});
|
||||
|
||||
it('falls back to the earliest transform stage when there is no source_<n>', () => {
|
||||
expect(pickSourceDataset(['data_0', 'data_1', 'marks'])).toBe('data_0');
|
||||
});
|
||||
|
||||
it('equals the result when a spec has no transforms (only a source)', () => {
|
||||
const names = ['source_0', 'marks', 'root'];
|
||||
expect(pickSourceDataset(names)).toBe('source_0');
|
||||
expect(pickResultDataset(names)).toBe('source_0');
|
||||
});
|
||||
|
||||
it('returns null when nothing is inspectable', () => {
|
||||
expect(pickSourceDataset(['root', 'marks', 'brush_store'])).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,95 +0,0 @@
|
||||
/**
|
||||
* Picking the input and resolved datasets from a rendered Vega view (the data
|
||||
* inspector — spec §04; arch 05 → "the data inspector rides the boundary").
|
||||
*
|
||||
* A compiled Vega dataflow holds many named datasets. Vega-Lite names the ones a
|
||||
* spec produces by convention: `source_<n>` for each parsed data source, and
|
||||
* `data_<n>` for each transform/aggregation stage. So a single view carries both
|
||||
* ends of the pipeline:
|
||||
*
|
||||
* - the **input** is the most-upstream source (`source_0`) — the parsed rows
|
||||
* before the spec's transforms;
|
||||
* - the **resolved** output is the most-downstream `data_<n>` — the rows the marks
|
||||
* of a single-view chart actually draw.
|
||||
*
|
||||
* Everything else in the dataflow (`root`, `marks`, layout/scale tables, selection
|
||||
* `*_store`s, `_`-prefixed internals, faceted `a:b` names) is plumbing the user
|
||||
* never authored. A spec with no transforms has only a source, so input and
|
||||
* resolved resolve to the same table — correct: no transforms means output = input.
|
||||
*
|
||||
* This is the pure half of the inspector — given the view's dataset names, choose
|
||||
* one per direction. The view access itself (listing names, reading rows) lives
|
||||
* behind the renderer's `RenderHandle` boundary (services/chart-renderer; arch 05).
|
||||
*
|
||||
* Limitation: a single name can't represent a multi-view spec (layer/concat/facet
|
||||
* produce several `source_<n>` / `data_<n>`, one per view). We return the
|
||||
* most-upstream source and most-downstream output — both real, drawn tables — and
|
||||
* leave a full dataset selector as a future option.
|
||||
*/
|
||||
|
||||
/** Datasets that are dataflow plumbing, never a table the user would inspect. */
|
||||
function isInternalDataset(name: string): boolean {
|
||||
return (
|
||||
name === 'root' ||
|
||||
name === 'marks' ||
|
||||
name === 'layout' ||
|
||||
name === 'cell' ||
|
||||
name.startsWith('_') ||
|
||||
name.endsWith('_store') || // selection tuple stores
|
||||
name.includes(':') // faceted / cross-context child sources
|
||||
);
|
||||
}
|
||||
|
||||
/** The trailing index of a `prefix_<n>` name (e.g. `data_12` → 12), or null. */
|
||||
function suffixIndex(name: string, prefix: string): number | null {
|
||||
if (!name.startsWith(`${prefix}_`)) return null;
|
||||
const n = Number(name.slice(prefix.length + 1));
|
||||
return Number.isInteger(n) ? n : null;
|
||||
}
|
||||
|
||||
/** The `prefix_<n>` name with the highest (`'max'`) or lowest (`'min'`) suffix. */
|
||||
function bySuffix(names: readonly string[], prefix: string, end: 'min' | 'max'): string | null {
|
||||
let best: string | null = null;
|
||||
let bestN = end === 'max' ? -Infinity : Infinity;
|
||||
for (const name of names) {
|
||||
const n = suffixIndex(name, prefix);
|
||||
if (n === null) continue;
|
||||
if (end === 'max' ? n > bestN : n < bestN) {
|
||||
bestN = n;
|
||||
best = name;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the compiled dataset representing the rows the chart draws, given all of
|
||||
* the view's dataset names. Prefers the most-downstream transform output
|
||||
* (`data_<max n>`), then the most-downstream source (`source_<max n>`), then any
|
||||
* remaining non-internal table. Returns null when nothing is inspectable.
|
||||
*/
|
||||
export function pickResultDataset(names: readonly string[]): string | null {
|
||||
const candidates = names.filter((n) => !isInternalDataset(n));
|
||||
return (
|
||||
bySuffix(candidates, 'data', 'max') ??
|
||||
bySuffix(candidates, 'source', 'max') ??
|
||||
candidates[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose the compiled dataset representing the chart's input — the parsed rows
|
||||
* before the spec's transforms. Prefers the most-upstream source (`source_<min n>`),
|
||||
* then the earliest transform stage (`data_<min n>`) for the rare source-less spec,
|
||||
* then any remaining non-internal table. Returns null when nothing is inspectable.
|
||||
*/
|
||||
export function pickSourceDataset(names: readonly string[]): string | null {
|
||||
const candidates = names.filter((n) => !isInternalDataset(n));
|
||||
return (
|
||||
bySuffix(candidates, 'source', 'min') ??
|
||||
bySuffix(candidates, 'data', 'min') ??
|
||||
candidates[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { schemeColors } from './scheme-colors';
|
||||
|
||||
describe('schemeColors', () => {
|
||||
it('returns the full fixed palette for a categorical scheme (count ignored)', () => {
|
||||
const colors = schemeColors('tableau10', 3);
|
||||
expect(colors).toHaveLength(10);
|
||||
expect(colors.every((c) => /^#[0-9a-f]{6}$/i.test(c))).toBe(true);
|
||||
});
|
||||
|
||||
it('samples a continuous scheme at count stops, normalized to hex', () => {
|
||||
const colors = schemeColors('viridis', 5);
|
||||
expect(colors).toHaveLength(5);
|
||||
expect(colors.every((c) => /^#[0-9a-f]{6}$/.test(c))).toBe(true);
|
||||
});
|
||||
|
||||
it('samples the midpoint for a single continuous stop', () => {
|
||||
expect(schemeColors('viridis', 1)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns [] for an unknown scheme', () => {
|
||||
expect(schemeColors('not-a-scheme')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Named-scheme resolution to hex swatches (docs/exploration/chart-theming-scope.md §5).
|
||||
*
|
||||
* Split from theme-controls.ts because of the `vega-scale` import below:
|
||||
* theme-controls is reachable from the landing/learn entry graphs (via
|
||||
* `vega-themes`), and a static vega-scale edge there drags the vega vendor
|
||||
* chunk into the marketing pages' eager JS (`scripts/check-light-entries.mjs`
|
||||
* guards this after every build). Only the app's color controls resolve
|
||||
* schemes to swatches, so the edge lives here, app-side of the split.
|
||||
*/
|
||||
|
||||
import { scheme } from 'vega-scale';
|
||||
|
||||
/**
|
||||
* Resolve a named Vega scheme to hex swatches. A categorical scheme returns its
|
||||
* fixed palette in full; a continuous scheme (sequential/diverging) is sampled
|
||||
* at `count` evenly-spaced stops (`count` applies to continuous schemes only).
|
||||
* Continuous interpolators yield `rgb(...)`, normalized to hex here. An unknown
|
||||
* name returns `[]` — the picker shows that scheme without a preview rather than
|
||||
* throwing. Used for the swatch/gradient preview and "materialize to swatches".
|
||||
*/
|
||||
export function schemeColors(name: string, count = 9): string[] {
|
||||
const resolved: unknown = scheme(name);
|
||||
if (Array.isArray(resolved)) return resolved.map((c) => toHex(String(c)));
|
||||
if (typeof resolved === 'function' && count > 0) {
|
||||
const interp = resolved as (t: number) => string;
|
||||
if (count === 1) return [toHex(interp(0.5))];
|
||||
return Array.from({ length: count }, (_, i) => toHex(interp(i / (count - 1))));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Normalize a CSS color to `#rrggbb`; passes through existing hex and unknowns. */
|
||||
function toHex(color: string): string {
|
||||
if (color.startsWith('#')) return color;
|
||||
const m = /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i.exec(color);
|
||||
if (!m) return color;
|
||||
const h = (n: string) => Math.round(Number(n)).toString(16).padStart(2, '0');
|
||||
return `#${h(m[1])}${h(m[2])}${h(m[3])}`;
|
||||
}
|
||||
@@ -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,105 @@
|
||||
/**
|
||||
* 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 JSON path (object keys and array indices, root → cursor) of the node at
|
||||
* `offset`. Drives the view-scoped data context: the path is walked to find the
|
||||
* nearest enclosing view's data binding and ancestor transforms
|
||||
* (`core/spec-data`, `core/spec-fields`). Error-tolerant, so it works mid-edit.
|
||||
*/
|
||||
export function pathAtOffset(text: string, offset: number): (string | number)[] {
|
||||
return [...getLocation(text, offset).path];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,185 @@
|
||||
import Ajv, { type ValidateFunction } from 'ajv';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import vegaLiteSchema from 'vega-lite/vega-lite-schema.json';
|
||||
import {
|
||||
DATA_TRANSFORMS,
|
||||
transformPlacementAt,
|
||||
transformSiteAt,
|
||||
type TypedField,
|
||||
} from './spec-data-transforms';
|
||||
import { snippetToPlain } from './spec-snippet';
|
||||
|
||||
/** Compile the bundled Vega-Lite schema once so every step can be validated for real. */
|
||||
const validateSpec: ValidateFunction = new Ajv({
|
||||
strict: false,
|
||||
validateFormats: false,
|
||||
}).compile(vegaLiteSchema as object);
|
||||
|
||||
/** Place a `|` marker in the source, strip it, and classify at that offset. */
|
||||
function placeAt(marked: string) {
|
||||
const offset = marked.indexOf('|');
|
||||
return transformPlacementAt(marked.replace('|', ''), offset);
|
||||
}
|
||||
|
||||
/** Place a `|` marker in the source, strip it, and resolve the transform site. */
|
||||
function siteAt(marked: string) {
|
||||
const offset = marked.indexOf('|');
|
||||
return transformSiteAt(marked.replace('|', ''), offset);
|
||||
}
|
||||
|
||||
const FIELDS: TypedField[] = [
|
||||
{ name: 'price', type: 'number' },
|
||||
{ name: 'date', type: 'date' },
|
||||
{ name: 'origin', type: 'string' },
|
||||
];
|
||||
|
||||
describe('transformPlacementAt — step slots', () => {
|
||||
it('classifies an empty transform array as a step slot', () => {
|
||||
expect(placeAt('{"transform":[|],"mark":"bar"}')).toEqual({ kind: 'step', scope: 'view' });
|
||||
});
|
||||
|
||||
it('classifies the slot after an existing step', () => {
|
||||
expect(placeAt('{"transform":[{"filter":"x"},|],"mark":"bar"}')).toEqual({
|
||||
kind: 'step',
|
||||
scope: 'view',
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies a bare partial word in the array as a step slot', () => {
|
||||
// `fil` is not valid JSON, but jsonc-parser locates the slot regardless.
|
||||
expect(placeAt('{"transform":[fil|],"mark":"bar"}')).toEqual({ kind: 'step', scope: 'view' });
|
||||
});
|
||||
|
||||
it('marks a transform on a composition parent as shared (upstream of children)', () => {
|
||||
const src = '{"transform":[|],"layer":[{"mark":"bar"},{"mark":"line"}]}';
|
||||
expect(placeAt(src)).toEqual({ kind: 'step', scope: 'shared' });
|
||||
});
|
||||
|
||||
it('resolves the owning view of a nested transform (layer child = local)', () => {
|
||||
const src = '{"layer":[{"transform":[|],"mark":"bar"},{"mark":"line"}]}';
|
||||
expect(placeAt(src)).toEqual({ kind: 'step', scope: 'view' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformPlacementAt — not a step slot', () => {
|
||||
it('stays out of a key inside an existing step object (the schema owns that)', () => {
|
||||
expect(placeAt('{"transform":[{"fil|"}],"mark":"bar"}')).toEqual({ kind: 'none' });
|
||||
});
|
||||
|
||||
it('does not fire at an encoding channel key (the schema completes those)', () => {
|
||||
expect(placeAt('{"encoding":{"x":{"field":"date","ti|"}}}')).toEqual({ kind: 'none' });
|
||||
});
|
||||
|
||||
it('does not fire at a bare view key', () => {
|
||||
expect(placeAt('{"mark":"bar","tr|"}')).toEqual({ kind: 'none' });
|
||||
});
|
||||
|
||||
it('returns none for a plain value position', () => {
|
||||
expect(placeAt('{"mark":"|"}')).toEqual({ kind: 'none' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformSiteAt', () => {
|
||||
it('reports no array on a bare unit view (cursor anywhere inside it)', () => {
|
||||
const site = siteAt('{"mark":"bar","encoding":{"x":{"field":"a|"}}}');
|
||||
expect(site).toMatchObject({ scope: 'view', array: null });
|
||||
expect(site?.view.offset).toBe(0);
|
||||
});
|
||||
|
||||
it('reports the array and its element count when a pipeline exists', () => {
|
||||
const site = siteAt('{"transform":[{"filter":"x"}],"mark":"|bar"}');
|
||||
expect(site?.array).toMatchObject({ count: 1 });
|
||||
});
|
||||
|
||||
it('reports an empty array with count 0', () => {
|
||||
const site = siteAt('{"transform":[|],"mark":"bar"}');
|
||||
expect(site?.array).toMatchObject({ count: 0 });
|
||||
});
|
||||
|
||||
it('resolves the site from inside the transform array itself', () => {
|
||||
const site = siteAt('{"transform":[{"filter":"x"},|],"mark":"bar"}');
|
||||
expect(site?.array).toMatchObject({ count: 1 });
|
||||
expect(site?.view.offset).toBe(0);
|
||||
});
|
||||
|
||||
it('climbs to the nearest view — a layer child is local (view scope)', () => {
|
||||
const site = siteAt('{"layer":[{"mark":"ba|r"},{"mark":"line"}]}');
|
||||
expect(site).toMatchObject({ scope: 'view', array: null });
|
||||
expect(site?.view.offset).toBeGreaterThan(0); // the child, not the root
|
||||
});
|
||||
|
||||
it('marks a composition parent’s pipeline as shared', () => {
|
||||
const site = siteAt('{"transform":[|],"layer":[{"mark":"bar"}]}');
|
||||
expect(site).toMatchObject({ scope: 'shared' });
|
||||
});
|
||||
|
||||
it('the view range spans the whole enclosing view object', () => {
|
||||
const src = '{"mark":"bar","encodi|ng":{}}';
|
||||
const site = siteAt(src);
|
||||
// offset at the opening brace, length to the closing brace inclusive.
|
||||
expect(site?.view.offset).toBe(0);
|
||||
expect(site?.view.length).toBe(src.replace('|', '').length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog — every step snippet, seeded, is valid Vega-Lite', () => {
|
||||
/** A unit spec carrying the stripped step, for real-schema validation. */
|
||||
const withStep = (snippet: string) => ({
|
||||
data: { values: [{ price: 1, origin: 'A' }] },
|
||||
mark: 'point',
|
||||
encoding: {},
|
||||
transform: [JSON.parse(snippetToPlain(snippet)) as unknown],
|
||||
});
|
||||
for (const t of DATA_TRANSFORMS) {
|
||||
it(`${t.id} (with fields in scope)`, () => {
|
||||
const ok = validateSpec(withStep(t.step(FIELDS)));
|
||||
expect(ok, JSON.stringify(validateSpec.errors?.[0])).toBe(true);
|
||||
});
|
||||
it(`${t.id} (no data in scope — generic fallbacks)`, () => {
|
||||
const ok = validateSpec(withStep(t.step([])));
|
||||
expect(ok, JSON.stringify(validateSpec.errors?.[0])).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('catalog — field-typed defaults', () => {
|
||||
it('seeds filter with the first available column', () => {
|
||||
expect(JSON.parse(snippetToPlain(byId('filter').step(FIELDS)))).toEqual({
|
||||
filter: 'datum.price > 0',
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds aggregate with a numeric field and a categorical groupby', () => {
|
||||
const parsed = parseAggregate(byId('aggregate').step(FIELDS));
|
||||
expect(parsed.aggregate[0]).toMatchObject({ field: 'price', as: 'price_mean' });
|
||||
expect(parsed.groupby).toEqual(['origin']);
|
||||
});
|
||||
|
||||
it('seeds timeUnit with a temporal field', () => {
|
||||
const parsed = JSON.parse(snippetToPlain(byId('timeUnit').step(FIELDS))) as { field: string };
|
||||
expect(parsed.field).toBe('date');
|
||||
});
|
||||
|
||||
it('falls back to generic names when no column matches the need', () => {
|
||||
const parsed = parseAggregate(byId('aggregate').step([]));
|
||||
expect(parsed.aggregate[0].field).toBe('value');
|
||||
expect(parsed.groupby).toEqual(['category']);
|
||||
});
|
||||
});
|
||||
|
||||
function byId(id: string) {
|
||||
const t = DATA_TRANSFORMS.find((x) => x.id === id);
|
||||
if (!t) throw new Error(`no transform ${id}`);
|
||||
return t;
|
||||
}
|
||||
|
||||
/** Parse an aggregate step snippet into its (typed) fields, for assertions. */
|
||||
function parseAggregate(snippet: string): {
|
||||
aggregate: Array<{ op: string; field: string; as: string }>;
|
||||
groupby: string[];
|
||||
} {
|
||||
return JSON.parse(snippetToPlain(snippet)) as {
|
||||
aggregate: Array<{ op: string; field: string; as: string }>;
|
||||
groupby: string[];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* Data-transform scaffolding (docs/architecture/08 → editor augmentation) — the
|
||||
* pure counterpart of `spec-transforms`. Where `spec-transforms` rewrites a view's
|
||||
* *structure* (wrap in a composition), this scaffolds a view's *data pipeline*: the
|
||||
* Vega-Lite `transform` steps (filter, calculate, aggregate, …) the editor offers as
|
||||
* ready-to-fill, field-typed placeholders. Text/Monaco handling (ranges, insertion,
|
||||
* the CodeLens) is the service's job (app/services/spec-transform-scaffold); this
|
||||
* owns the facts the schema can't supply — *where* a view's pipeline lives
|
||||
* (`transformSiteAt`, for the lens) or a step slot sits (`transformPlacementAt`, for
|
||||
* the completion), and *what* a field-typed skeleton for each transform looks like.
|
||||
*
|
||||
* The **discoverable surface is a CodeLens** — `+ Add transform` on a view with no
|
||||
* pipeline, and `+ filter`/`+ aggregate`/… on the array once it exists — mirroring
|
||||
* the composition CodeLens (`spec-transform-actions`). The step-slot completion is a
|
||||
* type-to-filter accelerator on the same catalog, not the primary way in.
|
||||
*
|
||||
* **Where a data transform can legally go (grammar-confirmed against the bundled VL
|
||||
* schema — the `transform` array appears on 16 spec types, which reduce to one idea:
|
||||
* every view node).** Three contexts exist; we scaffold only the one the schema
|
||||
* leaves bare, on the same "add only what the schema lacks" rule as `spec-dataset-hints`:
|
||||
*
|
||||
* - **step (owned here)** — an element of a view's `transform: [ … ]` array. The
|
||||
* array sits on *every* view node (unit, layer, facet, concat, repeat, and the
|
||||
* root) — the node set `spec-cursor`/`spec-data` already call a "view" — so it
|
||||
* inherits top-down: a step on a composition parent is **shared** (upstream of
|
||||
* all its children); a step on a unit is **local**. The schema never offers a
|
||||
* ready `{ "filter": … }` with your columns, so this is where we add value.
|
||||
* - **inline (deferred to the schema)** — `bin`/`timeUnit`/`aggregate`/`sort` are
|
||||
* also properties on an encoding channel's field def. The JSON schema already
|
||||
* completes those keys *and* their enum values, so scaffolding them here would be
|
||||
* a redundant second source.
|
||||
* - **new pipeline (deferred — composition of two that already work)** — starting a
|
||||
* `transform` on a bare view is the schema completing the `transform` key (→ empty
|
||||
* array) followed by the step completion firing inside it. No separate surface needed.
|
||||
*
|
||||
* `transformPlacementAt` classifies the cursor from `jsonc-parser`'s error-tolerant
|
||||
* tree, so it holds mid-edit. The catalog builders take the columns in scope (resolved
|
||||
* by the service via `active-dataset`, exactly as the facet/repeat wraps do) and seed
|
||||
* each placeholder with a type-appropriate default.
|
||||
*/
|
||||
|
||||
import {
|
||||
findNodeAtLocation,
|
||||
findNodeAtOffset,
|
||||
getLocation,
|
||||
parseTree,
|
||||
type Node,
|
||||
} from 'jsonc-parser';
|
||||
import { ARRAY_COMPOSITIONS } from './spec-transforms';
|
||||
import type { ColumnType } from './type-inference';
|
||||
|
||||
/** A column in scope at the cursor, with its inferred data type. */
|
||||
export interface TypedField {
|
||||
name: string;
|
||||
type: ColumnType;
|
||||
}
|
||||
|
||||
/** A `transform` step belongs to a composition parent (shared) or a unit (local). */
|
||||
type TransformScope = 'shared' | 'view';
|
||||
|
||||
/**
|
||||
* Where the cursor sits relative to a data transform:
|
||||
* - `step` — in a `transform: [ … ]` element slot (offer the field-typed catalog),
|
||||
* tagged with whether that array is a composition parent's (shared) or a unit's;
|
||||
* - `none` — anywhere else (including inside an existing step object, where the
|
||||
* schema owns key completion).
|
||||
*/
|
||||
export type TransformPlacement = { kind: 'step'; scope: TransformScope } | { kind: 'none' };
|
||||
|
||||
/** A byte range into the spec text. */
|
||||
interface ByteRange {
|
||||
offset: number;
|
||||
length: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The data-transform site for the view the cursor is in — what the CodeLens needs to
|
||||
* anchor and act. `view` is the enclosing view object's range (where an `+ Add
|
||||
* transform` lens sits when there is no pipeline yet); `array` is the `transform[]`
|
||||
* range and element count when one exists (where the per-step `+ filter` … lenses
|
||||
* sit, and where a new step is spliced in). `scope` marks a composition parent's
|
||||
* pipeline as shared.
|
||||
*/
|
||||
export interface TransformSite {
|
||||
scope: TransformScope;
|
||||
view: ByteRange;
|
||||
array: (ByteRange & { count: number }) | null;
|
||||
}
|
||||
|
||||
/** Composition keys whose presence on a view makes its transform *shared* upstream —
|
||||
* the array operators (shared with `spec-cursor`/`spec-insert`) plus the single-child
|
||||
* `facet`/`repeat`, so this stays one spelling of the operator set. */
|
||||
const COMPOSITION_KEYS = [...ARRAY_COMPOSITIONS, 'facet', 'repeat'];
|
||||
|
||||
/** The object node at a jsonc location path, or null if the path isn't an object. */
|
||||
function objectNodeAt(tree: Node | undefined, path: ReadonlyArray<string | number>): Node | null {
|
||||
if (!tree) return null;
|
||||
const node = findNodeAtLocation(tree, path as (string | number)[]);
|
||||
return node && node.type === 'object' ? node : null;
|
||||
}
|
||||
|
||||
/** The property keys of an object node. */
|
||||
function objectKeys(node: Node | null): string[] {
|
||||
if (!node?.children) return [];
|
||||
const keys: string[] = [];
|
||||
for (const prop of node.children) {
|
||||
const key = prop.type === 'property' ? prop.children?.[0] : undefined;
|
||||
if (key?.type === 'string' && typeof key.value === 'string') keys.push(key.value);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** A view carrying a composition key feeds all its children, so its transform is shared. */
|
||||
function scopeFromKeys(keys: readonly string[]): TransformScope {
|
||||
return keys.some((k) => COMPOSITION_KEYS.includes(k)) ? 'shared' : 'view';
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the cursor's data-transform context. Read from `jsonc-parser`'s
|
||||
* error-tolerant tree so it holds mid-edit — a step slot is a `transform` array
|
||||
* element position (`[ …, "transform", <index> ]`) that is *not* a property key,
|
||||
* which excludes the cursor being inside a step object editing its own keys (the
|
||||
* schema owns that). `scope` reads the owning view's keys, defaulting to `view` when
|
||||
* the tree can't resolve them.
|
||||
*/
|
||||
export function transformPlacementAt(text: string, offset: number): TransformPlacement {
|
||||
const { path, isAtPropertyKey } = getLocation(text, offset);
|
||||
if (
|
||||
isAtPropertyKey ||
|
||||
path.length < 2 ||
|
||||
path[path.length - 2] !== 'transform' ||
|
||||
typeof path[path.length - 1] !== 'number'
|
||||
) {
|
||||
return { kind: 'none' };
|
||||
}
|
||||
const owner = objectNodeAt(parseTree(text), path.slice(0, -2));
|
||||
return { kind: 'step', scope: scopeFromKeys(objectKeys(owner)) };
|
||||
}
|
||||
|
||||
/** Is this object node a view a `transform` can attach to — the root, a composition, or a unit? */
|
||||
function isViewNode(node: Node): boolean {
|
||||
if (!node.parent) return true; // the root spec
|
||||
const keys = objectKeys(node);
|
||||
if (keys.some((k) => COMPOSITION_KEYS.includes(k))) return true;
|
||||
return keys.includes('mark') || keys.includes('encoding');
|
||||
}
|
||||
|
||||
/** The `transform` array node of a view object, or null when it has none. */
|
||||
function transformArrayNode(view: Node): Node | null {
|
||||
for (const prop of view.children ?? []) {
|
||||
if (prop.type === 'property' && prop.children?.[0]?.value === 'transform') {
|
||||
const value = prop.children[1];
|
||||
return value?.type === 'array' ? value : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The transform site for the nearest view enclosing `offset` — climbed from the
|
||||
* error-tolerant tree (so it holds mid-edit), or null when the cursor is in no view.
|
||||
* The CodeLens reads this to place its lenses and to splice a new step; the byte
|
||||
* ranges are turned into Monaco positions by the service.
|
||||
*/
|
||||
export function transformSiteAt(text: string, offset: number): TransformSite | null {
|
||||
const tree = parseTree(text);
|
||||
if (!tree) return null;
|
||||
let node: Node | undefined = findNodeAtOffset(tree, offset, true);
|
||||
while (node) {
|
||||
if (node.type === 'object' && isViewNode(node)) break;
|
||||
node = node.parent;
|
||||
}
|
||||
if (!node) return null;
|
||||
const array = transformArrayNode(node);
|
||||
return {
|
||||
scope: scopeFromKeys(objectKeys(node)),
|
||||
view: { offset: node.offset, length: node.length },
|
||||
array: array
|
||||
? { offset: array.offset, length: array.length, count: array.children?.length ?? 0 }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Field-typed default picking ────────────────────────────────────────────────
|
||||
|
||||
/** What kind of column a placeholder wants, so its default is type-appropriate. */
|
||||
type FieldNeed = 'any' | 'numeric' | 'temporal' | 'categorical';
|
||||
|
||||
/** The first column matching a need, or null when none is in scope. */
|
||||
function pick(fields: readonly TypedField[], need: FieldNeed): string | null {
|
||||
switch (need) {
|
||||
case 'numeric':
|
||||
return fields.find((f) => f.type === 'number')?.name ?? null;
|
||||
case 'temporal':
|
||||
return fields.find((f) => f.type === 'date')?.name ?? null;
|
||||
case 'categorical':
|
||||
return fields.find((f) => f.type === 'string' || f.type === 'boolean')?.name ?? null;
|
||||
case 'any':
|
||||
return fields[0]?.name ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
/** A tab-stop's default text: the resolved column when one is in scope, else a name. */
|
||||
function def(fields: readonly TypedField[], need: FieldNeed, fallback: string): string {
|
||||
return pick(fields, need) ?? fallback;
|
||||
}
|
||||
|
||||
// ─── The transform catalog ──────────────────────────────────────────────────────
|
||||
|
||||
/** One data transform the editor can scaffold as a `transform[]` step. */
|
||||
export interface DataTransform {
|
||||
id: string;
|
||||
/** Completion label. */
|
||||
label: string;
|
||||
/** One-line description (completion detail). */
|
||||
detail: string;
|
||||
/** The step snippet: compact JSON with `${n:default}` tab stops. */
|
||||
step(fields: readonly TypedField[]): string;
|
||||
}
|
||||
|
||||
/** The scaffoldable transforms, in rough order of how often they're reached for. */
|
||||
export const DATA_TRANSFORMS: readonly DataTransform[] = [
|
||||
{
|
||||
id: 'filter',
|
||||
label: 'filter',
|
||||
detail: 'Keep only rows matching a predicate',
|
||||
step: (f) => `{ "filter": "datum.\${1:${def(f, 'any', 'field')}} > \${2:0}" }`,
|
||||
},
|
||||
{
|
||||
id: 'calculate',
|
||||
label: 'calculate',
|
||||
detail: 'Derive a new field from an expression',
|
||||
step: (f) => `{ "calculate": "datum.\${1:${def(f, 'any', 'field')}}", "as": "\${2:newField}" }`,
|
||||
},
|
||||
{
|
||||
id: 'aggregate',
|
||||
label: 'aggregate',
|
||||
detail: 'Group rows and summarize (mean, sum, count…)',
|
||||
step: (f) =>
|
||||
`{ "aggregate": [{ "op": "\${1:mean}", "field": "\${2:${def(f, 'numeric', 'value')}}", "as": "\${3:${def(f, 'numeric', 'value')}_mean}" }], "groupby": ["\${4:${def(f, 'categorical', 'category')}}"] }`,
|
||||
},
|
||||
{
|
||||
id: 'bin',
|
||||
label: 'bin',
|
||||
detail: 'Bucket a quantitative field into bins',
|
||||
step: (f) =>
|
||||
`{ "bin": \${1:true}, "field": "\${2:${def(f, 'numeric', 'value')}}", "as": "\${3:${def(f, 'numeric', 'value')}_binned}" }`,
|
||||
},
|
||||
{
|
||||
id: 'timeUnit',
|
||||
label: 'timeUnit',
|
||||
detail: 'Derive a calendar unit (year, month…) from a date',
|
||||
step: (f) =>
|
||||
`{ "timeUnit": "\${1:yearmonth}", "field": "\${2:${def(f, 'temporal', 'date')}}", "as": "\${3:${def(f, 'temporal', 'date')}_unit}" }`,
|
||||
},
|
||||
{
|
||||
id: 'window',
|
||||
label: 'window',
|
||||
detail: 'Running totals, ranks, row numbers over a sort',
|
||||
step: (f) =>
|
||||
`{ "window": [{ "op": "\${1:row_number}", "as": "\${2:rank}" }], "sort": [{ "field": "\${3:${def(f, 'any', 'field')}}", "order": "descending" }] }`,
|
||||
},
|
||||
{
|
||||
id: 'joinaggregate',
|
||||
label: 'joinaggregate',
|
||||
detail: 'Group aggregate that keeps every row (share of total)',
|
||||
step: (f) =>
|
||||
`{ "joinaggregate": [{ "op": "\${1:sum}", "field": "\${2:${def(f, 'numeric', 'value')}}", "as": "\${3:total}" }], "groupby": ["\${4:${def(f, 'categorical', 'category')}}"] }`,
|
||||
},
|
||||
{
|
||||
id: 'fold',
|
||||
label: 'fold',
|
||||
detail: 'Reshape wide columns into key/value rows',
|
||||
step: (f) =>
|
||||
`{ "fold": ["\${1:${f[0]?.name ?? 'field1'}}", "\${2:${f[1]?.name ?? 'field2'}}"], "as": ["\${3:key}", "\${4:value}"] }`,
|
||||
},
|
||||
{
|
||||
id: 'lookup',
|
||||
label: 'lookup',
|
||||
detail: 'Join fields from another dataset by a key',
|
||||
step: () =>
|
||||
`{ "lookup": "\${1:key}", "from": { "data": { "name": "\${2:dataset}" }, "key": "\${3:key}", "fields": ["\${4:field}"] } }`,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import {
|
||||
classifyData,
|
||||
dataBindingAtPath,
|
||||
forEachDataBinding,
|
||||
libraryRefName,
|
||||
mapDataBindings,
|
||||
selfDefinedNames,
|
||||
setDataBindingAtPath,
|
||||
} from './spec-data';
|
||||
|
||||
describe('classifyData', () => {
|
||||
test('classifies the four Vega-Lite data shapes', () => {
|
||||
expect(classifyData({ url: 'x.csv' })).toBe('url');
|
||||
expect(classifyData({ values: [{ a: 1 }] })).toBe('inline');
|
||||
expect(classifyData({ sequence: { start: 0, stop: 5 } })).toBe('generator');
|
||||
expect(classifyData({ sphere: true })).toBe('generator');
|
||||
expect(classifyData({ graticule: true })).toBe('generator');
|
||||
expect(classifyData({ name: 'Sales' })).toBe('named');
|
||||
});
|
||||
|
||||
test('url / values / generator win over a bare name (Vega-Lite precedence)', () => {
|
||||
// A name on these shapes is a runtime-rebind label, not a named reference.
|
||||
expect(classifyData({ name: 'pts', values: [{ a: 1 }] })).toBe('inline');
|
||||
expect(classifyData({ name: 'remote', url: 'x.csv' })).toBe('url');
|
||||
expect(classifyData({ name: 'seq', sequence: { start: 0, stop: 5 } })).toBe('generator');
|
||||
});
|
||||
|
||||
test('returns null for non-data values', () => {
|
||||
expect(classifyData(null)).toBeNull();
|
||||
expect(classifyData(undefined)).toBeNull();
|
||||
expect(classifyData('string')).toBeNull();
|
||||
expect(classifyData([])).toBeNull();
|
||||
expect(classifyData({})).toBeNull();
|
||||
expect(classifyData({ name: 123 })).toBeNull(); // a non-string name is not named data
|
||||
});
|
||||
});
|
||||
|
||||
describe('selfDefinedNames', () => {
|
||||
test('collects top-level datasets keys', () => {
|
||||
expect([...selfDefinedNames({ datasets: { a: [], b: [] } })].sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
test('is empty when there is no datasets map', () => {
|
||||
expect(selfDefinedNames({ data: { name: 'x' } }).size).toBe(0);
|
||||
expect(selfDefinedNames(null).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('libraryRefName', () => {
|
||||
const none = new Set<string>();
|
||||
|
||||
test('returns the name of a true named reference', () => {
|
||||
expect(libraryRefName({ name: 'Sales' }, none)).toBe('Sales');
|
||||
});
|
||||
|
||||
test('returns null for inline/url/generator data carrying a name', () => {
|
||||
expect(libraryRefName({ name: 'pts', values: [{ a: 1 }] }, none)).toBeNull();
|
||||
expect(libraryRefName({ name: 'remote', url: 'x.csv' }, none)).toBeNull();
|
||||
expect(libraryRefName({ name: 'seq', sequence: { start: 0, stop: 5 } }, none)).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for a self-defined name', () => {
|
||||
expect(libraryRefName({ name: 'local' }, new Set(['local']))).toBeNull();
|
||||
});
|
||||
|
||||
test('returns null for a non-data value', () => {
|
||||
expect(libraryRefName(null, none)).toBeNull();
|
||||
expect(libraryRefName({ values: [{ a: 1 }] }, none)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dataBindingAtPath', () => {
|
||||
test('finds the top-level data when no view overrides it', () => {
|
||||
const spec = { data: { name: 'Sales' }, layer: [{ mark: 'bar', encoding: { x: {} } }] };
|
||||
// Cursor deep inside the first layer's encoding — inherits the top-level data.
|
||||
const binding = dataBindingAtPath(spec, ['layer', 0, 'encoding', 'x']);
|
||||
expect(binding).toEqual({ data: { name: 'Sales' }, anchorPath: [] });
|
||||
});
|
||||
|
||||
test('the nearest enclosing view wins (Vega-Lite data inheritance)', () => {
|
||||
const spec = {
|
||||
data: { name: 'Top' },
|
||||
layer: [{ data: { name: 'Inner' }, encoding: { y: { field: 'a' } } }],
|
||||
};
|
||||
const binding = dataBindingAtPath(spec, ['layer', 0, 'encoding', 'y', 'field']);
|
||||
expect(binding).toEqual({ data: { name: 'Inner' }, anchorPath: ['layer', 0] });
|
||||
});
|
||||
|
||||
test('a sibling view does not leak its data', () => {
|
||||
const spec = {
|
||||
layer: [
|
||||
{ data: { name: 'A' }, mark: 'bar' },
|
||||
{ data: { name: 'B' }, mark: 'line' },
|
||||
],
|
||||
};
|
||||
expect(dataBindingAtPath(spec, ['layer', 1, 'mark'])).toEqual({
|
||||
data: { name: 'B' },
|
||||
anchorPath: ['layer', 1],
|
||||
});
|
||||
});
|
||||
|
||||
test('resolves through a facet/repeat child spec', () => {
|
||||
const spec = { facet: { field: 'g' }, spec: { data: { name: 'Child' }, mark: 'point' } };
|
||||
expect(dataBindingAtPath(spec, ['spec', 'mark'])).toEqual({
|
||||
data: { name: 'Child' },
|
||||
anchorPath: ['spec'],
|
||||
});
|
||||
});
|
||||
|
||||
test('returns null when no ancestor on the path declares data', () => {
|
||||
expect(dataBindingAtPath({ mark: 'bar' }, ['mark'])).toBeNull();
|
||||
});
|
||||
|
||||
test('descends defensively past a non-object on the path', () => {
|
||||
expect(dataBindingAtPath({ mark: 'bar' }, ['mark', 'type', 'nope'])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setDataBindingAtPath', () => {
|
||||
test('sets the data block of the view at the anchor path', () => {
|
||||
const spec = { layer: [{ mark: 'line', data: { values: [{ a: 1 }] } }] };
|
||||
expect(setDataBindingAtPath(spec, ['layer', 0], { name: 'X' })).toBe(true);
|
||||
expect(spec.layer[0].data).toEqual({ name: 'X' });
|
||||
});
|
||||
|
||||
test('sets the root data block for the empty path', () => {
|
||||
const spec: { data?: unknown; mark: string } = { mark: 'bar' };
|
||||
expect(setDataBindingAtPath(spec, [], { name: 'Root' })).toBe(true);
|
||||
expect(spec.data).toEqual({ name: 'Root' });
|
||||
});
|
||||
|
||||
test('returns false when the path does not resolve to an object', () => {
|
||||
const spec = { layer: [{ mark: 'line' }] };
|
||||
expect(setDataBindingAtPath(spec, ['layer', 3], { name: 'X' })).toBe(false);
|
||||
expect(setDataBindingAtPath({ mark: 'bar' }, ['mark'], { name: 'X' })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('forEachDataBinding', () => {
|
||||
test('visits every object node and prunes the data/datasets payloads', () => {
|
||||
// A row inside `data.values` carries a field literally named `data`; the prune
|
||||
// keeps the walk from visiting it as a binding.
|
||||
const spec = {
|
||||
datasets: { ds: [{ z: 1 }] },
|
||||
layer: [{ data: { name: 'A' } }, { data: { values: [{ data: { name: 'buried' } }] } }],
|
||||
};
|
||||
const seen: unknown[] = [];
|
||||
forEachDataBinding(spec, (node) => {
|
||||
if (node.data !== undefined) seen.push(node.data);
|
||||
});
|
||||
expect(seen).toEqual([{ name: 'A' }, { values: [{ data: { name: 'buried' } }] }]);
|
||||
});
|
||||
|
||||
test('passes the spec self-defined names and stops early on a true return', () => {
|
||||
const spec = {
|
||||
datasets: { ds: [{ a: 1 }] },
|
||||
layer: [{ data: { name: 'ds' } }, { data: { name: 'other' } }],
|
||||
};
|
||||
let visits = 0;
|
||||
let sawSelfDefined: ReadonlySet<string> | null = null;
|
||||
forEachDataBinding(spec, (node, selfDefined) => {
|
||||
visits++;
|
||||
sawSelfDefined = selfDefined;
|
||||
return node.data !== undefined; // stop at the first node carrying a data block
|
||||
});
|
||||
expect(sawSelfDefined && [...sawSelfDefined]).toEqual(['ds']);
|
||||
// root (no data) → layer[0] (has data, stop). layer[1] never visited.
|
||||
expect(visits).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('mapDataBindings', () => {
|
||||
test('maps each data block, carries datasets through, and prunes payloads', () => {
|
||||
const spec = {
|
||||
datasets: { ds: [{ a: 1 }] },
|
||||
layer: [{ data: { name: 'A' } }, { data: { values: [{ data: 'row-field' }] } }],
|
||||
};
|
||||
const out = mapDataBindings(spec, (data) =>
|
||||
typeof (data as { name?: unknown }).name === 'string' ? { name: 'MAPPED' } : data,
|
||||
) as typeof spec;
|
||||
expect(out.layer[0].data).toEqual({ name: 'MAPPED' });
|
||||
expect(out.layer[1].data).toEqual({ values: [{ data: 'row-field' }] }); // payload untouched
|
||||
expect(out.datasets).toEqual({ ds: [{ a: 1 }] }); // carried through
|
||||
});
|
||||
|
||||
test('does not mutate the input', () => {
|
||||
const spec = { data: { name: 'A' } };
|
||||
const before = structuredClone(spec);
|
||||
mapDataBindings(spec, () => ({ name: 'B' }));
|
||||
expect(spec).toEqual(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
/**
|
||||
* The Vega-Lite data model — the single place that decides what a `data` block
|
||||
* *means* (docs/architecture/05 → Rendering Contract; 07 → Relationships). Pure
|
||||
* core: no browser APIs, no React.
|
||||
*
|
||||
* A Vega-Lite `data` block is exactly one of four shapes (mirrored from
|
||||
* vega-lite/src/data.ts — `isUrlData`/`isInlineData`/`isNamedData`/`isGenerator`):
|
||||
*
|
||||
* - **url** — has `url`
|
||||
* - **inline** — has `values` (and MAY also carry a `name` label)
|
||||
* - **generator** — has `sequence` | `sphere` | `graticule`
|
||||
* - **named** — has a string `name`, and NONE of the above
|
||||
*
|
||||
* Astrolabe binds its library datasets through *named* data: `{ data: { name } }`
|
||||
* resolves to the library dataset's rows at render time (core/rendering). The
|
||||
* fidelity rule, taken verbatim from Vega-Lite, is that a `name` ALONE does not
|
||||
* make a reference — a `name` riding on inline `values`, a `url`, or a generator
|
||||
* is a label Vega-Lite uses to rebind data at runtime, and Astrolabe must leave it
|
||||
* untouched. Classifying by this rule (not by "has a string name") is what keeps
|
||||
* Astrolabe a strict *extension* of Vega-Lite rather than a divergence: every
|
||||
* native data form keeps working, and only true named references are resolved.
|
||||
*
|
||||
* Reference detection (`extractDatasetRefs`/rename in core/spec-refs) and reference
|
||||
* resolution (core/rendering) both run through `libraryRefName` here, so the two
|
||||
* can never disagree on what counts as a library dependency.
|
||||
*/
|
||||
|
||||
import { isJsonObject } from './spec-config';
|
||||
|
||||
/** The four Vega-Lite data shapes; `null` when the value is not a data block. */
|
||||
export type DataKind = 'url' | 'inline' | 'generator' | 'named';
|
||||
|
||||
/** The keys that mark a Vega-Lite data *generator* (sequence/sphere/graticule). */
|
||||
const GENERATOR_KEYS = ['sequence', 'sphere', 'graticule'] as const;
|
||||
|
||||
/**
|
||||
* Classify a Vega-Lite `data` value, matching Vega-Lite's own precedence: `url`
|
||||
* and `values` and generator keys win over a bare `name`, so a named-inline or
|
||||
* named-url block is inline/url data — not a named reference. Returns `null` for a
|
||||
* non-object, `data: null`, or an empty/unrecognized block.
|
||||
*/
|
||||
export function classifyData(data: unknown): DataKind | null {
|
||||
if (!isJsonObject(data)) return null;
|
||||
if ('url' in data) return 'url';
|
||||
if ('values' in data) return 'inline';
|
||||
if (GENERATOR_KEYS.some((key) => key in data)) return 'generator';
|
||||
if (typeof data.name === 'string') return 'named';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The set of dataset names a spec defines for itself via a top-level `datasets`
|
||||
* object. These are resolved natively by Vega-Lite, so they are never library
|
||||
* dependencies and are excluded from reference detection and resolution.
|
||||
*/
|
||||
export function selfDefinedNames(spec: unknown): Set<string> {
|
||||
const names = new Set<string>();
|
||||
if (isJsonObject(spec) && isJsonObject(spec.datasets)) {
|
||||
for (const key of Object.keys(spec.datasets)) names.add(key);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* The library dataset name a `data` block references, or `null` when it is not a
|
||||
* library reference — i.e. it is named data (`classifyData` → `'named'`) whose
|
||||
* name is not one the spec defines for itself. This is the single predicate behind
|
||||
* both reference extraction and render-time resolution.
|
||||
*/
|
||||
export function libraryRefName(data: unknown, selfDefined: ReadonlySet<string>): string | null {
|
||||
if (classifyData(data) !== 'named') return null;
|
||||
const { name } = data as { name: string };
|
||||
return selfDefined.has(name) ? null : name;
|
||||
}
|
||||
|
||||
/** A `data` block in scope at a cursor, with the path prefix where it was found. */
|
||||
export interface DataBinding {
|
||||
/** The raw `data` value (classify it with `classifyData` / `libraryRefName`). */
|
||||
data: unknown;
|
||||
/** The path prefix of the view that declares this `data` — `[]` for the root. */
|
||||
anchorPath: ReadonlyArray<string | number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The data binding in scope at a JSON `path` into the spec — the `data` block of
|
||||
* the *nearest enclosing view* (docs/architecture/08 → editor augmentation). This
|
||||
* implements Vega-Lite's data inheritance: a layer/concat/facet/repeat child
|
||||
* inherits its parent's data unless it declares its own, so the binding is the
|
||||
* `data` of the deepest object along the path that has one. Returns `null` when no
|
||||
* ancestor on the path declares `data`.
|
||||
*
|
||||
* `path` is a jsonc-parser location path (object keys and array indices, root to
|
||||
* cursor). The walk descends it defensively, stopping at any non-object/array.
|
||||
*/
|
||||
export function dataBindingAtPath(
|
||||
spec: unknown,
|
||||
path: ReadonlyArray<string | number>,
|
||||
): DataBinding | null {
|
||||
let node: unknown = spec;
|
||||
let binding: DataBinding | null = null;
|
||||
if (isJsonObject(node) && 'data' in node) binding = { data: node.data, anchorPath: [] };
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
if (node === null || typeof node !== 'object') break;
|
||||
node = (node as Record<string | number, unknown>)[path[i]];
|
||||
if (isJsonObject(node) && 'data' in node) {
|
||||
binding = { data: node.data, anchorPath: path.slice(0, i + 1) };
|
||||
}
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the `data` block of the view at `anchorPath` (an anchor from
|
||||
* `dataBindingAtPath`), in place — the write-counterpart of the read above, used by
|
||||
* Extract-to-Dataset to swap a view's inline data for a `{ name }` reference.
|
||||
* Mutates `spec`, so the caller passes a copy it owns (a fresh parse). Returns false
|
||||
* when the path no longer resolves to an object, so the caller can refuse rather
|
||||
* than write the wrong node.
|
||||
*/
|
||||
export function setDataBindingAtPath(
|
||||
spec: unknown,
|
||||
anchorPath: ReadonlyArray<string | number>,
|
||||
block: unknown,
|
||||
): boolean {
|
||||
let node: unknown = spec;
|
||||
for (const key of anchorPath) {
|
||||
if (node === null || typeof node !== 'object') return false;
|
||||
node = (node as Record<string | number, unknown>)[key];
|
||||
}
|
||||
if (node === null || typeof node !== 'object') return false;
|
||||
(node as Record<string, unknown>).data = block;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one spec walk every data-binding pass shares (reference detection/resolution,
|
||||
* extraction gates). Visits each object node that could carry a `data` binding,
|
||||
* with the spec's self-defined `datasets` names, and **prunes two payload keys** —
|
||||
* a `data` object's contents and the top-level `datasets` map hold user data, not
|
||||
* nested specs, so descending into them would misread a data row carrying a field
|
||||
* literally named `data` as a binding. `visit` returns `true` to stop early (for a
|
||||
* predicate); any other return continues. Pruning the same two keys here is what
|
||||
* keeps every pass in agreement on what counts as a reference — they cannot drift
|
||||
* because they share this walk.
|
||||
*/
|
||||
export function forEachDataBinding(
|
||||
spec: unknown,
|
||||
visit: (node: Record<string, unknown>, selfDefined: ReadonlySet<string>) => boolean | void,
|
||||
): void {
|
||||
const selfDefined = selfDefinedNames(spec);
|
||||
let stop = false;
|
||||
const walk = (node: unknown): void => {
|
||||
if (stop) return;
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) walk(item);
|
||||
return;
|
||||
}
|
||||
if (!isJsonObject(node)) return;
|
||||
if (visit(node, selfDefined) === true) {
|
||||
stop = true;
|
||||
return;
|
||||
}
|
||||
for (const key of Object.keys(node)) {
|
||||
if (key === 'data' || key === 'datasets') continue;
|
||||
walk(node[key]);
|
||||
}
|
||||
};
|
||||
walk(spec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a deep copy of `spec` with each data binding's `data` value replaced by
|
||||
* `mapData(data, selfDefined)` — the copy-rewrite counterpart of
|
||||
* {@link forEachDataBinding}, for rename/promote passes. Prunes the same two keys:
|
||||
* the top-level `datasets` map is carried through untouched, and a `data` payload is
|
||||
* never descended into (only its block is mapped). The input is not mutated.
|
||||
*/
|
||||
export function mapDataBindings(
|
||||
spec: unknown,
|
||||
mapData: (data: unknown, selfDefined: ReadonlySet<string>) => unknown,
|
||||
): unknown {
|
||||
const selfDefined = selfDefinedNames(spec);
|
||||
const rewrite = (node: unknown): unknown => {
|
||||
if (Array.isArray(node)) return node.map(rewrite);
|
||||
if (node && typeof node === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
|
||||
if (key === 'data') out[key] = mapData(value, selfDefined);
|
||||
else if (key === 'datasets') out[key] = value;
|
||||
else out[key] = rewrite(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return node;
|
||||
};
|
||||
return rewrite(spec);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EXPRESSION_KEYS, expressionStringsIn, firstExpressionError } from './spec-expressions';
|
||||
|
||||
/** The substring a span points at, to assert the offset/length land on the text. */
|
||||
function slice(text: string, span: { offset: number; length: number }): string {
|
||||
return text.slice(span.offset, span.offset + span.length);
|
||||
}
|
||||
|
||||
describe('spec-expressions', () => {
|
||||
it('declares the expression-bearing keys', () => {
|
||||
expect([...EXPRESSION_KEYS].sort()).toEqual(['calculate', 'expr', 'filter', 'test']);
|
||||
});
|
||||
|
||||
it('finds a calculate expression and points between the quotes', () => {
|
||||
const text = '{ "transform": [ { "calculate": "datum.a + 1", "as": "b" } ] }';
|
||||
const spans = expressionStringsIn(text);
|
||||
expect(spans).toHaveLength(1);
|
||||
expect(spans[0].key).toBe('calculate');
|
||||
expect(spans[0].value).toBe('datum.a + 1');
|
||||
expect(slice(text, spans[0])).toBe('datum.a + 1');
|
||||
});
|
||||
|
||||
it('finds string filter and test, in source order', () => {
|
||||
const text =
|
||||
'{ "transform": [ { "filter": "datum.x > 0" } ], "encoding": { "color": { "condition": { "test": "datum.x > 5", "value": "red" } } } }';
|
||||
const spans = expressionStringsIn(text);
|
||||
expect(spans.map((s) => s.value)).toEqual(['datum.x > 0', 'datum.x > 5']);
|
||||
});
|
||||
|
||||
it('excludes object-form filter predicates (only string values are expressions)', () => {
|
||||
const text =
|
||||
'{ "transform": [ { "filter": { "field": "x", "gt": 0 } }, { "filter": { "param": "brush" } } ] }';
|
||||
expect(expressionStringsIn(text)).toEqual([]);
|
||||
});
|
||||
|
||||
it('finds an expr value (param / value-ref form)', () => {
|
||||
const text = '{ "params": [ { "name": "n", "expr": "width / 2" } ] }';
|
||||
const spans = expressionStringsIn(text);
|
||||
expect(spans.map((s) => s.value)).toEqual(['width / 2']);
|
||||
});
|
||||
|
||||
it('finds expressions across all views of a composition', () => {
|
||||
const text =
|
||||
'{ "hconcat": [ { "transform": [ { "calculate": "datum.a", "as": "a2" } ] }, { "transform": [ { "calculate": "datum.b", "as": "b2" } ] } ] }';
|
||||
expect(expressionStringsIn(text).map((s) => s.value)).toEqual(['datum.a', 'datum.b']);
|
||||
});
|
||||
|
||||
it('keeps offsets aligned when the string contains escapes', () => {
|
||||
const text = '{ "transform": [ { "calculate": "datum[\\"a b\\"]", "as": "c" } ] }';
|
||||
const spans = expressionStringsIn(text);
|
||||
expect(spans).toHaveLength(1);
|
||||
// The raw inner token (escapes intact) is what the span covers.
|
||||
expect(slice(text, spans[0])).toBe('datum[\\"a b\\"]');
|
||||
});
|
||||
|
||||
it('yields a zero-length span for an empty expression', () => {
|
||||
const text = '{ "transform": [ { "calculate": "", "as": "c" } ] }';
|
||||
const spans = expressionStringsIn(text);
|
||||
expect(spans).toHaveLength(1);
|
||||
expect(spans[0].length).toBe(0);
|
||||
expect(spans[0].value).toBe('');
|
||||
});
|
||||
|
||||
it('returns nothing for unparseable text rather than throwing', () => {
|
||||
expect(expressionStringsIn('{ "transform": [ { "calc')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('firstExpressionError', () => {
|
||||
it('returns null when every expression parses', () => {
|
||||
const text = '{ "transform": [ { "filter": "datum.value > 0" } ] }';
|
||||
expect(firstExpressionError(text)).toBeNull();
|
||||
});
|
||||
|
||||
it('attributes a malformed expression to its property, with the parser message', () => {
|
||||
const text = '{ "transform": [ { "filter": "datum[\'value\'] ==" } ] }';
|
||||
const err = firstExpressionError(text);
|
||||
expect(err?.key).toBe('filter');
|
||||
expect(err?.expr).toBe("datum['value'] ==");
|
||||
expect(err?.message).toBe('Unexpected end of input');
|
||||
});
|
||||
|
||||
it('reports the 1-based line of the offending expression', () => {
|
||||
const text = ['{', ' "transform": [', ' { "calculate": "clamp(" }', ' ]', '}'].join('\n');
|
||||
expect(firstExpressionError(text)?.line).toBe(3);
|
||||
});
|
||||
|
||||
it('ignores empty expressions (incomplete, not malformed)', () => {
|
||||
expect(firstExpressionError('{ "transform": [ { "calculate": "", "as": "x" } ] }')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the first malformed expression in document order', () => {
|
||||
const text =
|
||||
'{ "transform": [ { "calculate": "datum.a +", "as": "x" }, { "filter": "datum.b ==" } ] }';
|
||||
expect(firstExpressionError(text)?.key).toBe('calculate');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Expression strings within a spec document (docs/architecture/08 → editor
|
||||
* augmentation). Portable core: `jsonc-parser`'s error-tolerant tree only — no
|
||||
* browser APIs, no React, no Monaco — so it keeps working while the draft is
|
||||
* briefly unparseable mid-edit.
|
||||
*
|
||||
* Vega-Lite embeds the Vega expression language inside a handful of JSON string
|
||||
* values; the JSON schema sees them as opaque strings. This module finds every
|
||||
* such string so the editor can validate them (→ markers) and so the completion /
|
||||
* hover providers share one definition of "is the cursor in an expression?". The
|
||||
* single-expression analysis itself lives in core/expr-validate; this module only
|
||||
* *locates* the expressions in a document.
|
||||
*/
|
||||
|
||||
import { parseTree, type Node } from 'jsonc-parser';
|
||||
import { validateExpression } from './expr-validate';
|
||||
|
||||
/**
|
||||
* Property keys whose **string** value is a Vega expression. `filter` and `test`
|
||||
* also accept *object* predicates (`{field, gt}`, `{param}`) — those are not
|
||||
* expressions and are excluded by the string-value check, not by this set.
|
||||
*/
|
||||
export const EXPRESSION_KEYS: ReadonlySet<string> = new Set([
|
||||
'calculate',
|
||||
'filter',
|
||||
'expr',
|
||||
'test',
|
||||
]);
|
||||
|
||||
/** One expression string located in the document. */
|
||||
export interface ExpressionSpan {
|
||||
/** The property the expression sits under (`calculate`, `filter`, `expr`, `test`). */
|
||||
key: string;
|
||||
/** The expression text (the JSON string's unescaped value). */
|
||||
value: string;
|
||||
/** Byte offset of the first character *inside* the quotes. */
|
||||
offset: number;
|
||||
/** Length of the characters between the quotes (the raw token, escapes intact). */
|
||||
length: number;
|
||||
}
|
||||
|
||||
/** This property node's key + value when it is `<expr-key>: <string>`, else null. */
|
||||
function expressionValue(node: Node): { key: string; valueNode: Node } | null {
|
||||
if (node.type !== 'property' || !node.children) return null;
|
||||
const [keyNode, valueNode] = node.children;
|
||||
const key = typeof keyNode?.value === 'string' ? keyNode.value : undefined;
|
||||
if (key === undefined || !EXPRESSION_KEYS.has(key)) return null;
|
||||
return valueNode?.type === 'string' && typeof valueNode.value === 'string'
|
||||
? { key, valueNode }
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every Vega expression string in the document, in source order. The span covers
|
||||
* the characters *between* the quotes (so a marker squiggles the expression, not
|
||||
* the JSON punctuation); an empty string yields a zero-length span the caller can
|
||||
* ignore. Returns `[]` for unparseable text.
|
||||
*/
|
||||
export function expressionStringsIn(text: string): ExpressionSpan[] {
|
||||
const tree = parseTree(text);
|
||||
if (!tree) return [];
|
||||
const spans: ExpressionSpan[] = [];
|
||||
const walk = (node: Node): void => {
|
||||
const found = expressionValue(node);
|
||||
if (found) {
|
||||
// Inner span = the quoted token minus its two quotes. Computed from the raw
|
||||
// token length (not value.length) so escapes inside the string stay aligned.
|
||||
spans.push({
|
||||
key: found.key,
|
||||
value: found.valueNode.value as string,
|
||||
offset: found.valueNode.offset + 1,
|
||||
length: Math.max(0, found.valueNode.length - 2),
|
||||
});
|
||||
}
|
||||
if (node.children) for (const child of node.children) walk(child);
|
||||
};
|
||||
walk(tree);
|
||||
return spans;
|
||||
}
|
||||
|
||||
/** A syntactically-invalid expression located in the document. */
|
||||
export interface ExpressionError {
|
||||
/** The property it sits under (`filter`, `calculate`, …) — to name the problem. */
|
||||
key: string;
|
||||
/** The offending expression text. */
|
||||
expr: string;
|
||||
/** The parser's message — the same text the editor marker and hover show. */
|
||||
message: string;
|
||||
/** 1-based line of the expression, so the message can point at it (matches the
|
||||
* editor's line numbers). */
|
||||
line: number;
|
||||
}
|
||||
|
||||
/** The 1-based line number of a byte offset in `text` (newlines counted before it). */
|
||||
function lineAtOffset(text: string, offset: number): number {
|
||||
let line = 1;
|
||||
for (let i = 0; i < offset && i < text.length; i++) {
|
||||
if (text[i] === '\n') line++;
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first syntactically-invalid expression in the document, or null when every
|
||||
* expression parses. Lets a failed render attribute itself to a broken expression
|
||||
* — a precise, located message that agrees with the editor's squiggle — instead of
|
||||
* the generic "check your JSON" fallback (the JSON is valid; the expression isn't).
|
||||
*/
|
||||
export function firstExpressionError(text: string): ExpressionError | null {
|
||||
for (const span of expressionStringsIn(text)) {
|
||||
if (span.value.trim() === '') continue;
|
||||
const result = validateExpression(span.value);
|
||||
if (!result.valid) {
|
||||
return {
|
||||
key: span.key,
|
||||
expr: span.value,
|
||||
message: result.error ?? 'Invalid expression.',
|
||||
line: lineAtOffset(text, span.offset),
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { derivedFieldNamesAtPath } from './spec-fields';
|
||||
|
||||
describe('derivedFieldNamesAtPath', () => {
|
||||
const spec = {
|
||||
transform: [{ calculate: 'x', as: 'shared' }],
|
||||
layer: [
|
||||
{ transform: [{ calculate: 'y', as: 'inner' }], mark: 'bar' },
|
||||
{ transform: [{ calculate: 'z', as: 'sibling' }], mark: 'line' },
|
||||
],
|
||||
};
|
||||
|
||||
it('collects calculate / timeUnit / bin as-names', () => {
|
||||
const s = {
|
||||
transform: [
|
||||
{ calculate: 'datum.a + 1', as: 'plusOne' },
|
||||
{ timeUnit: 'month', field: 'date', as: 'mo' },
|
||||
{ bin: true, field: 'x', as: ['x_start', 'x_end'] },
|
||||
],
|
||||
};
|
||||
expect(derivedFieldNamesAtPath(s, []).sort()).toEqual(['mo', 'plusOne', 'x_end', 'x_start']);
|
||||
});
|
||||
|
||||
it('reaches into op-list transforms (aggregate/window/joinaggregate)', () => {
|
||||
const s = {
|
||||
transform: [
|
||||
{ aggregate: [{ op: 'mean', field: 'v', as: 'meanV' }], groupby: ['g'] },
|
||||
{ window: [{ op: 'rank', as: 'rk' }] },
|
||||
{ joinaggregate: [{ op: 'sum', field: 'v', as: 'total' }] },
|
||||
],
|
||||
};
|
||||
expect(derivedFieldNamesAtPath(s, []).sort()).toEqual(['meanV', 'rk', 'total']);
|
||||
});
|
||||
|
||||
it('defaults fold output to key/value when as is omitted', () => {
|
||||
expect(derivedFieldNamesAtPath({ transform: [{ fold: ['a', 'b'] }] }, []).sort()).toEqual([
|
||||
'key',
|
||||
'value',
|
||||
]);
|
||||
expect(
|
||||
derivedFieldNamesAtPath({ transform: [{ fold: ['a'], as: ['k', 'v'] }] }, []).sort(),
|
||||
).toEqual(['k', 'v']);
|
||||
});
|
||||
|
||||
it('returns nothing for a spec with no transforms', () => {
|
||||
expect(derivedFieldNamesAtPath({ mark: 'bar', encoding: { x: { field: 'a' } } }, [])).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it('includes ancestor transforms but not sibling-view transforms', () => {
|
||||
// Inside layer 0: sees its own `inner` and the inherited top-level `shared`,
|
||||
// but never layer 1's `sibling`.
|
||||
expect(derivedFieldNamesAtPath(spec, ['layer', 0, 'mark']).sort()).toEqual(['inner', 'shared']);
|
||||
expect(derivedFieldNamesAtPath(spec, ['layer', 1, 'mark']).sort()).toEqual([
|
||||
'shared',
|
||||
'sibling',
|
||||
]);
|
||||
});
|
||||
|
||||
it('at the root sees only the top-level transforms', () => {
|
||||
expect(derivedFieldNamesAtPath(spec, [])).toEqual(['shared']);
|
||||
});
|
||||
|
||||
it('descends defensively past a non-object on the path', () => {
|
||||
expect(derivedFieldNamesAtPath(spec, ['transform', 0, 'calculate', 'nope'])).toEqual([
|
||||
'shared',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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`), scoped to the transforms in effect at a cursor — the enclosing
|
||||
* view's and its ancestors' (Vega-Lite applies a parent view's transforms upstream
|
||||
* of its children; a sibling view's do not apply).
|
||||
*
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Add every field name a node's own `transform` array introduces. */
|
||||
function collectNodeTransforms(node: unknown, names: Set<string>): void {
|
||||
if (
|
||||
node !== null &&
|
||||
typeof node === 'object' &&
|
||||
Array.isArray((node as { transform?: unknown }).transform)
|
||||
) {
|
||||
for (const t of (node as { transform: unknown[] }).transform) collectFromTransform(t, names);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The fields derived by transforms *in scope* at a JSON `path` into the spec — the
|
||||
* `as` outputs of every transform on the cursor's enclosing view and its ancestors
|
||||
* (docs/architecture/08 → editor augmentation). Vega-Lite applies a parent view's
|
||||
* transforms upstream of its children, so a field a parent's `calculate` creates is
|
||||
* visible to a nested view; sibling views' transforms are not. The walk descends
|
||||
* `path` (a jsonc-parser location path) defensively, collecting along the way.
|
||||
*/
|
||||
export function derivedFieldNamesAtPath(
|
||||
spec: unknown,
|
||||
path: ReadonlyArray<string | number>,
|
||||
): string[] {
|
||||
const names = new Set<string>();
|
||||
let node: unknown = spec;
|
||||
collectNodeTransforms(node, names);
|
||||
for (const key of path) {
|
||||
if (node === null || typeof node !== 'object') break;
|
||||
node = (node as Record<string | number, unknown>)[key];
|
||||
collectNodeTransforms(node, names);
|
||||
}
|
||||
return [...names];
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
inlineValuesOf,
|
||||
rowsForDataBinding,
|
||||
selfDefinedPayloadOf,
|
||||
specHasExtractableData,
|
||||
} from './spec-inline-data';
|
||||
|
||||
describe('rowsForDataBinding', () => {
|
||||
it('reads inline values from the given binding', () => {
|
||||
const spec = { data: { values: [{ a: 1 }, { a: 2 }] } };
|
||||
expect(rowsForDataBinding(spec, spec.data)).toEqual([{ a: 1 }, { a: 2 }]);
|
||||
});
|
||||
|
||||
it('resolves a self-defined named binding against top-level datasets', () => {
|
||||
const spec = { datasets: { ds: [{ c: 1 }, { c: 2 }] }, data: { name: 'ds' } };
|
||||
expect(rowsForDataBinding(spec, { name: 'ds' })).toEqual([{ c: 1 }, { c: 2 }]);
|
||||
});
|
||||
|
||||
it('returns null for a library reference, url, generator, or CSV-string values', () => {
|
||||
const spec = { datasets: { ds: [{ c: 1 }] } };
|
||||
expect(rowsForDataBinding(spec, { name: 'LibraryRef' })).toBeNull(); // not self-defined
|
||||
expect(rowsForDataBinding(spec, { url: 'x.csv' })).toBeNull();
|
||||
expect(rowsForDataBinding(spec, { sequence: { start: 0, stop: 5 } })).toBeNull();
|
||||
expect(rowsForDataBinding(spec, { values: 'a,b\n1,2' })).toBeNull();
|
||||
expect(rowsForDataBinding({ mark: 'bar' }, { values: [] })).toBeNull(); // empty rows
|
||||
});
|
||||
});
|
||||
|
||||
describe('inlineValuesOf', () => {
|
||||
it('returns the JSON array payload as json', () => {
|
||||
expect(inlineValuesOf({ values: [{ a: 1 }] })).toEqual({ values: [{ a: 1 }], format: 'json' });
|
||||
});
|
||||
|
||||
it('preserves a CSV/TSV string payload verbatim with its declared format', () => {
|
||||
expect(inlineValuesOf({ values: 'a,b\n1,2', format: { type: 'csv' } })).toEqual({
|
||||
values: 'a,b\n1,2',
|
||||
format: 'csv',
|
||||
});
|
||||
expect(inlineValuesOf({ values: 'a\tb', format: { type: 'tsv' } })).toEqual({
|
||||
values: 'a\tb',
|
||||
format: 'tsv',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a name riding on inline values (named-inline is still inline)', () => {
|
||||
expect(inlineValuesOf({ name: 'x', values: [{ a: 1 }] })).toEqual({
|
||||
values: [{ a: 1 }],
|
||||
format: 'json',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for a library reference, url, generator, or non-object', () => {
|
||||
expect(inlineValuesOf({ name: 'sales' })).toBeNull();
|
||||
expect(inlineValuesOf({ url: 'x.csv' })).toBeNull();
|
||||
expect(inlineValuesOf({ sequence: { start: 0, stop: 5 } })).toBeNull();
|
||||
expect(inlineValuesOf(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('selfDefinedPayloadOf', () => {
|
||||
it('returns the rows of a referenced self-defined datasets entry', () => {
|
||||
const spec = { datasets: { sales: [{ a: 1 }, { a: 2 }] }, data: { name: 'sales' } };
|
||||
expect(selfDefinedPayloadOf(spec, spec.data)).toEqual({
|
||||
values: [{ a: 1 }, { a: 2 }],
|
||||
format: 'json',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for a library reference, inline values, or a non-array entry', () => {
|
||||
const spec = { datasets: { sales: [{ a: 1 }] } };
|
||||
expect(selfDefinedPayloadOf(spec, { name: 'LibraryRef' })).toBeNull(); // not self-defined
|
||||
expect(selfDefinedPayloadOf(spec, { values: [{ a: 1 }] })).toBeNull(); // inline, not named
|
||||
expect(
|
||||
selfDefinedPayloadOf({ datasets: { s: 'a,b\n1,2' }, data: {} }, { name: 's' }),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('specHasExtractableData', () => {
|
||||
it('is true for inline values and for a self-defined reference', () => {
|
||||
expect(specHasExtractableData({ data: { values: [{ a: 1 }] } })).toBe(true);
|
||||
expect(specHasExtractableData({ datasets: { ds: [{ a: 1 }] }, data: { name: 'ds' } })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('is false for a library reference, url, or generator', () => {
|
||||
expect(specHasExtractableData({ data: { name: 'sales' } })).toBe(false);
|
||||
expect(specHasExtractableData({ data: { url: 'x.csv' } })).toBe(false);
|
||||
expect(specHasExtractableData({ data: { sequence: { start: 0, stop: 5 } } })).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores a field literally named data inside a user data row (prune-the-payload)', () => {
|
||||
// The walker prunes the `data`/`datasets` keys, so a row that carries a field
|
||||
// named `data` is never mistaken for a binding — only the genuine top-level
|
||||
// inline binding triggers, and an encoding field named `data` triggers nothing.
|
||||
expect(
|
||||
specHasExtractableData({ data: { values: [{ data: { name: 'x' } }] }, mark: 'bar' }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
specHasExtractableData({ layer: [{ mark: 'bar', encoding: { x: { field: 'data' } } }] }),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user