In this post I would like to describe how to create Vega-Lite plots that integrate nicely into an HTMX application. A difficulty in the design is that Vega requires asynchronous operations and the newly created element must ensure updates are applied in the correct order. In the following I describe the issue and a potential solution.
The goal is to write a custom element, vegalite-plot, that
takes the plot spec as its text content and renders the plot. With this element,
embedding a plot into a page is as simple as
<vegalite-plot id="plot">
{
"$schema": "https://vega.github.io/schema/vega-lite/v5.json",
"data": "..",
"mark": "bar",
"encoding": {
"theta": {"field": "x", "type": "quantitative"},
"color": {"field": "y", "type": "nominal"}
}
}
</vegalite-plot>
The basic strategy will be
- to watch for changes to the text content via a MutationObserver, and
- to call
vegaEmbedto update the plot for each change
One complication is that Vega allows to reference external resources in the
plots, e.g., CSV files. To support this feature, vegaEmbed uses
an asynchronous API. Therefore, the creation of the plot can take an
indeterminate amount of time and updates to the element will happen in an
unknown order.
Imagine two updates to the element. Each update is characterized by two events:
the modification of the plot spec and the resulting modification of the dom. The
time between these two events is determined by the asynchronous vegaEmbed
operation that takes an unknown amount of time to finish. That means in
principle any of the following order of events may be observed:
- SpecModification1, DomModification1, SpecModification2, DomModification2
- SpecModification1, SpecModification2, DomModification1, DomModification2
- SpecModification1, SpecModification2, DomModification2, DomModification1
The first two orders are consistent in the sense that the second update wins. The third order would however overwrite the second update and therefore lead to an inconsistent state.
To address this issue, the implementation of vegalite-plot serializes the
updates into a defined update order. Specifically it uses increasing update
counters to ensure that newer updates are never overwritten by older updates.
Any DOM modification that would overwrite a newer update is simply ignored.
The core logic utilizes two counters: _specUpdate which is the last update of
the text content and _domUpdate which is the last update applied to the dom.
With the these two counters the core logic reads
update() {
const specUpdate = ++this._specUpdate;
const source = this.textContent.trim();
(async () => {
// this operation takes an unknown time to finish
const [el, view, cleanup] = await this.createPlot(source);
// if no newer updates were performed by now
if(specUpdate >= this._domUpdate) {
// attach the new plot
this._domUpdate = specUpdate;
this.swapPlot(el, view, cleanup);
} else {
// otherwise ignore the update and cleanup
cleanup();
}
})();
}
See the full implementation for further details and here for a complete example with HTMX. A simple test suite is found here.