DEV Community

Cover image for How to create a screen reader accessible graph like Apple's with D3.js
Sarah
Sarah

Posted on • Originally published at fossheim.io

How to create a screen reader accessible graph like Apple's with D3.js

Originally posted on fossheim.io.

After previously writing about the accessibility of Apple Health’s data visualizations, I felt inspired to recreate one of them with D3.js. I already covered some of the basics in the form of a bar chart, so this time I decided to go for a different type of graph: the activity rings.

Apple Activity app: small donut charts for activity every day of the week, large donut chart for current day, bar chart for the current day broken down by the hour

Before we start

While we will build the graph together step by step, this tutorial does require some previous knowledge or experience with D3.js. If you haven’t used D3 before, I suggest starting with some of these tutorials:

Part 1: Drawing the rings.

First, we’ll need to add a container in the HTML, and (optionally) style the page with CSS already. Next, we’ll draw an SVG element using JavaScript:

/* Define properties */
const width = 450;
const height = 450;
const margin = 40;

/* Add SVG inside <div id="activity"></div> */
const chart = d3.select('#activity').append('svg')
  .attr('width', width)
  .attr('height', height);
Enter fullscreen mode Exit fullscreen mode

Now that we have an we can start adding elements to it. First, we’ll create a group to draw the rings in, and center it within its parent ().

const rings = chart.append('g')
  .attr('transform', `translate(${width / 2}, ${height / 2})`);
Enter fullscreen mode Exit fullscreen mode

Then we’ll need to draw our three rings for moving, exercising, and standing. For now, we’ll be using the following input data:

const stats = [
 {
    name: 'Moving',
    value: 122,
    goal: 350,
    perc: 0.35,
    unit: 'kcal',
    color: 'hotpink'
  }, {
    name: 'Exercising',
    value: 40,
    goal: 40,
    perc: 1.00,
    unit: 'min',
    color: 'limegreen'
  }, {
    name: 'Standing',
    value: 9,
    goal: 12,
    perc: 0.75,
    unit: 'h',
    color: 'turquoise'
  }
];
Enter fullscreen mode Exit fullscreen mode

There are a few different ways to draw the rings, but I chose to drawpaths in combination with the d3.arc() function by looping through the stats and using the perc (percentage) to define start and stop positioning.

rings.append('path')
    .attr('d', d3.arc()
      .innerRadius(150)
      .outerRadius(200)
      .startAngle(0)
      .endAngle(Math.PI) // full circle: Math.PI * 2
     )
    .attr('fill', 'white');
Enter fullscreen mode Exit fullscreen mode

This would give us half a donut that’s 200px in radius (400px in diameter), has a band width of 50px and a gap of 2px.

When we look back at the activity rings, we can see that each ring should decrease in size, and we should have a small gap between each of the rings.

Concretely, this means that for each row of data, the innerRadius and outerRadius should get smaller.

1st ring: moving: outerRadius: radius, innerRadius: radius - stroke. 2nd ring: exercising: outerRadius: radius - stroke - gap, innerRadius: radius - 2 * stroke - gap. 3rd ring: standing: outerRadius: radius - 2*stroke - 2*gap, innerRadius: radius - 3*stroke - 2*gap.

If we set our radius to (width - margin) / 2 (so it takes up the entire space of the SVG minus a predefined margin) and the stroke/donut width to 50, the first row of data would look like this:

rings.append('path')
    .attr('d', d3.arc()
      .innerRadius((width - margin) / 2 - 50)
      .outerRadius((width - margin) / 2)
      .startAngle(0)
      .endAngle(Math.PI * 2 * 0.35)
     )
    .attr('fill', 'hotpink');
Enter fullscreen mode Exit fullscreen mode

Because Math.PI * 2 gives us a full circle, we can multiply it with the goal completion percentage (stat.perc) to calculate the correct endAngle.

For the second ring, this would have to be:

