Lab 6: Visualizing categorical data with D3
In this lab, we will learn:
- What is SVG and what does it look like?
- What does D3 do?
- How can we use D3 to draw a pie chart?
- How can we create reactive visualizations of data that is changing?
- How can we make interactive visualizations that change the data displayed on the page?
- How can we make interactive visualizations accessible?
Table of contents
- Lab 6: Visualizing categorical data with D3
- Check-off
- Questions Doc
- Slides (or lack thereof)
- Step 0: Update project data and add years
- Step 1: Creating a pie chart with D3
- Step 2: Adding a legend
- Step 3: Plotting our actual data
- Step 4: Adding a search for our projects and only visualizing visible projects
- Step 5: Turning the pie into filtering UI for our projects
Check-off
You need to come to TA Office Hours to get checked off for this lab (any of them, no appointment needed). Please fill in the check-off form at labs/6/checkoff
before your check-off. Ideally you should fill in the form right before your check-off, but it’s ok if you fill it out in advance.
Filling out the form is a necessary but not sufficient condition to get checked-off. You still need to come to office hours in person for your check-off to be processed.
You could even fill it out before you finish the lab, since we won’t look at it until your check-off, but the closer to the end of the lab you fill it out, the more meaningful your feedback will be.
Questions Doc
Add questions to the questions doc throughout the lecture and lab! After lab, come to office hours or ask on Discourse for futher questions!
Slides (or lack thereof)
No slides for this lab! Since the topic was covered in Monday’s lecture, it can be helpful for you to review the material from it.
This lab is a little more involved than some of the previous labs, because it’s introducing the core technical material around data visualization. A robust understanding of these concepts will be invaluable as you work on your final projects, so spending time practicing them for the lab will be time will spent.
Step 0: Update project data and add years
If you have not yet done Step 3 of Lab 5, you should do it now.
Step 0.1: Show year in each project
Since we have the year data, we should show it in the project list. That way we can also more easily verify whether our code in the rest of the lab works correctly.
Edit the <Project>
component (in src/lib/Project.svelte
) to show the year of the project. You can use any HTML you deem suitable and style it however you want. I placed it under the project description (you’ll need to wrap both in the ame <div>
otherwise they will occupy the same grid cell and overlap), and styled it like this:
In case you like the above, the font-family is Baskerville
(a system font) and I’m using font-variant-numeric: oldstyle-nums
to make the numbers look a bit more like they belong in the text.
From this point onwards, there are only two files that we will be editing in this lab:
src/lib/Pie.svelte
(created in Step 1.1)src/routes/projects/+page.svelte
(our projects page)
Step 1: Creating a pie chart with D3
Step 1.1: Create a <Pie>
component and use it in your project page
To keep the code manageable, we will be creating our pie chart in a separate component, called <Pie>
.
Create a new Pie.svelte
file in src/lib
and add some text in it, e.g. “Hello from Pie.svelte”. Then, in your projects page, import the <Pie>
component:
import Pie from '$lib/Pie.svelte';
and then use it like <Pie />
. Save, and make sure the text you wrote in Pie.svelte
is displayed in your project page to make sure all plumbing is working.
Step 1.2: Create a circle with SVG
The first step to create a pie chart with D3 is to create an <svg>
element in the <Pie>
component.
D3 is a library that translates high level visualization concepts into low level drawing commands. The technology currently used for these drawing commands is called SVG and is a language for drawing vector graphics. This means that instead of drawing pixels on a screen, SVG draws shapes and lines using descriptions of their geometry (e.g. centerpoints, radius, start and end coordinates, etc.). It looks very much like HTML, with elements delineated by tags, tags delineated by angle brackets, and attributes within those tags. However, instead of content-focused elements like <h1>
and <p>
, we have drawing-focused elements like <circle>
and <path>
. SVG code can live either in separate files (with an .svg
extension) or be embedded in HTML via the <svg>
elements.
We will give it a viewBox
of -50 -50 100 100
which defines the coordinate system it will use internally. In this case, it will have a width and height of 100, and the (0, 0) point will be in the center (which is quite convenient for a pie chart!).
We can use these coordinates to e.g. draw a red circle within it with a center at (0, 0) and a radius of 50 via the SVG <circle>
element:
<svg viewBox="-50 -50 100 100">
<circle cx="0" cy="0" r="50" fill="red" />
</svg>
Since we have not given the graphic any explicit dimensions, by default it will occupy the entire width of its parent container and will have an aspect ratio of 1:1 (as defined by its coordinate system). It will look a bit like this:
We can add some CSS in the component’s <style>
element to limit its size a bit and also add some spacing around it:
svg {
max-width: 20em;
margin-block: 2em;
/* Do not clip shapes outside the viewBox */
overflow: visible;
}
This will make it look like this:
Step 1.3: Using a <path>
instead of a <circle>
A <circle>
element is an easy way to draw a circle, but we can’t really go anywhere from there: it can only draw circles. If we were drawing pie charts directly in SVG, we’d need to switch to another element, that is more complicated, but also more powerful: the <path>
element.
The <path>
element can draw any shape, but its syntax is a little unwieldy. It uses a string of commands to describe the shape, where each command is a single letter followed by a series of numbers that specify command parameters. All of this is stuffed into a single d
attribute.
Here is our circle as a <path>
element:
<svg viewBox="-50 -50 100 100">
<path d="M -50 0 A 50 50 0 0 1 50 0 A 50 50 0 0 1 -50 0" fill="red" />
</svg>
This draws the circle as two arcs, each of which is defined by its start and end points, its radius, and a few flags that control its shape. Before you run away screaming, worry not, because D3 saves us from this chaos by generating the path strings for us. Let’s use it then!
Step 1.3: Drawing our circle path with D3
Now let’s use D3 to create the same path, as a first step towards our pie chart.
First, we need to add D3 to our project so we can use it in our JS code. Open the VS Code terminal and run:
npm install d3
Ignore any warnings about peer dependencies.
So now that D3 is installed how do we use it? In your Pie component, add the following import statement at the top of the <script>
element:
import * as d3 from 'd3';
Now let’s use the d3.arc()
function from the D3 Shape module to create the path for our circle. This works with two parts: first, we create an arc generator which is a function that takes data and returns a path string. We’ll configure it to produce arcs based on a radius of 50
by adding .innerRadius(0).outerRadius(50)
. If you instead want to create a donut chart, it’s as easy as changing the inner radius to something other than 0
!
let arcGenerator = d3.arc().innerRadius(0).outerRadius(50);
We then generate an arc by providing a starting angle (0
) and an ending angle in radians (2 * Math.PI
) to create a full circle:
let arc = arcGenerator({
startAngle: 0,
endAngle: 2 * Math.PI
});
Did we need two statements? Not really, we only did so for readability. This would have been perfectly valid JS:
let arc = d3.arc().innerRadius(0).outerRadius(50)({ startAngle: 0, endAngle: 2 * Math.PI });
Now that we have our path, we can add it to our SVG:
<svg viewBox="-50 -50 100 100">
<path d={arc} fill="red" />
</svg>
Step 1.4: Drawing a static pie chart with D3
’Nuff dilly-dallying with circles, let’s cut to the chase and draw a pie chart! Let’s draw a pie chart with two slices, one for each of the numbers 1
and 2
, i.e. a 33% and 66% slice.
let data = [1, 2];
We’ll draw our pie chart as two <path>
elements, one for each slice. First, we need to calculate the total, so we can then figure out what proportion of the total each slice represents:
let total = 0;
for (let d of data) {
total += d;
}
Then, we calculate the start and end angles for each slice:
let angle = 0;
let arcData = [];
for (let d of data) {
let endAngle = angle + (d / total) * 2 * Math.PI;
arcData.push({ startAngle: angle, endAngle });
angle = endAngle;
}
And now we can finally calculate the actual paths for each of these slices:
let arcs = arcData.map(d => arcGenerator(d));
Now let’s wrap our <path>
element with an {#each}
block since we are now generating multiple paths:
{#each arcs as arc}
<path d={arc} fill="red" />
{/each}
If we reload at this point, all we see is …the same red circle. A bit anticlimactic, isn’t it?
However, if you inspect the circle, you will see it actually consists of two <path>
elements. We just don’t see it, because they’re both the same color!
Let’s assign different colors to our slices, by adding a colors
array and using it to set the fill
attribute of our paths:
let colors = ['gold', 'purple'];
Then we convert our code to use it:
{#each arcs as arc, i}
<path d={ arc } fill={ colors[i] } />
{/each}
The result should look like this:
Phew! 😮💨 Finally an actual pie chart!
While it does no harm, make sure to clean up your code by removing the arc
variable we defined early on in this step, since we’re no longer using it.
Now let’s clean up the code a bit. D3 actually provides a higher level primitive for what we just did: the d3.pie()
function. Just like d3.arc()
, d3.pie()
is a function that returns another function, which we can use to generate the start and end angles for each slice in our pie chart instead of having to do it ourselves.
This …slice generator function takes an array of data values and returns an array of objects, each of whom represents a slice of the pie and contains the start and end angles for it. We still feed these objects to our arcGenerator
to create the paths for the slices, but we don’t have to create them manually. It looks like this:
let data = [1, 2];
let sliceGenerator = d3.pie();
let arcData = sliceGenerator(data);
let arcs = arcData.map(d => arcGenerator(d));
Step 1.5: Adding more data
Let’s tweak the data
array to add some more numbers:
let data = [1, 2, 3, 4, 5, 5];
Our pie chart did adapt, but all the new slices are black! They don’t even look like four new slices, but rather a huge black one. 😭
This is because we’ve only specified colors for the first two slices. We could specify more colors, but this doesn’t scale. Thankfully, D3 comes with both ordinal and sequential color scales that can generate colors for us based on our data.
For example to use the schemePaired
color scale we use the d3.scaleOrdinal()
function with that as an argument:
let colors = d3.scaleOrdinal(d3.schemeTableau10);
We also need to change colors[index]
to colors(index)
in our template, since colors
is now a function that takes an index and returns a color.
This is the result:
Success! 🎉
Step 2: Adding a legend
Our pie chart looks good, but there is no way to tell what each slice represents. Let’s fix that!
Step 2.1: Adding labels to our data
First, even our data does not know what it is — it does not include any labels, but only random quantities.
D3 allows us to specify more complex data, such as an array of objects:
let data = [
{ value: 1, label: "apples" },
{ value: 2, label: "oranges" },
{ value: 3, label: "mangos" },
{ value: 4, label: "pears" },
{ value: 5, label: "limes" },
{ value: 5, label: "cherries" }
];
However, to use this data, we need to change our sliceGenerator
to tell it how to access the values in our data:
let sliceGenerator = d3.pie().value(d => d.value);
If everything is set up correctly, you should now see the same pie chart as before.
Step 2.2: Adding a legend
The colors D3 scales return are just regular CSS colors. We can actually create a legend with plain HTML and CSS.
We can use a <ul>
element, but a <dl>
would have been fine too.
We use the same {#each}
block to create a list item for each slice, and use a CSS variable (e.g. --color
) to pass the color to CSS for styling.
<ul class="legend">
{#each data as d, index}
<li style="--color: { colors(index) }">
<span class="swatch"></span>
{d.label} <em>({d.value})</em>
</li>
{/each}
</ul>
At this point, it doesn’t look like a legend very much:
We need to add some CSS to make it look like an actual legend. You can experiment with the styles to make it look the way you want, but we’re including some tips below.
Making the swatch look like a swatch
You could probably want to make the swatch look like a swatch by:
- Making it a square by e.g. giving it the same width and height, or one the two plus
aspect-ratio: 1 / 1
- Giving it a background color of
var(--color)
- You may find
border-radius
useful to add slight rounding to the corners or even make it into a full circle by setting it to50%
.
Note that because <span>
is an inline element by default, to get widths and heights to work, you need to set it to display: inline-block
or inline-flex
(or apply display: flex
or display: grid
on its parent).
Applying layout on the list to make it look like a legend
I applied display: grid
to the <ul>
(via suitable CSS rules). To make the grid make best use of available space, I used an auto-fill
grid template, and set the min-width
of the list items to a reasonable value.
grid-template-columns: repeat(auto-fill, minmax(9em, 1fr));
This lays them all out on one line if there’s space, or multiple columns if not.
I also applied display: flex
on each <li>
(via suitable CSS rules) to vertically center align the text and the swatch (align-items: center
) and give it spacing via gap
Make sure the gap
you specify for the <li>
s is smaller than the gap
you specify for the whole legend’s grid, to honor the design principle of Proximity.
You probably also want to specify a border around the legend, as well as spacing inside it (padding
) and around it (margin
). The final result will vary depending on your exact CSS, but this was mine:
Step 2.3: Laying out our pie chart and legend side by side
Right now, our pie chart and legend are occupying a ton of space on our page. It’s more common to place the legend to the right of the pie chart, so let’s do that.
We can do that by wrapping both the pie chart and the legend with a shared container, and using a flex layout on it.
<div class="container">
<svg viewBox="-50 -50 100 100">
<!-- ... -->
</svg>
<ul class="legend">
<!-- ... -->
</ul>
</div>
You can experiment with the horizontal alignment (align-items
) and spacing (gap
) of the pie chart and legend, but I would recommend applying flex: 1
to the legend, so that it occupies all available width.
If everything worked well, you should now see the pie chart and legend side by side and it should be responsive, i.e. adapt well to changes in the viewport width.
Step 3: Plotting our actual data
So far, we’ve been using meaningcless hardcoded data for our pie chart. Let’s change that and plot our actual project data, and namely projects per year.
Step 3.1: Making data
a prop
Hardcoding data within the pie component would give us a pie chart component that can only be used for one specific pie chart. That’s not very useful! Instead, we want to make data
a prop of the component, so we can pass it to the component from the page that uses it.
There are two parts to that change: First, changing our data
declaration to an export (and its value to an empty array, which will be its default value):
export let data = [];
Then, we can pass the same data we’ve used in step 2.1 from our projects page this time, by first assigning them to a variable:
let pieData = [
{ value: 1, label: "apples" },
{ value: 2, label: "oranges" },
{ value: 3, label: "mangos" },
{ value: 4, label: "pears" },
{ value: 5, label: "limes" },
{ value: 5, label: "cherries" }
];
and then passing it to the <Pie>
component:
<Pie data={pieData} />
If everything worked well, we should now see the same pie chart (and legend) as before.
Step 3.2: Passing project data via the data
prop
Now that we’re passing the data from the Projects page, let’s calculate the labels and values we’ll pass to the pie chart from our project data. We will be displaying a chart of projects per year, so the labels would be the years, and the values the count of projects for that year. But how to get from our project data to that array?
D3 does not only provide functions to generate visual output, but includes powerful helpers for manipulating data. In this case, we’ll use the d3.rollups()
function to group our projects by year and count the number of projects in each bucket:
let rolledData = d3.rollups(projects, v => v.length, d => d.year);
This will give us an array of arrays that looks like this:
[
[ "2024", 3 ],
[ "2023", 4 ],
[ "2022", 3 ],
[ "2021", 2 ],
]
We will then convert this array to the type of array we need by using array.map()
. Replace your previous pieData
declaration with:
let pieData = rolledData.map(([year, count]) => {
return { value: count, label: year };
});
That’s it! The result should look like this:
Step 4: Adding a search for our projects and only visualizing visible projects
At first glance, this step appears a little unrelated to the rest of this lab. However, it demonstrates how these visualizations don’t have to be static, but can reactively update with the data, a point we will develop further in the next lab.
Step 4.1: Adding a search field
First, declare a variable that will hold the search query in the <script>
element of your projects page:
let query = "";
Then, add an <input type="search">
to the HTML, and bind its value to that variable:
<input type="search" bind:value={query}
aria-label="Search projects" placeholder="🔍 Search projects…" />
As usual, you can print out the variable in your HTML via {query}
to make sure the binding works as expected.
Step 4.2: Basic search functionality
This is the same regardless of whether you have implemented Step 7 of Lab 4 or not, since we’ll be filtering the projects by changing the data that we are displaying.
However, in Lab 4 we avoided having to change our single project template by doing
let p = info;
Since in this step we will be reactively updating the projects displayed, it’s time to actually delete that alias and edit our expressions to use the actual prop name (e.g. use info.title
instead of p.title
).
To filter the project data, we will use the array.filter()
function, which returns a new array containing only the elements that pass the test implemented by the provided function.
For example, this is how we’d search in project titles:
let filteredProjects = projects.filter(project => {
if (query) {
return project.title.includes(query);
}
return true;
});
return project.title.includes(query);
by itself would have actually worked fine, since if the query is ""
, then every project title contains it anyway. However, there is no reason to do extra work if we don’t have to.
For this to work, we’d need to use filteredProjects
instead of projects
in our template that displays the projects.
If you try this out, you’ll notice that no filtering is actually happening. This is because we are only executing the filtering once, when our search query is empty
For the filtering to re-run whenever the query changes, we need to make it a reactive statement by using the $:
prefix:
let filteredProjects;
$: filteredProjects = projects.filter(project => {
if (query) {
return project.title.includes(query);
}
return true;
});
If you try it now, filtering should work!
Step 4.3: Improving the search
Finding projects by title is a good first step, but it could make it hard to find a project. Also, it’s case-sensitive, so e.g. searching for “svelte” won’t find “Svelte”.
Let’s fix both of these!
Make the search case-insensitive
To do this, we can simply convert both the query and the title to lowercase before comparing them by using the string.toLowerCase()
function:
$: filteredProjects = projects.filter(project => {
if (query) {
return project.title.toLowerCase().includes(query.toLowerCase());
}
return true;
});
Search across all project metadata, not just titles
For the second, we can use the Object.values()
function to get an array of all the values of a project, and then join them into a single string, which we can then search in the same way:
$: filteredProjects = projects.filter(project => {
let values = Object.values(project).join("\n").toLowerCase();
return values.includes(query.toLowerCase());
});
Try it again. Both issues should be fixed at this point.
Step 4.4: Visualizing only visible projects
As it currently stands, our pie chart and legend are not aware of the filtering we are doing. Wouldn’t it be cool if we could see stats only about the projects we are currently seeing?
There are two components to this:
- Calculate
pieData
based onfilteredProjects
instead ofprojects
- Make it update reactively.
The former is a simple matter of replacing the variable name used in your projects page **from projects
to filteredProjects
. The second does involve something we have not yet done: how do we turn something that consists of several lines into a reactive statement? So far we’ve only been prepending single commands with $:
!
The answer is that we can use a block statement ({}
) to contain multiple commands, and then prepend that with $:
:
// Make sure the variable definition is *outside* the block
let pieData;
$: {
// Initialize to an empty object every time this runs
pieData = {};
// Calculate rolledData and pieData based on filteredProjects here
}
If you try the search out at this point, you will see that the legend is updating, but the pie chart is not.
This is because none of the calculations in the <Pie>
component are actually reactive. We need to make them reactive by separating the variable declarations from the value calculations and using the $:
prefix on the latter.
This only applies to arcData
and arcs
, since none of the rest needs to actually change.
Once we do that, our pie chart becomes beautifully reactive as well:
Step 5: Turning the pie into filtering UI for our projects
Visualizations are not just output. Interactive visualizations allow you to interact with the data as well and explore it more effective ways.
In this step, we will turn our pie chart into a filtering UI for our projects, so we can click on the wedge or legend entry for a given year and only see projects from that year.
It will work a bit like this:
Ready? Let’s go!
Step 5.1: Highlighting hovered wedge
While there are some differences, SVG elements are still DOM elements. This means they can be styled with regular CSS, although the available properties are not all the same.
Let’s start by adding a hover effect to the wedges. What about fading out all other wedges when a wedge is hovered? We can target the <svg>
element when it contains a hovered <path>
by using the :has()
pseudo-class:
svg:has(path:hover) {
path:not(:hover) {
opacity: 50%;
}
}
This gives us something like this:
Why not just use svg:hover
instead of svg:has(path:hover)
? Because the <svg>
can be covered without any of the wedges being hovered, and then all wedges would be faded out.
We can even make it smooth by adding a transition
property to the <path>
elements:
path {
transition: 300ms;
}
Which would look like this:
Step 5.2: Highlighting selected wedge
In this step, we will be able to click on a wedge and have it stay highlighted. Its color will change to indicate that it’s highlighted, and its legend item will also be highlighted. Pages using the component should be able to read what the selected wedge is, if any. Clicking on a selected wedge should deselect it.
First, create a selectedIndex
prop and initialize it to -1
(a convention to mean “no index”):
export let selectedIndex = -1;
Then, add an on:click
event on your <path>
to set it to the index of the wedge that was clicked:
{#each arcs as arc, index}
<path d={arc} fill={ colors(index) }
on:click={e => selectedIndex = index} />
{/each}
Ignore the accessibility warnings for now, we will address them at the end.
Right now, there is no observable difference when we click on a wedge, since we’re not doing anything with the selectedIndex
. Let’s use it to conditionally apply a selected
class, that we can then use in our CSS to style selected wedges differently:
{#each arcs as arc, index}
<path d={arc} fill={ colors(index) }
class:selected={selectedIndex === index}
on:click={e => selectedIndex = index} />
{/each}
You can apply the exact same class:selected
directive to conditionally apply a selected
class to the legend items as well.
Then let’s apply CSS to change the color of the selected wedge and legend item:
.selected {
--color: oklch(60% 45% 0) !important;
&:is(path) {
fill: var(--color);
}
}
Feel free to use any color you want, as long as it’s disctinct from the actual wedge colors.
Why the !important
? Because we are trying to override the --color
variable set via the style
attribute, which has higher precedence than any selector.
Lastly, we want to be able to deselect a wedge by clicking on it again.
This is as simple as setting selectedIndex
to -1
if it’s already the index of the selected wedge, i.e. changing the assignment to
selectedIndex = selectedIndex === index ? -1 : index;
You can improve UX by indicating that a wedge is clickable through the cursor:
path { /* ... */ cursor: pointer; }
Step 5.3: Filtering the projects by the selected year
Selecting a wedge doesn’t really do that much right now. However, the ability to select a wedge becomes truly powerful when handled by the parent page.
In src/routes/projects/+page.svelte
, add a variable to hold the selected index:
let selectedYearIndex = -1;
Then bind it to the <Pie>
component’s selectedIndex
prop:
<Pie data={pieData} bind:selectedIndex={selectedYearIndex} />
Make sure that it works by printing out the selected index in an expression ({selectedYearIndex}
) somewhere on the page.
Now define a reactive variable to hold the selected year:
let selectedYear;
$: selectedYear = selectedYearIndex > -1 ? pieData[selectedYearIndex].label : null;
Similarly, print it out somewhere on the page to make sure it works before proceeding.
Now that we have the selected year, we can filter the projects by it!
Our first thought might be to do this filtering by adding another conditional in our array.filter()
call from Step 4:
$: filteredProjects = projects.filter(project => {
if (query) {
// ...
}
if (selectedYear) {
return project.year === selectedYear;
}
return true;
});
However, this will produce an error:
But even if it worked, it would make for some pretty jarring user experience: because we are using the same filteredProjects
variable for the pie chart as well, it would make all other years disappear from the pie chart when a year is selected. The only way to select another year would be to deselect the current one.
Instead, we should use another variable to hold the result of that filtering, e.g. filteredByYear
.
Then, use filteredByYear
in your template where you are displaying the actual projects, but leave filteredProjects
as it is for the pie chart.
That’s it! It should work now.
Step 5.4: Fixing accessibility issues (Optional, but strongly recommended)
Now that we got the basic functionality working, let’s address the accessibility warnings.
It’s important to understand why these warnings are there. The path
elements are not focusable by default, so they cannot be interacted with using the keyboard. This means that as it currently stands, people who cannot use a mouse or other pointing device cannot select a wedge. Even users who can use a mouse, often find keyboard interactions more convenient (e.g. imagine filling out a form by clicking on each field with the mouse instead of pressing Tab!).
So how do we fix this? The first step is making it possible to interact with these wedges with the keyboard at all. Right now, you cannot even select a wedge by pressing the Tab key on your keyboard, because they are not focusable.
We can fix this by adding a few attributes to the <path>
elements:
- Make it focusable, by adding
tabindex="0"
- Expose it as a button to assistive technology, by adding
role="button"
- Adding a label via
aria-label
We’re not done yet. All that these do is to make sure users of assistive technology can actually interact with the wedge. However, because it’s not a native button or link, the click
event will not be triggered when the user focuses on the wedge with the keyboard and presses Enter
or Space
. Instead, we need to enable that, via a separate event listener (keyup
is a good candidate).
To avoid duplicating code, let’s move the code that selects a wedge into a separate function:
function toggleWedge (index, event) {
selectedIndex = index;
}
Then replace on:click={e => selectedIndex = index}
with on:click={e => toggleWedge(index, e)}
. Now add a keyboard event listener: on:keyup={e => toggleWedge(index, e)}
.
In the toggleWedge
function, we can wrap the code that selects the wedge with a conditional that checks that either event.key
doesn’t exist, or if it does, that it is Enter
:
function toggleWedge (index, event) {
if (!event.key || event.key === "Enter") {
selectedIndex = index;
}
}
If you try the keyboard interaction out you will notice that it works, but even when we are interacting with it via the mouse, we get an unwieldy focus ring around the wedge which looks awful since it’s actually covered by the other wedges:
We can hide that with outline: none
:
path {
transition: 300ms;
outline: none;
}
However, now keyboard users have no way to know which wedge they have currently focused, which is a terrible user experience. Never, ever remove the browser’s default focus styles without providing alternative focus styles. Often extending :hover
styles to cover :focus-visible
as well is a good start. So let’s extend our previous :hover
effect to keyboard users as well:
svg:has(path:hover, path:focus-visible) {
path:not(:hover, :focus-visible) {
opacity: 50%;
}
}
If you try out the keyboard interaction now, you will notice that we are getting a visible indication of focus, and that the unwieldy default focus ring is no longer visible. Yay! 🎉
Step 5.5: Better selected wedge styling (Optional)
We are currently only indicating which wedge is selected by its color, which is a little confusing (not to mention problematic for colorblind users), since that could be just another color in the pie chart.
The reason we went with that is that it’s easier than pretty much any alternative, but if you want to go further, we can do it in a better way.
First, it’s important to understand something about SVG:
Shapes are painted in the order they appear in the source code, and unlike in HTML, there is no way to change this order with CSS.
This means that decorations like strokes or shadows will work nicely for one of the wedges and fail miserably for the others:
Yikes on bikes! So what can we do?
A common technique is to move the selected wedge to the very end with JS or make a copy, then style that. But we’ll try something different: we’ll move the selected slice outwards a bit, and make it a bit bigger, like taking a pizza slice from a large colorful pizza. It will look like this:
We will need the start and end angles in our CSS, so the first step is to pass them in as CSS variables:
<path d={arc} style="
--start-angle: { arcData[index]?.startAngle }rad;
--end-angle: { arcData[index]?.endAngle }rad;"
Note the rad
at the end: CSS angles need a unit too.
Then, in the CSS we can calculate the difference, and the angle to get to the midpoint of the arc:
path {
--angle: calc(var(--end-angle) - var(--start-angle));
--mid-angle: calc(var(--start-angle) + var(--angle) / 2);
}
Now comes the fun part: We will use the transform
property to rotate to the midpoint of the arc (so that we move along that angle), move by 6, then rotate back to restore the original orientation:
path {
--angle: calc(var(--end-angle) - var(--start-angle));
--mid-angle: calc(var(--start-angle) + var(--angle) / 2);
&.selected {
transform: rotate(var(--mid-angle))
translateY(-6px)
rotate(calc(-1 * var(--mid-angle)));
}
}
If you try it, it should work, even if a little rough around the edges:
The reason it’s a little janky when we click on it, is that it’s transitioning all three transforms at once. If we apply a transform on its non-selected state as well, we can fix that:
path {
transform: rotate(var(--mid-angle))
translateY(0)
rotate(calc(-1 * var(--mid-angle)));
/* ... */
}
And let’s also make it a little bigger, by adding a scale(1.1)
transform as well:
&.selected {
transform: rotate(var(--mid-angle))
translateY(-6px) scale(1.1)
rotate(calc(-1 * var(--mid-angle)));
}
This is the final result: