Files
astrolabe/docs/exploration/visual-composition-editing-exploration.md
T

10 KiB

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 spikeviewTree(spec) + a static nested-box renderer with click-to-cursor sync.


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 mutationsmoveViewTo(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)

Built but parked for a later pass:

  • Mark-type glyph per leaf. A simplified icon of each unit view's mark (bar/line/point/…) inside its box, so which-is-which reads at a glance without hovering. Needs a small mark→glyph set (its own icon-ledger sub-family).
  • A more legible layered primitive. Layers currently render as offset stacked rectangles. A dedicated "stacked planes" metaphor — overlapping sheets/disks, like the database glyph's stacked cylinders — would convey "one space, several layers" more elegantly than nudged boxes.
  • 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.