rings.append('path')
    .attr('d', d3.arc()
      .innerRadius((width - margin) / 2 - 100 - 2)
      .outerRadius((width - margin) / 2 - 50 - 2)
      .startAngle(0)
      .endAngle(Math.PI * 2 * 1)
     )
    .attr('fill', 'limegreen');
Enter fullscreen mode Exit fullscreen mode

Which we can generalize as:

stats.forEach((stat, index) => {
  rings.append('path')
      .attr('d', d3.arc()
        .innerRadius(radius - circleStroke * (index + 1) - circleSpace * index)
        .outerRadius(radius - circleStroke * index - circleSpace * index)
        .startAngle(0)
        .endAngle(Math.PI * 2 * stat.perc)
      )
      .attr('fill', stat.color);
});
Enter fullscreen mode Exit fullscreen mode

Then, we’ll need to add a similar <path> for the darker, uncompleted part of the circle. The only thing we need to do for that is set the startAngle to fullCircle * stat.perc, so that it starts where the bright circle ends and set the endAngle to Math.PI * 2. We’ll also turn down the opacity.

stats.forEach((stat, index) => {
  rings.append('path')
      .attr('d', d3.arc()
        .innerRadius(radius - circleStroke * (index + 1) - circleSpace * index)
        .outerRadius(radius - circleStroke * index - circleSpace * index)
        .startAngle(0)
        .endAngle(Math.PI * 2 * stat.perc)
      )
      .attr('fill', stat.color);

  rings.append('path')
      .attr('d', d3.arc()
        .innerRadius(radius - circleStroke * (index + 1) - circleSpace * index)
        .outerRadius(radius - circleStroke * index - circleSpace * index)
        .startAngle(Math.PI * 2 * stat.perc)
        .endAngle(Math.PI * 2)
      )
      .attr('fill', stat.color)
      .attr('opacity', 0.25);
});
Enter fullscreen mode Exit fullscreen mode

I made a few more modifications to this and moved part of the code into a drawRings function, so I wouldn’t have to repeat the calculations for the inner and outer radius. You can see the full code for this part in the pen below 👇🏻.

If we listen to this with a screen reader, such as VoiceOver or Narrator, we won’t hear much useful. In fact, we won’t hear anything at all. That is because so far we have only drawn shapes, which doesn’t really tell a screen reader what to do.

In my previous tutorial we used elements to read out the data, but for this one I decided to go for another option: the aria-labelledby property in combination with a and element. This is inspired by how FiveThirtyEight labeled their graphs in their 2020 presidential election forecast (I reviewed those graphs before).

We’ll want to:

  1. Set the role of the graph to img.
  2. Include a and inside the SVG, and give each a unique id.
  3. Link the title and description to image by adding aria-labelledby=”titleID descID” to the graph.

If we want to mimic Apple’s native behavior, the completion percentage for all three rings should be read simultaneously. Eg. “Moving: 35%. Exercising: 100%. Standing: 75%“.

To generate this text, we’ll create a function that extracts the label (moving, exercising, standing) and the values (35%, 100%, 75%) from the array with the data and then puts it in a sentence.

const generateDescription = () => {
  return stats.map((stat) => {
    return `${stat.name}: ${stat.perc * 100}%.`;
  }).join(' ');
}
Enter fullscreen mode Exit fullscreen mode

Here we loop through the objects inside the stats array and replace each of them with a string. So after we’re finished looping through the stats, this is our output:

[
  'Moving: 35%.',
  'Exercising: 100%.',
  'Standing: 75%.'
]
Enter fullscreen mode Exit fullscreen mode

Lastly, we’ll use .join(' ') at the end to create one long description, and use the output of the function to fill out the text inside the element.

/* Create the chart. */
const chart = d3.select('#activity').append('svg')
  .attr('width', width)
  .attr('height', height)
  .attr('role', 'img') // SR support
  .attr('aria-labelledby', 'activityTitle activityDesc'); // SR support

