Responsive Graphics

Responsiveness is table stakes.

In today’s era, you never know what device someone will be using to view your page or product, or how they set their browser zoom. It’s important to make sure your graphics look good and work well on all devices, which the previous example does not. In order to do this, I usually turn to the ResizeObserver API to detect changes in the size of the container element, and then update the graphics accordingly. This is a powerful API that allows you to detect changes in the size of an element, and respond to those changes in a performant way. In this article, I’ll show you one way to use the ResizeObserver API and D3 Scales to create responsive graphics that work well on all devices.

open full screen and resize to try it out!

source code

The Problem Statement

I like to phrase problems as user stories like this:

As a developer of interactive data visualizations, I want to be able to use the latest width and height of the visualization container, so that I can develop rendering logic that is responsive to resize and works well on mobile.

How can we do that? Well, in broad strokes, we can use ResizeObserver to get notified of changes in width and height, then use setState to set state.width and state.height when those things change. In general, the idea is to derive a definition of width and height that downstream code can use, such that it’s always accurate, and triggers a re-render when the size of the container changes.

Observing Dimensions

Let’s observe dimensions by defining a new function observeDimensions! We need to do some housekeeping to get there, relative to the code as it was in the previous example, which we’re building on top of here. Namelu:

  • Modify renderSVG so it doesn’t measure the dimensions
  • Set up the main function to orchestrate the flow of measured dimensions

renderSVG.js

import { select } from "d3";

export const renderSVG = (container, { width, height }) =>
  select(container)
    .selectAll("svg")
    .data([null])
    .join("svg")
    .attr("width", width)
    .attr("height", height)
    .style("background", "#F0FFF4");

Firstly, we modify our renderSVG function to accept width and height passed in from the outside rather than measure it internally (as it did previously). This makes the function more “dumb” and shifts the responsibility of knowing the dimensions to the caller, namely main defined in index.js, paving the way for main to use the new observeDimensions function.

index.js

import { data } from "./data.js";
import { observeDimensions } from "./observeDimensions.js";
import { renderSVG } from "./renderSVG.js";
import { renderCircles } from "./renderCircles.js";
import { clickableCircles } from "./clickableCircles.js";

export const main = (container, { state, setState }) => {
  const dimensions = observeDimensions(container, { state, setState });
  if (!dimensions) return;
  const { width, height } = dimensions;
  const svg = renderSVG(container, { width, height });
  const circles = renderCircles(svg, { data, width, height });
  clickableCircles(circles, { data, state, setState, dimensions });
};

Here, we invoke observeDimensions, which is responsible for:

  • Setting up the ResizeObserver only once
  • Ensuring the application re-renders when resized
  • Returning the measured dimensions

In our wacky and wonderful world of “unidirectional data flow”, we can use the early return pattern to bail out of the control flow when downstream code needs to wait for something. In this case, observeDimensions may return null, which signifies that downstream code should hold off on running for the time being. This is a pattern that makes it “safe” for functions to synchronously invoke setState without needing to worry about potential extra renders or infinite loops.

Additionally, we then change around how renderCircles and clickableCircles relate to one another, calling them both from main. This allows us to make it very clear which functions depend on what values. We now are passing width and height into renderCircles so that the circle positions can be derived from the measured dimensions. We then pass the circles selection into clickableCircles, which remains pretty much the same as it was before.

observeDimensions.js

export const observeDimensions = (container, { state, setState }) => {
  if (!state.dimensions) {
    new ResizeObserver(() => {
      const dimensions = {
        width: container.clientWidth,
        height: container.clientHeight,
      };
      setState((state) => ({ ...state, dimensions }));
    }).observe(container);
    return null;
  }
  return state.dimensions;
};

Here’s the main character: the observeDimensions function. This function is responsible for setting up the resize observer. Since our main function is idempotent, meaning it can run multiple times, we want to only set up the resize observer on the first render. We can know that it’s the first render by checking if state.dimensions is defined. If state.dimensions is not defined, then we know it’s the first render and we need to set up the resize observer.

Setting up the resize observer involves constructing a new ResizeObserver instance with a callback function, then calling .observe(container) to attach it to the given DOM element. The callback function is invoked once immediately, and again and again each time the container is resized. In order to handle the immediate invocation case, the return value from observeDimensions is null at the time the observer is set up. This is a signal to the caller to return early, which avoids extra renders.

The callback function itself does two things: measure the dimensions, and update the state. Measuring the dimensions could in theory be done by accessing the confusing entries data structure passed into it which is documented in the MDN docs). However, I figure it’s a lot simpler to use clientWidth and clientHeight on the DOM element, since we already have access to that and it works well for giving up-to-date dimensions.

Updating the state in the callback function assigns a new value to state.dimensions using the functional update pattern. Note that in this specific example, it’s important to use ...state to copy over the other state fields to the new state because otherwise we would lose track of state.selectedDatum on every resize.

