Files
astrolabe/docs/lyra-review.md
T

28 KiB
Raw Blame History

Lyra — Repository Review & Improvement Ideas

Status: review complete (2026-06-10). Source: vega/lyra cloned to reference/lyra (branch lyra2019, last commit fb284bf, 2021-05-14 — unmaintained). Purpose: mine Lyra, UW IDL's direct-manipulation Vega design environment, for interaction patterns that could improve Astrolabe's chart-building experience. This is a companion to chart-builder-research.md (which already seats Voyager, also from UW IDL); Lyra is the no-code authoring sibling we hadn't read. Bottom line: take Lyra's interaction primitives and data-context UI, not its architecture. Astrolabe's chart-choice intelligence (Tier B) is already ahead of Lyra; the gaps Lyra exposes are about how the user touches fields and sees their data, plus making our guidance actionable.


1. What Lyra is, and the one fact that decides what transfers

Lyra lets you build a custom visualization without writing code — drag data fields onto graphical mark properties, position marks with connectors, resize with handles, and define interactions by demonstration. It targets raw Vega (not Vega-Lite), is a React + **Redux

  • Immutable.js** app on a **d3 v3** runtime, and is a 2019-era research prototype that was never finished ("does not contain all functionality" — README) and has been dormant since

The single most important architectural fact:

Lyra's source of truth is a decomposed GUI model; the Vega spec is a one-way export.

