A layer is one plotting space with several marks stacked in z-order, so it now renders as a single frame holding its child marks as a row of glyphs, badged with a new 'layers' glyph — instead of offset stacked cards that read as separate spaces and hid the lower glyphs. Each mark stays an individual treeitem, so selection and Alt+up/down z-order reorder are unchanged.
11 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 spike —
viewTree(spec)+ a static nested-box renderer with click-to-cursor sync.Shipped (2026-06-29): Phases A–C — the wireframe, mark-type leaf glyphs, in-container reorder (drag +
Alt+↑/↓), and cross-container drag-to-restructure (core/spec-restructurewrapViews). The live contract is now arch 08 (transforms) + arch 10 (interaction). Phase D (on-chart overlay) and Phase E (size editing) remain deferred — size deferred by choice.
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/concathold an array of child views.facet/repeathold 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
pushUndoStoppath (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-rendererkeepsresult.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;inspectableViewswalks the compiled vg spec for data tables, which is the wrong layer for structure.- Cross-container mutations —
moveViewTo(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.
layeris 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: Nis 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/heightlive on the child in concat but on the wrapper in layer;data/resolveon 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
datainherits its nearest ancestor's. Moved under a different data source it silently rebinds; detect viadataBindingAtPathand 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 aresolve/spacingthat 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
/councilitem before the interaction is built.
6a. Deferred polish (Phase A follow-ons)
- Mark-type glyph per leaf. (Shipped.) A simplified glyph of each unit view's mark inside
its box (the
mark-*icon sub-family), so which-is-which reads at a glance. - A more legible
layeredprimitive. (Shipped.) A layer renders as one frame holding its child marks as a row of glyphs, badged as layered (thelayersglyph) — not separate boxes, so it reads as one space and stays distinct from a concat (which is box-per-view). The overlapping/stacked-planes options weighed here were rejected: at glyph scale, overlapping line-art muddies the very marks the glyphs exist to show; legibility beat the z-order-depth cue. - 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.