/* Add title. */
chart.append('title')
  .text('Activity')
  .attr('id', 'activityTitle');

/* Add the description. */
chart.append('desc')
  .text(generateDescription)
  .attr('id', 'activityDesc');
Enter fullscreen mode Exit fullscreen mode

Alternative: Using aria-label

We can achieve the same result by using aria-label instead of aria-labelledby in combination with the same generateDescription() function.

const chart = d3.select('#activity').append('svg')
  .attr('width', width)
  .attr('height', height)
  .attr('role', 'img') 
  .attr('aria-label', generateDescription());
Enter fullscreen mode Exit fullscreen mode

Part 3: Explaining the data.

So now we have three screen reader accessible rings, but visually those don’t tell us that much yet. Pink, green and blue don’t really mean anything, and don’t work well for color blind folks either.

3 progress circles (activity rings) in pink, green and blue. They have icons for moving, exercising and standing, which are circled.

Let’s start by adding icons. For the sake of simplicity, I didn’t draw or import any icons but used existing symbols as text.

/* Define icons */
const icons = {
  moving: '',
  exercising: '',
  standing: ''
};

/* Inside of stats.forEach(...), 
  at the end of the loop */
rings.append('text')
    .text('icons[stat.name.toLowerCase()]')
    .attr('fill', '#000')
    .attr('transform', `translate(${circleSpace}, -${(arc.outer + arc.inner) / 2 - circleSpace * (index + 2)})`)
    .attr('font-size', '1.5rem');
});
Enter fullscreen mode Exit fullscreen mode

In addition, we should explain what the colors and symbols mean in a legend. Apple combines this explanation with statistics that show the data in a more detailed way.

This doesn’t just add context to the colors of the graph, but also makes the same data available in different formats, which also improves accessibility.

We can implement a simplified version of this by adding elements containing the label, total, goal and percentage values. We’ll also need to add the corresponding icons and colors, and adjust the vertical position for each row.

chart.append('text')
    .text(`${icons[stat.name.toLowerCase()]} ${stat.name}: ${stat.value}/${stat.goal}${stat.unit} (${stat.perc * 100}%)`)
    .attr('text-anchor', 'middle')
    .attr('transform', `translate(${width / 2}, ${radius * 2 + 20 * (index + 2)})`)
    .attr('fill', stat.color);
Enter fullscreen mode Exit fullscreen mode

The text is added directly to the , not to the same group as the rings, so that it can be focused when using VoiceOver.

Right now the icons in the legend will still be read. If we want that to prevent that from happening, we can add the aria-hidden='true' attribute to the icons this way:

const legend = chart.append('text')
    .attr('text-anchor', 'middle')
    .attr('transform', `translate(${width / 2}, ${radius * 2 + 20 * (index + 2)})`)
    .attr('fill', stat.color);

  legend.append('tspan')
      .text(`${icons[stat.name.toLowerCase()]} `)
      .attr('aria-hidden', 'true');

  legend.append('tspan')
    .text(`${stat.name}: ${stat.value}/${stat.goal}${stat.unit} (${stat.perc * 100}%)`);
Enter fullscreen mode Exit fullscreen mode

Alternative: Expanding the aria-label solution

Next steps.

We can keep styling the graph to make it look more similar to Apple’s graphs, or apply our own styling to it. A few possible next steps could be to move the color scheme to the CSS file, replace the icons or add gradients and shadows.

If you’re new to working with D3.js, SVGs or (dataviz) accessibility, here are a few more articles that can help you with this:

Feel free to share the results with me (you can tag me on Twitter or mention it in the comments here) if you build something similar using this tutorial or have a different way of solving this 👀

Bonus solutions:

Different type of input.

Navigate through the activity rings.

Top comments (1)

Collapse
 
ben profile image
Ben Halpern

Cooool!