Lyra keeps the design as a Redux store of typed primitives — Mark, Scale, Guide (axis/legend), Pipeline/Dataset, Signal, Interaction, Widget (src/js/store/factory/*) — and ctrl/export.ts (exporter(), 546 lines) serializes that store into a Vega spec on demand. There is no inverse: Lyra cannot import and edit an arbitrary Vega spec. The GUI is the document.

Astrolabe is the exact inverse. Our source of truth is the JSON spec (a snippet); the builder is a view that emits JSON (buildChartSpec/buildSnippetSpecText in src/core/chart-builder.ts), and the user can always drop to Monaco and hand-edit. For a snippet manager this is the right call — Lyra's model would forbid the round-trip our whole product depends on. So we take none of Lyra's architecture and all of the transferable lessons are at the interaction layer, where the GUI-vs-JSON question doesn't matter.

This also reframes the comparison: Lyra is not a "better chart builder" to catch up to. On chart-choice intelligence Astrolabe is already further along — Lyra has no recommendation logic at all (that was Voyager's job, which we already mined). Lyra's value to us is the parts of the authoring experience we haven't built: field-first interaction, live data context, and one-click assistance.


2. Where Astrolabe already leads Lyra (so we don't chase the wrong things)

Dimension Lyra Astrolabe (today)
Chart-choice guidance none — manual mark + manual encoding Tier B: smart default mark, valid-type locking, Size discipline, non-blocking warnings (builderWarnings)
Transforms in the builder data-pipeline only (filter/formula/aggregate/lookup), not per-encoding per-channel Aggregate / Bin / timeUnit, chart-level Sort / Stack
Target language raw Vega (verbose, low-level) Vega-Lite (the right altitude for "pick mark + channels")
Import / round-trip impossible (GUI is the doc) native — JSON is the doc, builder emits it
Stack d3 v3 / Redux / Immutable / class components (stale) React + Zustand + CSS Modules (current)

The takeaways below deliberately avoid re-building anything in the right column.

2.1 — Counterpoint: Lyra exposes a far larger control surface

On chart-choice intelligence we lead, but on raw number of direct controls Lyra is well ahead. Selecting a primitive (mark / scale / axis / legend) opens an inspector of literal knobs — and the inventory dwarfs our builder's:

Primitive (components/inspectors/*) Lyra's direct controls
Mark — Point (Symbol.tsx) x, y, shape, size, fill color, fill opacity, stroke color, stroke width
Mark — Bar/Rect (Rect.tsx) X extent (start/center/end spatial preset), Y extent, fill color/opacity, stroke color/width
Mark — Line (Line.tsx) x, y, stroke color/width, interpolate (curve), tension
Mark — Area (Area.tsx) baseline extent, fill color/opacity, stroke, interpolate, tension, orient
Mark — Text (Text.tsx) text (expr/template), font face/size/weight/style, color, opacity, x/y, dx/dy offset, align, baseline, rotation
Scale (Scale.tsx) type (linear/log/time/ordinal/band/point), zero/nice/clamp/reverse/round, domain (auto fields or manual min/max / value list), range / colour scheme (tableau, category10/20, sequential, diverging), padding(Inner/Outer)
Axis (Axis.tsx) orient, axis-line colour/width, title text + font/size/colour/offset, label size + angle + colour, grid on/off + colour/opacity/width, tick count/size/colour
Legend (Legend.tsx) orient, border, title text/size/colour, label font, symbol shape/size/fill/opacity, gradient

And the structural multiplier: every mark property is droppable — each is either a literal value or a bound field. Lyra therefore has no fixed channel set; you can bind data to strokeWidth, opacity, shape, fontSize, dx… Our builder caps at X / Y / Color / Size and exposes zero styling, scale, axis, or legend controls.

Why this is not simply "Lyra is more capable":

  1. It's a builder-surface gap, not a capability gap. Everything above, Vega-Lite expresses too (mark config, scale/axis/legend, channels like opacity/shape/ theta/row/column). In Astrolabe you reach it by hand-editing the JSON in Monaco. Lyra had no editor and no round-trip — the GUI was the only way to touch the spec, so it was forced to surface every knob. We split the work on purpose: a small guarded encoding builder + a first-class JSON editor for the long tail.
  2. Lyra's breadth carries zero intelligence and high cost — no defaults, no valid-type locking, no guardrails, on raw low-level Vega, in an unfinished inspector.
  3. So the question is not "match Lyra's control count" but "which slices of that surface are common and awkward enough in JSON to deserve promotion into the guarded builder?"

Slices worth promoting (ranked):

  • Constant-value styling via the value-or-field channel (§3.3): fixed fill colour / opacity / point shape / line curve without touching JSON — the most common "tweak the look."
  • Colour-scheme picker on the Color channel (categorical / sequential / diverging) + a measure-axis zero / log toggle. Palette changes are common and fiddly in JSON.
  • A few more channelsopacity, shape, tooltip (already auto-enabled), theta (unlocks pie/donut → a true part-to-whole), row/column facets (backlog B8). These are the field-shelf model (§3.4): adding channels and adding the shelf are one project.
  • Light axis/legend text — custom axis title, legend title / hide. Common, low-risk.

Guardrail: promote a control only when it is both common and awkward in JSON. Otherwise the clean guarded builder drifts toward Lyra's fiddly everything-inspector — the very complexity that helped leave Lyra unfinished. Styling/scale/axis breadth for its own sake belongs in Monaco, not the builder.


3. High-value ideas to adopt (ranked, in-scope, mapped to our code)

3.1 — Make builderWarnings actionable (Lyra's Hints) — highest value, smallest lift

Lyra's hint system (components/hints/Hints.tsx) is a contextual nudge that carries an action: {title, text, action, action_text} — a one-click button that applies the fix and clears the hint. Our guidance is strictly advisory text today: BuilderWarning (chart-builder.ts:333) renders as a passive hint with no affordance.

Several of our existing warnings have an obvious one-click remedy and should become suggested actions (Carbon "inline notification with action" / NN-g "make the system do the work"):

  • "…draws one mark per row; aggregate the measure"[Aggregate as Sum] sets the measure channel's aggregate.
  • "…long labels; flip to a horizontal bar"[Swap X/Y] (we already have the action — just wire it to the hint).
  • "Two measures usually read better as a scatter"[Switch to Point].
  • "Area split into many colour series…"[Stack] or [Remove colour].

This keeps the Tier-B "guarded, non-blocking" philosophy — the action is an offer, never a forced change — while turning a wall of advice into a guided improvement loop. Concretely: extend BuilderWarning with an optional fix?: { label: string; apply: (c) => BuilderConfig } (pure, lives in chart-builder.ts, fully unit-testable), and render the button in ChartBuilderModal. Run the copy + affordance through /council (it auto-fires on guidance copy and new interactive affordances).

3.2 — A live data-table preview in the builder (Lyra's DataTable) — closes a real gap

Confirmed gap: neither ChartBuilderModal nor DatasetsModal ever shows the actual data rows. The builder picks columns from a dropdown and renders a chart preview, but the user never sees the values they're encoding. Lyra always shows the data — DataTable.tsx is a paged, scrollable grid with a per-field type icon in each column header and hover inspection.

A compact, read-only data preview (first N rows, per-column type chip in the header) would let the user sanity-check "is this column really a date / really numeric" before building — exactly when type inference is most likely to surprise them. Good homes:

  • a collapsible "Data" strip in the builder's left pane (under the dataset name), and/or
  • the Datasets manager, where a dataset's shape is otherwise invisible.

We already compute per-column type + cardinality + extent in profile.ts (the A3/A4 profiling extension), so the header chips are free; we only need the row sample. Keep it read-only — editing data is out of scope (and is where Lyra's pipeline complexity lives).

3.3 — The unified Property primitive: one control = value · field · scale (Lyra's signature UI)

Lyra's best idea is components/inspectors/Property.tsx — a single droppable control that is, depending on binding state, either:

  • a literal value editor (number / color / range / select / text — FormInputProperty), or
  • a field chip (drag a column onto it; chip shows source-vs-derived, click to unbind), or
  • a scale chip (the scale the field flows through; click to unbind).

Every mark inspector is then just a declarative list of these — e.g. Symbol.tsx declares Position/Geometry/Fill/Stroke groups as ~10 <Property> lines. One primitive, one drop-to-bind gesture, one unbind gesture, reused for everything including the transform expression fields (Filter.tsx, Formula.tsx reuse the same Property in autocomplete mode).

For Astrolabe this is the conceptual model for a richer channel row: a channel is "a field or a constant or (future) a datum," and Vega-Lite encodes exactly that distinction (field vs value vs datum). Today our channel row is field-only (column dropdown + type segmented control). Adopting the Property model would let a channel also hold a constant value (e.g. a fixed color/size) with one consistent control and a chip showing the binding kind. This is a medium lift and a genuine capability gain, and it generalizes cleanly if we ever add channels beyond X/Y/Color/Size.

3.4 — Field-first interaction: a field shelf you drag onto channels (Lyra drop-zones + Voyager)

Both UW tools converge on fields as the primary objects: Lyra drags fields onto mark properties; Voyager has a field list with type chips you add to channel shelves. Astrolabe is channel-first (pick a channel, then choose its column). A field shelf — the dataset's columns listed with type chips, dragged or clicked onto channels — inverts that to match how people actually think ("I have these fields; where do they go?").

This is the largest interaction shift here and overlaps our deferred Tier-C / faceting work (chart-builder-research.md §8 B8/C). Recommend treating it as the interaction substrate for Tier C, not a standalone task: when we build the intent-first front door and add Row/Column facet channels, a field shelf is the natural way to assign many fields across many channels. Note now; sequence with Tier C.

3.5 — One-click type cycling on the field chip (Lyra's FieldType)

Lyra's FieldType.tsx is a tiny, nice touch: the field's type icon is the control — click it and it cycles N→O→Q→T (within the field's valid set). We already show a type indicator next to each column option and a full N|O|Q|T segmented control per channel. The cheap win is making the per-option type chip itself interactive (and, in a future field shelf, the chip on each field), so type is a property of the field the user can toggle in place — fewer controls, the same guard (only valid types, via our validFieldTypes).

3.6 — Inline expression validation via Vega's own parser (Lyra's transform technique)

Lyra validates a filter/calculate expression with parseExpr from vega-parser before committing it to the model (Filter.tsx, Formula.tsx), catching malformed expressions at the keystroke. We don't expose raw expressions in the builder, so this isn't an immediate builder feature — but the technique (validate Vega/VL expression strings with the library's parser and surface the error inline) is directly reusable for our Monaco editor's diagnostics and for any future calculate/filter affordance. Worth recording in architecture/08 (vega-editor techniques) as a known approach; Lyra even left the "indicate error in parsing" TODO unfinished, so we'd be completing the idea, not copying it.

Lyra's toolbar has an ExampleGallery modal that hydrate()s a curated example into the editor — a "start from something" affordance. Astrolabe's snippet library is the natural home for a small set of curated starter snippets (one per FT intent category we cover — Magnitude/Bar, Change-over-Time/Line, Correlation/Point…). Low effort, improves first-run and doubles as living documentation of what the app does well. Lower priority than 3.13.2.

3.8 — Per-chart export: image + standalone spec (Lyra's Export menu) — biggest miss; cheap

The one squarely-useful idea I almost overlooked. Lyra's toolbar Export (components/toolbar/Export.tsx) offers PNG, SVG, JSON spec, and a standalone HTML scaffold — using view.toImageURL(type) on the live Vega view.

Astrolabe's export today is workspace-JSON backup only (docs/spec/08) — there is no way to get a single chart out as an image, as its own spec file, or onto the clipboard. Yet our renderer already holds exactly the Vega view Lyra exports from (services/chart-renderer.ts, renderer: 'svg'), so this is a handful of lines:

  • Copy spec (clipboard) and Download .vl.json for the active snippet — the most-asked "get my chart out" actions, both trivial given the snippet is the spec.
  • Download PNG / SVG of the rendered chart via view.toImageURL('png'|'svg') — drop a chart into a doc/slide without a screenshot.
  • (Optional) standalone HTML — a self-contained vega-embed page; ties to the share/"private-move" direction in monetization-and-sync-exploration.md.

Home: a snippet-level "Export / Share" affordance (library row action or editor toolbar), distinct from the workspace Export. High value-to-effort; should jump near the top of the queue.

3.9 — Scale / axis / legend as auto-derived override panels (Lyra's bindChannel pipeline)

If we ever promote scale/axis/legend controls (§2.1), Lyra shows the right editing model. Binding a field in Lyra runs a pipeline (actions/bindChannel/): parseScales + parseGuides auto-materialize the scale and axis/legend, aggregateDependencies re-points them when the data is aggregated, and cleanupUnused garbage-collects scales/datasets no longer referenced. The user edits a scale/axis only to override an auto-derived default.

Vega-Lite already auto-derives scales/axes/legends from encodings, so we get the inference for free — the borrow is purely UX: surface scale/axis/legend as optional override panels that start empty (inheriting VL defaults) and only emit JSON when touched, and clearing a channel drops its overrides (the cleanupUnused lesson — no orphaned scale/axis config left behind). This is the discipline that keeps §2.1's promoted controls from bloating the output spec.

3.10 — Expression autocomplete with field/signal chips (Lyra's AutoComplete)

components/inspectors/AutoComplete.tsx is a contenteditable expression field that autocompletes dataset field names (and signals), rendering each datum.field reference as a styled, non-editable chip. For any future filter/calculate affordance — and for Monaco completions in expression positions — autocompleting the dataset's own column names (we already have the schema from profile.ts) is the high-value half; the chip rendering is polish. Niche until we expose expressions, but cheap to remember.

3.11 — Builder-state undo/redo (Lyra's redux-undo) — low priority

Lyra makes the whole design undoable (redux-undo, global vis.present/past). Our builder modal has no undo (Monaco covers the editor, not builder config). A session-scoped undo/redo — or even a lighter "reset to smart defaults" — would soften experimentation in the builder. Low priority: the builder is a short-lived modal and the smart default already gives a sane starting point. Noted for completeness, not urgency.

3.12 — Dataset-level transforms: Filter / Calculate (Lyra's data pipeline) — the cleanest gap

The transform dimension where Astrolabe and Lyra differ most. The key fact is that Vega-Lite has two transform layers:

  1. Inline encoding transformsaggregate / bin / timeUnit / sort / stack written straight onto a channel. This is the layer Astrolabe's builder uses, and uses well — per-channel and guarded, arguably cleaner than Lyra (whose Aggregate is a separate pipeline step).
  2. Top-level transform: [] — dataset-level operations applied in sequence before encoding: filter, calculate, lookup, window, fold, pivot, regression… Astrolabe's builder exposes none of these. Lyra's data pipeline is built almost entirely around them (components/pipelines/transforms/: Filter, Formula, Lookup, Sort — each an ordered card in a TransformList on datasets.<id>.transform).

The operations in layer 2 have no encoding-level shorthand — there is no way to express them except by hand-editing the JSON. The two worth borrowing:

  • Filter (highest value) — row filtering (transform: [{filter: …}]). The single most common data-shaping need, and the builder is already telling users to do it — three existing builderWarnings literally say "filter to fewer categories / to non-negative values" (chart-builder.ts:475,476,511) while offering no way to. Guarded form: a field + operator + value predicate shelf (Voyager-style), no expression required for the common case; power form: a raw datum.… expression validated with Lyra's parseExpr technique (§3.6) + field autocomplete (§3.10). VL applies top-level transforms before encoding aggregation, so "filter raw rows, then the chart aggregates" is the natural — and usually wanted — default; filtering on an aggregated value (HAVING) is the advanced case, defer it.
  • Calculate / derived fieldtransform: [{calculate: "datum.a / datum.b", as: "ratio"}], exactly Lyra's Formula. The new field then appears in the column dropdowns like any other. High value, second to Filter.
  • Lookup (join a second dataset) — Lyra's Lookup joins one pipeline's fields into another by key. Astrolabe references one dataset per snippet, so this is a larger data-model change — note and defer.

Where it lives: Lyra keeps these in a separate dataset pane, distinct from the encoding inspector — the right instinct. In Astrolabe they belong in a "Data" section in the builder's left pane, above the channels, paired with the §3.2 data-table preview: here are your rows [+ Filter] [+ Calculate] → now encode them, with the preview table updating live as filters apply. This composes cleanly and keeps the channel rows about encoding, not data prep.

Scope fit: a field+operator+value filter shelf is squarely Tier-B "smart + guarded," closes the loop on warnings the builder already emits, and is arguably higher value than faceting (B8) — it should enter the §8 backlog as a new first-class item (it is absent today).


4. Anti-recommendations — what to deliberately not take from Lyra

These are as load-bearing as the adopt list; Lyra is a research prototype and several of its defining features are traps for us.

  • The decomposed GUI-as-document architecture (store/factory/* + one-way export.ts). It forbids the spec→builder round-trip our product is built on. Our JSON-source-of-truth + emit model is correct; keep it.
  • Interaction-by-demonstration (ctrl/demonstrations.ts1454 lines that synthesize raw Vega signals/selections from brush/click/hover, plus signals.ts/listeners.ts). Impressive research, wrong layer for us: Vega-Lite's params/selections are the right abstraction. If we ever add interactivity, expose VL selections as a small guarded builder action — never port Lyra's signal generator.
  • The direct-manipulation canvas (handles, connectors, manipulators.ts, transforms/manipulators/*). Enormous surface tied to the Vega-runtime model, and it competes with — rather than complements — our JSON-first-with-guardrails value proposition.
  • The stack: d3 v3, Redux, Immutable.js, react-modal, class components, datalib. We're React + Zustand + CSS Modules with our own modal registry (architecture/03). Take ideas, read no code into the repo. (Per AGENTS.md: no shared lib, patterns adapted not imported.)
  • The full data-pipeline editor (lookup/aggregate/formula transform chains as a first-class pipeline UI). Our per-channel transforms cover the in-scope need; a general pipeline editor is a different, much larger product.

Consolidated (2026-06-10): this sequence is now merged with chart-builder-research.md §8 into the forward plan at chart-builder-enhancement-scope.md (Tier-C target). The list below is the original Lyra-side reasoning; the scope doc §4 is the authoritative build order.

Slot these into chart-builder-research.md §8 rather than inventing a new track:

  1. Actionable hints (§3.1) — extend BuilderWarning with an optional pure fix; wire the four obvious ones; /council the copy. Small, high-value, pure-core + thin UI. Do first.
  2. Per-chart export (§3.8) — Copy spec / Download .vl.json / PNG / SVG for a snippet, from the view the renderer already holds. Highest value-to-effort; near the top.
  3. Filter (+ Calculate) dataset transforms (§3.12) — a guarded field/operator/value filter shelf (then a derived-field calculate) in a "Data" section above the channels. Closes the gap the builder's own warnings point at; not in the backlog today; arguably > faceting.
  4. Data preview (§3.2) — read-only row sample + header type chips in the builder (and Datasets manager); reuse profile.ts stats. Closes a confirmed gap; mostly UI; pairs with #3.
  5. Value-or-field channels via the Property model (§3.3) — let a channel hold a constant value, not just a field; one consistent control. Capability gain; medium. Pairs with the §2.1 control-promotion shortlist (colour scheme, zero/log, axis/legend titles).
  6. Inline expression validation + field autocomplete (§3.6, §3.10) — record the parseExpr technique in architecture/08; apply when the Filter/Calculate expression mode (#3) lands. Build with the feature.
  7. Field shelf + in-place type chip (§3.4, §3.5) — design as the Tier-C / faceting + added-channels substrate (§2.1: shape/opacity/theta/facets), not standalone. Largest; with Tier C.
  8. Starter examples (§3.7) — curated seed snippets, one per covered FT intent. Cheap, optional.
  9. Scale/axis override panels + builder undo (§3.9, §3.11) — only alongside control promotion; override-panels-that-emit-only-when-touched, with orphan cleanup. Deferred polish.

Verification for each lands the usual way: pure rules get chart-builder.test.ts cases; UI/affordance changes get a manual pass against the live builder (a green build proves nothing about what the user sees — AGENTS.md). Items 1 and 3 are mostly src/core/ and squarely fit our "core-first, tested hardest" rule.


Citations are to reference/lyra/src/js/… (cloned, branch lyra2019). Lyra and Voyager are both UW IDL; Voyager is already seated in chart-builder-research.md, so this review focuses on the no-code-authoring ideas Voyager doesn't cover. Lyra is an engineering/interaction source, not a council seat.