Interactive D3.js charts with HTMX
An example of how to use HTMX inside of a D3.js chart.
In late 2022 I started working on a new data visualization at the day job. It was my first foray into D3.js, which I had been looking for an excuse to play with for a while. I was also having a lot of fun with HTMX, using it to load partial page templates in a cascading fashion. Cascading HTML sheets, if you will. These explorations led to a really nifty visualization, a timeline that used HTMX to load in charts of a specific occurence when you clicked on it.
The first element of this visualization is a timeline of events (aftertreatment regenerations on a diesel engine). These event are categorized into six different categories with an additional “uncategorized” category. So we have a y axis for categories and an x axis for dates.
1const y = d3.map(data, d => {
2 if (d.regen_category == '') {
3 return 'uncategorized';
4 } else {
5 return d.regen_category;
6 }
7});
8const yDomain = categories.map(d => d[0]);
9const yDisplay = categories.map(d => d[1]);
10const yScale = d3.scaleBand()
11 .domain(yDomain)
12 .rangeRound([margin.top, height - margin.bottom])
13 .paddingInner(0.3)
14 .paddingOuter(0.2)
15 .align(0);
16// The colorScale is used to color code the different categories
17const colorScale = d3.scaleOrdinal()
18 .domain(yDomain)
19 .range(categoryColors.map(d => d[1]));
20
21const x = d3.map(data, d => new Date(d.start_date));
22const xDomain = [Date.now() - 12 * 24 * 60 * 60 * 1000, Date.now()];
23const xScale = d3.scaleTime()
24 .domain(xDomain)
25 .range([margin.left, width - margin.right]);
During development, I noticed that sometimes there would be events that overlapped in the timeline. To keep the individual events clickable, I made the timeline zoomable on the x axis only.
1// Add scaleable axis
2const gx = svg.append("g");
3
4// z holds a copy of the previous transform, so we can track its changes
5let z = d3.zoomIdentity;
6
7// set up the ancillary zooms and an accossor for their transforms
8const zoomX = d3.zoom().scaleExtent([1, 5]);
9const tx = () => d3.zoomTransform(gx.node());
10gx.call(zoomX).attr("pointer-events", "none");
11
12// active zooming
13const zoom = d3.zoom().on("zoom", function(e) {
14 const t = e.transform;
15 const k = t.k / z.k;
16 const point = center(e, this);
17
18 // is it on an axis?
19 const doX = point[0] > xScale.range()[0];
20
21 if (k === 1) {
22 // pure translation?
23 doX && gx.call(zoomX.translateBy, (t.x - z.x) / tx().k, 0);
24 } else {
25 // if not, we're zooming on a fixed point
26 doX && gx.call(zoomX.scaleBy, 1 / k)
27 }
28 z = t;
29 redraw();
30});
31
32// Zoom helper functions
33// Find the center of the pointer(s), handles multitouch
34function center(event, target) {
35 if (event.sourceEvent) {
36 const p = d3.pointers(event, target);
37 return [d3.mean(p, d => d[0]), d3.mean(p, d => d[1])];
38 }
39 return [width / 2, height / 2];
40}
41
42function redraw() {
43 const xRescaled = tx().rescaleX(xScale);
44 gx.call(xAxis, xRescaled);
45 regens.attr("cx", i => xRescaled(X[i]));
46}
47
48// Add zoom to svg
49svg.call(zoom)
50 .call(zoom.transform, d3.zoomIdentity.scale(0.8))
51 .node();
Each event has it’s own chart which is loaded below the timeline whenever an event is clicked. HTMX is used to handle this request and load a partial template.
1function regenClicked(event) {
2 const regenId = event.target.attributes.title.value;
3 htmx.ajax('GET', `/regen-chart/${regenId}/`, '#regen-chart');
4}
This visualization was a lot of fun to build.