mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
116 lines
7.4 KiB
Markdown
116 lines
7.4 KiB
Markdown
# 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.
|