Dynamic Circle Positioning

The circles themselves should be re-positioned to fit nicely within the container. We can do this using D3 Scales.

data.js

export const data = [
  { x: 155, y: 382, r: 20, fill: "#D4089D" },
  { x: 340, y: 238, r: 52, fill: "#FF0AAE" },
  { x: 531, y: 59, r: 20, fill: "#00FF88" },
  { x: 482, y: 275, r: 147, fill: "#7300FF" },
  { x: 781, y: 303, r: 61, fill: "#0FFB33" },
  { x: 668, y: 229, r: 64, fill: "#D400FF" },
  { x: 316, y: 396, r: 85, fill: "#0FF0FF" },
];

The coordinates for the circles defined within data.js are hardcoded. These coordinates were optimized for the viz dimensions of { width: 960, height: 500 }, as was the default in the venerable and now defunct bl.ocks.org (but still visible in the open source clone blocks.roadtolarissa.com) and is currently the default in VizHub, a playground for dataviz development and learning. In order to re-position the circles to fit nicely within the container, let’s leverage D3 Scales, namely scaleLinear which performs linear_interpolation.

renderCircles.js

import { scaleLinear } from "d3";
const xScale = scaleLinear().domain([0, 960]);
const yScale = scaleLinear().domain([0, 500]);
export const renderCircles = (svg, { data, width, height }) => {
  xScale.range([0, width]);
  yScale.range([0, height]);

  return svg
    .selectAll("circle")
    .data(data)
    .join("circle")
    .attr("cx", (d) => xScale(d.x))
    .attr("cy", (d) => yScale(d.y))
    .attr("r", (d) => d.r)
    .attr("fill", (d) => d.fill)
    .attr("opacity", 700 / 1000);
};

Here we define two instances of scaleLinear: xScale and yScale. Both have a fixed domain, which is the space of data values passed in. The range represents the coordinate space to project into, which is set dynamically based on the passed in width and height. When the cx and xy attributes are set, we pass the raw data values through the scales as functions, which projects them from the domain to the range using linear interpolation.

See also Svelte + D3: Scales and responsive visualizations, an excellent tutorial by Dr. Matthias Stahl that inspired the design of this example.

clickableCircles.js

import { renderCircles } from "./renderCircles.js";
export const clickableCircles = (circles, { data, state, setState }) => {
  circles
    .attr("cursor", "pointer")
    .on("click", (event, selectedDatum) => {
      setState((state) => ({ ...state, selectedDatum }));
    })
    .attr("stroke", "none")
    .filter((d) => d === state.selectedDatum)
    .attr("stroke", "black")
    .attr("stroke-width", 5)
    .raise();
};

Furthermore, note that the clickableCircles function remains mostly the same as it was in the previous example, just slightly altered so that it accepts the circles selection as input. Now that we have state.dimensions in the mix, it’s absolutely critical that we use the ...state pattern to copy over the other state fields to the new state, otherwise we would lose track of state.dimensions every time we selected a new circle!

Conclusion

To test out the whole setup, you can resize the page and observe that the circles get closer and farther apart, spreading out to fill the container. Verify that clicking to select and resizing are both working at the same time. If you wanted to take it even further, you could scale the radius as well. I’ll leave that as an “exercise for the reader”.

This is just a toy example to demonstrate the concept of responsive graphics using D3 Scales and the ResizeObserver API. In real-world data visualizations, there are many more considerations to take into account, such as axis labels, legends, and tooltips. In general, once you get basic responsiveness working, simply propagating the measured dimensions to your scales is not enough. You’ll need to test for specific use cases and iterate to make it really work for the specific case at hand. The beauty of this approach is that once you have dimensions available in code, you can use them to make all sorts of decisions about how to render your visualization.

In this article, we’ve seen how to use the ResizeObserver API and D3 Scales to create responsive graphics that work well on all devices and respond to resize. We’ve also seen how to structure the code in a way that makes it easy to reason about and maintain. This lays the foundation for future examples that use more advanced types of responsive design for visualizations such as density-based tick marks and dynamic label alternatives based on size. I hope you found this article helpful, and I look forward to seeing what you build with it!

Full code listing

setup.js

import { main } from "./index.js";
const container = document.getElementById("viz-container");

let state = {};

const setState = (next) => {
  state = next(state);
  render();
};

const render = () => {
  main(container, { state, setState });
};

render();

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Clickable Circles</title>
    <link rel="stylesheet" href="styles.css" />
    <script type="importmap">
      { "imports": { "d3": "https://cdn.jsdelivr.net/npm/d3@7.9.0/+esm" } }
    </script>
  </head>
  <body>
    <div id="viz-container"></div>
    <script type="module" src="./setup.js"></script>
  </body>
</html>

styles.css

html,
body {
  margin: 0;
  padding: 0;
  height: 100%;
  overflow: hidden;
}

#viz-container {
  width: 100%;
  height: 100%;
}