Files
astrolabe/docs/exploration/multi-view-data-model-scope.md
T

116 lines
7.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.13.2); this memo stays the point-in-time record.