Vega-Lite plots with HTMX using custom elements

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

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:

  1. SpecModification1, DomModification1, SpecModification2, DomModification2
  2. SpecModification1, SpecModification2, DomModification1, DomModification2
  3. 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.