7.4 KiB
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-leveldatablock. - Data inspector (
DataInspector) surfaces one input + one resolved table; a composition produces severalsource_<n>/data_<n>. deriveSnippetName(core/snippet) reads top-levelmark/encodingonly, 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
nullfor composed specs, so they stay Monaco-only (correct).
Vega-Lite fidelity clashes
- 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 (spuriousDatasetNotFoundError, or clobbered inline values). Resolved by thecore/spec-dataclassifier (M1). - Shadowing — a library dataset whose name equals a self-defined
datasetskey is silently ignored (self-defined wins). Documented precedence; candidate for a user-facing note, no code change required. - 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. - 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-dataclassifier mirroringisNamedData;spec-refs+renderingrouted through it. Closes clash 1. - M2 — view-scoped editor context ✅ —
dataBindingAtPath(climb the cursor's JSON path to the nearest enclosingdata, honoring Vega-Lite's parent→child inheritance) +derivedFieldNamesAtPath(ancestor-chainasoutputs).active-datasetis cursor-scoped (dataInfoAt(text, offset)), resolving columns for every form (library ref case-insensitive, inline, named-inline, self-defineddatasets, 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-viewsenumerates the distinct tables the marks draw (from the compiled Vega spec'sfrom.data+data[].sourcelineage), each with its input + resolved ends;RenderHandle.inspectData()returns those tables with rows;DataInspectoradds aSelectControlview picker (hidden for the single-table case), labels viainspectViewLabel(named dataset or "View N", never compiler names) + acolumns · rowscue. Enumerating by drawn table (not authored view) is forced by Vega-Lite desugaring (apoint: trueline compiles to two layers). Replaced the single-paircore/result-data. - M5 — live / interactive inspection ✅ — the inspector reacts to interactive
selections.
RenderHandle.onDataChangeattaches a debouncedview.addDataListenerto each drawn table's resolved + input names; a selection-as-filter (filter: {param}) recomputes a downstream view'sdata_N, so its listener fires andLivePreviewbumps aliveEpochthat re-reads the table ("what am I visualizing now"); a selection-as-highlight (aconditionencoding) 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*_storetables 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-actionresolves the focused view's data binding (dataBindingAtPath) and lifts whichever of two embedded-data shapes it carries: a view's inlinedata.values(inlineValuesOf→ rewrite that view'sdatablock at its anchor path), or a{ name }reference to a self-defineddatasetsentry (selfDefinedPayloadOf→promoteSelfDefinedDataset: drop thedatasetsentry, 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. Alookuptransform's inlinefrom.datais covered incidentally —dataBindingAtPathfinds 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 — adatasetsentry 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.