FAQ / Gotchas
Common surprises and how to handle them.
Aliases for clashing names
A few exports have aliases so you can avoid clashes with locals (especially when working inside HTMXObjects structs that often have a data field):
| Canonical | Alias | When to reach for the alias |
|---|---|---|
data | vdata | Your struct/closure has a field named data |
to_node | vdraw | You want a draw-shaped name without clashing with AoG's draw (which works on Makie figures, not VegaSpec) |
vlspec | — | Use to construct a raw Vega-Lite spec wrapper directly (no AoG needed) |
Both aliases call straight through to the canonical implementation — no behavioural difference.
AoG's draw does not work on VegaSpec AlgebraOfGraphics's draw is for Makie figures and will error on a VegaSpec. Use to_node(spec) (or its alias vdraw(spec)) instead, or rely on the Base.show MIME hooks. :::
Select series from the color legend
Click a categorical color legend entry to highlight its series. Other groups dim to 15% of their usual opacity; the data and axis ranges stay in place. Shift-click adds another group, and clicking blank plot space clears the selection.
This works with layer-local color encodings, facets, uncertainty bands and intervals, and per-column scale layouts, in live plots and standalone HTML. Layers and facets sharing a color field share one selection. Layers that color by different fields share one color scale, so Vega-Lite draws one merged legend for them (titled e.g. "Vessel, Observation"); each of its entries highlights the marks colored by that entry, whichever field they color by. An explicit independent color scale (resolve) draws a legend per layer, and each of those selects its own field. The channel picker rebuilds the selection when color changes.
interactive=false disables automatic interaction. Explicit Vega-Lite params remain authoritative, as do conditional or data-driven opacity encodings. Continuous color scales, hidden legends, and Vega-Lite composite marks do not receive automatic legend selections.
Light and dark pages
Plots follow the page's light/dark choice. The runtime draws axes, legends, facet headers, titles and unencoded text in the CSS color the plot element inherits, on a transparent background, so a plot inside a dark Pico page (or a dark card) reads as part of it. Palettes and literal mark colours stay fixed. For a fixed line or point that should use the page's foreground ink, write visual(Lines; color=:ink) (or use another mark type, a ribbon — lineribbon(...) * visual(; color=:ink) — or an interval analysis: pointinterval() * visual(; color=:ink)). The same spec uses the active Makie theme's text colour with sdraw (where the analysis has a static lowering); PNG/SVG browser downloads use the page's colour and background. When the page switches theme (prefers-color-scheme, or a class / data-theme / style change on <html> or <body>) the plots re-render in place, keeping the channel-picker assignment, update_spec data and streamed rows. After switching a theme some other way (a class on a wrapper element), call AoV.refreshTheme().
Do not style the chrome with
currentColorinconfig(config=...): Vega's canvas renderer paintscurrentColorblack. A spec's ownconfigvalues still win over the theme, key by key.Do not invert the canvas with a CSS
filter— it recolours the palette too.PNG/SVG downloads of a plot are painted on the page background behind it.
vega_head(theme=:none)(page) orconfig(theme=:none)(one plot) keeps Vega's own look: white background, black text.
Faceting + select= interactions
config(select=:origin) adds a client-side dropdown that filters the data frame before it reaches any layer. When you also use faceting (row= / col= channels), the filter applies to all facets — there's no per-facet dropdown. If you want per-facet interaction, use mapping_controls + auto_remap_node instead.
Animate over a column (frame=)
config(frame=:year) shows one value of a column at a time, with a play/pause button, a slider and the current value under the plot. Play steps through the values in order and stops on the last one.
data(anomalies) * mapping(:land; color=:anomaly) * geoshape("geo/laender.geojson") *
config(frame=(; field=:year, interval=0.3, loop=false))Rows whose frame value is missing, and layers without the frame column (a base map, a reference line), are drawn in every frame.
Colour, size and x/y scales keep one domain across all frames, so a colour means the same value in every frame and axes do not jump. A map keeps one projection fit. Stacked bars and Vega-Lite transforms such as unfaceted
density()orlinear()are recomputed per frame, still on the shared axes.Frames play in sorted order, or in a
sorterorder:frame=:month => sorter("Jan", "Feb", …). Options:interval(seconds per frame, default 0.5),loop(defaultfalse) andstart(:first,:lastor a frame value).It works in live pages and in static exports (
to_html, the "⬇ HTML" download), wherever AoV's runtime is on the page. The controls' words come fromplot_texts(play,pause).The page can drive a player through
AoV.setFrame(id, index),AoV.playFrames(id)andAoV.pauseFrames(id).Boundaries:
sdrawrefuses frames. Summaries AoV computes in Julia from raw draws (pointinterval(),lineribbon(), faceted or ridgelinedensity()) cannot step through frames: summarise per frame yourself and use thebands=form. Refresh a framed plot withupdate_spec;append_dataand the other raw-row operations are refused. In a facet with a single layer, a panel with no rows in the shown frame is not drawn in that frame. Independent facet scales (linkyaxes=:none, per-column Y scales) fit each panel to the shown frame.
Deep-merged encoding
When you pass config(encoding = (; x = (; scale = (; type = "log")))), AoV deep-merges that into the encoding dict it generated from mapping(...). So you can override (or add to) any auto-generated channel without re-specifying it from scratch:
data(df) * mapping(:x, :y) * visual(Scatter) *
config(encoding = (;
x = (; scale = (; type = "log")),
tooltip = [(; field=:x), (; field=:y), (; field=:group)], # extra tooltip fields
))The Dict spelling is equivalent: config(encoding = Dict("x" => Dict("scale" => Dict("type" => "log")))). Keys may be Symbols or Strings, and Symbol values such as field=:x become the strings Vega-Lite reads. Raw config(config = (; …)) is deep-merged the same way into AoV's default Vega-Lite config. Any other value (a string, nothing, …) is rejected by config(...) with an ArgumentError.
If the override doesn't take effect, the most likely cause is a typo in a channel or property name — names must match Vega-Lite's exactly.
When to use pregrouped()
AoG's pregrouped(xs, ys, ...) is the right hook when your data is already in nested-vector shape (one element per group). AoV passes through to AoG's own grouping behaviour. Use it when you have e.g. a Vector{Vector{Float64}} of curves and don't want to flatten + tag with a group column first.
lineribbon vs ribbon vs precomputed bands
| You have | Use |
|---|---|
| Long-format draws (one row per draw × x) | lineribbon — computes intervals from draws |
| Long-format draws + you want only the band (no centre line) | ribbon |
Precomputed lo/hi columns | lineribbon(...; bands=[:lo50 => :hi50, :lo95 => :hi95]) (or ribbon(...; bands=…)) — bands is a vector of lo => hi Pairs of column names; skips the draw-based estimator |
All three take a fixed colour the way a plain mark does — lineribbon(bands=…) * visual(; color="black") (or color=:ink) colours every band and the median line. A color= mapping wins over it; other visual attributes are not applied to ribbons and log a warning.
The interval analyses take a fixed colour the same way: pointinterval() * visual(; color="black") (also bands=, gradient_interval() and dotinterval()) colours every interval rule — and dotinterval's dots — in place of the per-category palette colour, while the median point stays white with a dark ring. A color= mapping wins here too.
See the API Reference for the full docstring set and the Gallery for worked examples.
CDN versions and offline rendering
vega_head() injects tags for the exact-pinned trio in vega_cdn_urls() (a Vector of three URL strings — Vega, Vega-Lite, Vega-Embed in that order), with subresource-integrity hashes (vega_sri_hashes()), so a CDN-side release can never change rendering with zero repo diff. Override vega_version= / vegalite_version= / vega_embed_version= only with source=:cdn (a custom version opts out of SRI, since the hash would not match).
For offline use, AoV vendors the same builds under vendor/: vega_head(; source=:vendor, base="/vendor") emits same-origin tags (serve vega_vendor_dir() at base from your app), and source=:inline inlines the bytes. to_html(...; source=:inline) writes a standalone file that renders with no network at all.
AoV's own runtime (aov-runtime.js) and stylesheet (aov.css), about 96 KB, are inlined into every page by default. With the vendor mount, vega_head(; source=:vendor, base="/vendor", runtime=:linked) loads them from that directory too, so each full page carries a few hundred bytes of tags that the browser caches. Every vendor URL ends in ?v=<content hash>, which changes exactly when the file does, so the mount may use a far-future cache lifetime — with HTMXObjects: staticfiles(vega_vendor_dir(), "vendor"; headers=["Cache-Control" => "public, max-age=31536000, immutable"]). Page settings (zoom, max_width, actions, theme) stay a small inline script. A captioned plot's "⬇ HTML" download inlines the vendored files the page loaded, so the saved card still renders with no server and no CDN.
"My spec doesn't render and I get no error"
Vega-Lite is forgiving — it'll silently render a blank chart for a malformed spec. Two debugging moves:
println(to_json(spec))— inspect the actual Vega-Lite JSON being emitted.Open browser DevTools, paste the JSON into the Vega Editor — it produces actionable errors in a way the embed runtime doesn't.
HTMX + update_data doesn't refresh
update_data(id, new_rows) works only when the rendered spec was given the same id. The id is a kwarg to to_node, not config:
to_node(spec; id="my-plot-1") # render with stable id
update_data("my-plot-1", new_rows) # later, push new rows from a route handlerIf you don't set an explicit id, AoV assigns one automatically — but the update handler can't guess it. Always set an explicit id for any spec you intend to update from a server endpoint.