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.

Lang: js
 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.

Lang: js
 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.

Lang: js
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.