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

7.4 KiB
Raw Permalink Blame History

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/renderingprepareSpecForRender); 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 (selfDefinedPayloadOfpromoteSelfDefinedDataset: 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.