Responsive charts: a case study

A very common topic of questions on data visualization forums concerns responsive charts. In this post I will use an example of responsive chart design to illustrate the key issues when confronting this problem, outline the framework of a general solution, and show a bit of general-purpose code. The principles should be applicable to any frontend framework (React, Vue, Svelte). I'll use d3 throughout, but no prior d3 expertise is necessary. I will also use TypeScript, because it more clearly illustrates what data we are working with, but JavaScript should work just as well. Finally, although this particular chart is rendered in HTML5 canvas, the same principles would be applicable to a chart built with SVG.

Basic familiarity with JavaScript, how to draw with canvas, and DOM event handlers are needed.

What we'll be building

The inspiration for this post came from this question on Observable Talk. A user named hiroyukikumazawa asked how to build a responsive chart with a mouse-hover tooltip and linked to this example. The chart in question shows Bitcoin price tracked over the last ~24hrs and has several features that are interesting from a web developer's perspective:

Here is my attempt at reverse-engineering what they did:

px

It doesn't do everything, but it does illustrate the responsive features I'm going to talk about here (and adds/fixes a few more). I wrote it in Deno/Fresh/Preact (the stack for this website), but the "pure functions", and the d3 bits I will show below can be applied to any framework. Their chart is based on canvas, so I'll stick to that, as well. The full solution is available here.

What do we mean by "responsive", anyway?

Charts present a particular challenge for responsive design because they do not fit neatly into the categories of other element types that we are used to dealing with. Simple text can reflow to fit its container. Raster images can resize to maintain a consistent aspect ratio (though even so, HTML5 gives us mechanisms to load different images for different devices). Charts are not just images: maintaining a constant aspect ratio almost never works well outside of the context in which it was designed, while text quickly becomes illegible as it shrinks down to mobile sizes. (This is most easily observed for charts that are displayed as raster images, but SVGs don't improve the situation much, without extensive cajoling).

How can we scale the bits we need to? The answer is to add a layer of indirection between the "data scale" and the actual pixels that we draw on the canvas. In the example, the heights (and vertical positions) of each box are given as properties of the data themselves, and must be scaled prior to drawing. Other dimensions (such as the width of each box), are detemined after scaling, and are drawn without reference to the data. I will refer to these two different layers as data space and pixel space throughout.

Obtaining and processing the raw data

If I had more time, I would figure out how to use the Coin Market Cap API to show up-to-date Bitcoin prices. However, large parts of their API don't seem to work as advertised (at least on the free tier). So, the chart data show the data for the date I downloaded the data: Dec 13, 2023.

The data can be downloaded as a CSV via the button to the top-right of the chart. The CSV data are in one hour intervals, instead of 15 min intervals as in the original chart. Therefore, my chart has sparser data points than the original.

Furthermore, although the file has a .csv extension, the data are actually "semicolon-separated", not comma separated. They can be imported into JavaScript with d3's handy dsv package:

import { dsvFormat } from "https://cdn.skypack.dev/d3-dsv@3";
const { parse } = dsvFormat(";");
const data = parse(text);

The data are objects consisting of a timestamp and a value for high, low, open, and close prices for each hour. (There's some other values in there, too, but we'll ignore those for this charting project):

interface Row {
    timestamp: string;
    open: number;
    close: number;
    high: number;
    low: number;
}

D3 also has several modules for working with and plotting datetimes, but for reasons which should become clear below, I find it easier to just convert these to simple numbers: aka UTC timecodes. It is useful to define a function that extracts a numeric x value from a data row:

import { datetime } from "https://deno.land/x/ptera@v1.0.2";
import { dateToTS } from "https://deno.land/x/ptera@v1.0.2/convert.ts";

const xValue = (row: Row): number => dateToTS(datetime(row.timestamp));

Getting the space available

Usually, responsive design depends on media queries that apply different styles depending on the width of the overall viewport. In our case, it is more useful to respond to the width of the chart itself. (In principle, this is related to the CSS container query, but as our charts are based on TypeScript we'll get the container width and height there):

const pxWidth = el.clientWidth;
const pxHeight = el.clientHeight;

where el is the canvas (chart) element.

Data space and pixel space

Having determined our pixel space, we'll now turn our attention to the "data space" that make up the Cartesian plane upon which we usually think about graphs. We need to know the minimum and maximum values we'll have to worry about for both the x- and y-axis. Since the data starts out sorted by timestamp (x-axis) values, the minimum and maximum are just defined by the first and last values:

const xMin = xValue(data[0]);
const xMax = xValue(data[data.length - 1]);

The y-axis is more complicated. To simplify things, we'll use the same scale for both the line and box plot, which means the lowest possible y-value in the series is given by:

let yMin = Math.min(...data.map((p) => p.low));

And the highest value by:

let yMax = Math.max(...data.map((p) => p.high));

We'll give that some breathing room by "zooming out" by 20%:

const Y_ZOOM = 1.2;

const yLength = (yMax - yMin) * Y_ZOOM;
yMin = yMin - (yLength - yMax + yMin) / 2;
yMax = yMin + yLength;

We should also make sure the chart has sufficient "padding" to ensure the axes have enough space regardless of the overall width. Let's give the following padding values in pixels:

const PAD_LEFT = 40;
const PAD_RIGHT = 65;
const PAD_BOTTOM = 50;

Now we can define a function for interconverting between data space and pixel space. D3 provides a scaleLinear facade which creates a function to return any value on a given "domain" (in our case, data space) to a given "range" (in our case, pixel space). Our overall scaling function for the x-axis looks like this:

import { scaleLinear } from "https://cdn.skypack.dev/d3-scale@3";

const scaleX = (dataValue: number): number => 
    scaleLinear()
        .domain([xMin, xMax])
        .range([PAD_LEFT, pxWidth - PAD_RIGHT]);

The y-axis is similar, but also reverses the up/down direction. We do this by putting the desired range in backwards:

const scaleY = (dataValue: number): number =>
    scaleLinear()
        .domain([yMin, yMax])
        .range([pxHeight - PAD_BOTTOM, 0]);

These two functions together can be used to take any data point and convert it to "pixel space" for plotting. Plotting the actual line in our line chart, for example, depends on converting them first and then using d3-shape to do the actual drawing:

import { line as d3Line } from "https://cdn.skypack.dev/d3-shape@3";

const path: Array<[number, number]> = rows.map((row: Row) => [
    scaleX(xValue(row)),
    scaleY(row.close),
]);

d3Line().context(ctx)(path);

ctx.stroke();

where ctx is the canvas context.

There are some more details here, like how to draw a line that changes color depending on its value. However, I want to stay focused on how to implement responsive features, so I will skip over that.

Responsive box width

The width of each data box scales so that the boxes fill the available space at any given width, without overlapping each other (in fact, there are gaps in between them). Here we see how to use data space and pixel space together. The top and bottom of each box are determined by the data:

const drawBox = (datapoint: Row) => {
    const y0 = scaleY(datapoint.open);
    const y1 = scaleY(datapoint.close);    
}

However, the box width can be determined by the actual width of the chart in pixel space:

const BOX_GAP = 10;

const barWidth = (pxWidth / data.length) - BOX_GAP;

Drawing the boxes is given by combining both of these concepts of "space":

const BOX_SPACING = 10;

const boxPath = (datapoint: Row) => {
    const y0 = scaleY(datapoint.open);
    const y1 = scaleY(datapoint.close);
    const x = scaleX(xValue(datapoint));
    
    const barWidth = (pxWidth / data.length) - BOX_SPACING;
    const halfWidth = barWidth / 2;
    const path = [
        [x - halfWidth, y1],
        [x + halfWidth, y1],
        [x + halfWidth, y0],
        [x - halfWidth, y0],
        [x - halfWidth, y1], // close the path
    ];
};

This path can be passed to d3Line to draw boxes.

Placing ticks on the y-axis

Our y-axis is not responsive. We have 300px of height to deal with, regardless of the width. However, we don't (in theory) know the range of values that will be charted and therefore can't determine a priori what ticks to draw.

D3 makes this quite easy, though the relevant function is hidden within the array sub-package. d3.array.ticks does the following according to the documentation: "Returns an array of approximately count + 1 uniformly-spaced, nicely-rounded values between start and stop (inclusive)". I love the words "approximate" and "nicely" in this description. This isn't a situation where we are looking for total predictability: we need a bunch of ticks that fit within the range of our axis, and they should be round numbers, within whatever range we are talking about.

The first two arguments to this function are the minimum and maximum values of the axis, the third is the approximate number of ticks we want. The original chart had about ten:

import { ticks } from "https://cdn.skypack.dev/d3-array@3";

const yTicks = ticks(yMin, yMax, 10);

What about the tick label? Let's define a function that takes a y value as argument and returns the label:

const formatYTick = (position: number) => `${(position / 1000).toFixed(2)}K`;

Responsive x-axis ticks

The x-axis shrinks as the graph container shrinks, and it has a couple nice features that makes it legible on small screens.

Combining these two features is a challenge: we need to decide which labels to hide, but the midnight/new day timepoint should always be shown (or else the user will lose track of which day it is).

It's also important to keep in mind that, so far, we have converted ISO datetimes (which are all in GMT), to UTC numbers for plotting. The labels should represent local time, including showing the new day of the month at midnight, local.

Let's "thin out" our tick marks, by defining a parameters skip that takes every 2nd, 3rd, or nth tick mark, counting midnight as 0:

const xTicks = (skip: number) => (rows: Row): number[] =>
    rows.map(row => datetime(xValue(row)))
        .filter(dt => dt.hour % skip)
        .map(dt => dateToTS(dt));

Calling xTicks(2) will create a function that returns only even hours: midnight, 2:00 am, 4:00 am, 6:00 am, etc.. We have to play with the chart a little bit to see what skip value is appropriate at each chart width. I came up with:

const skip = width < 600 ? 6 : width < 992 ? 3 : 2;

Although we've used the datetime constructor a couple of times already, I still think it's simplest to think of tick formatting as a pure function that produces a label from the position of the tick mark:

const formatXLabel = (position: number) => {
    const dt = datetime(position);
    return formatDate(dt, dt.hours === 0 ? "d" : "hh:mm a")
}

The midnight/day of month label is also shown in bold. I'm going to skip over that implementation, as it again depends on the interface with canvas (or the DOM). In principle, it uses the same strategy.

Reminder: All of this occurs in data space. These values need to be converted to pixel space for drawing.

Using reverse scaling to get tooltip data

So far, we've been using d3's scale function to convert data values to pixels. When the user hovers over the chart, we want to perform this function in reverse, and convert the mouse position back to data so that we can select the correct data point. Fortunately, d3 allows us to easily reverse our scaling function.

Firstly, we need to determine the mouse position in pixels. We can construct an event handler like this:

const handleMouseMove = (ev: MouseEvent) => {
    const el = ev.target as HTMLCanvasElement;
    const offsets = el.getBoundingClientRect();
    const x = scaleX.invert(ev.clientX - offsets.x);
    const y = scaleY.invert(ev.clientY - offsets.y);
}

Then, we can round this to the nearest discrete x-value by stepping through our data series and finding the first data point that has a lower x value than the pointer position:

const handleMouseMove = (ev: MouseEvent) => {
    const el = ev.target as HTMLCanvasElement;
    const offsets = el.getBoundingClientRect();
    const x = scaleX.invert(ev.clientX - offsets.x);
    const y = scaleY.invert(ev.clientY - offsets.y);
    
    const indexToRight = data.findIndex((p) =>
        xValue(p) > x
    );
    if (indexToRight === 0) return cb(data[0]);
    if (indexToRight === -1) return cb(data[data.length - 1]);
    
    const dToRight = Math.abs(
        x - xValue(data[indexToRight]),
    );
    const dToLeft = Math.abs(
        x - xValue(data[indexToRight - 1]),
    );
    if (dToRight < dToLeft) {
        return cb(data[indexToRight]);
    }
    return cb(data[indexToRight - 1]);
}

Here, we assume that cb will handle setting some state with our tooltip data. The tooltips themselves are HTML, not canvas drawing, and so they are best handled in React or whatever framework is used for rendering. To position them, we surround our canvas element with a relatively-positioned div and render the tooltip with absolute positioning.

Final remarks

My intention here has been to highlight "pure functions" that can be used to implement responsive chart design. I've avoided going in the details of how to tie this to an SVG or canvas implementation because that depends quite a bit on how the website in question is built.

The full code is available here, however, if you'd like more detail. It is important to note that responsive design is about more than more than "making it work on mobile" - the goal is to provide the best experience we can across a wide range of device capabilities. By separating the concerns of content (data) and presentation (pixels), we can create better data-vis experiences for more users.

Like this post? Start a conversation, browse my other writing, or view my portfolio.