Clickable Circles
One of the strong points of D3.js is that it enables you to create arbitrarily complex and custom interactions. D3 provides low-level primitives for dealing with interaction events and rendering updates, but does not prescribe a particular way to manage state. This is a good thing, because it allows you to use the state management approach that is most appropriate for your particular application. In this article, I’ll show you how to create a simple example of clickable circles using D3.js, using my own personal favorite state management pattern: unidirectional data flow. This example will serve as a foundation for future articles that explore more complex interactions and state management patterns.
Click a circle to try it out!
State Management Setup
The state management pattern I like to use is a unidirectional data flow pattern. This pattern is similar to the one used in React, Redux, and Elm. The idea is that you have a single source of truth for the state of the application, and you pass that state down to components via functions. When a component needs to update the state, it calls a function that updates the state and then re-renders the component. This pattern is simple, predictable, and easy to reason about. It’s also very flexible, and can be used to build complex applications with minimal boilerplate.
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>
Here is an implementation of unidirectional data flow that is extremly small and simple, yet powerful enough for us to get started. The index.html
file is the entry point for the application. It imports the main
function from the index.js
file, and then sets up the state management logic. The imported main
function is then called with the container element (as in the previous post), but this time with a second argument that contains the state
and setState
functions.
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();
The state
object is used to store the state of the application (initially an empty object), and the setState
function is used to update the state and re-render the application. The render
function is called to render the application for the first time, and then the main
function is called to render the application, passing in the latest state. This pattern of calling main
with the latest state and setState
function is what enables the unidirectional data flow pattern.
To update the state, we can call setState
, passing a function that takes as input the previous version of the state and returns as output the next version of the state. This type of function is often called a reducer function, and it is responsible for updating the state in response to events. The setState
function then updates the state with the new state returned by the reducer function, and re-renders the application. This pattern enables updating multiple state properties at once in a single call, and also enables deriving new state properties from existing state properties.
Passing State to Components
To give nested functions / components access to the current state and the ability to update state, we can simply pass the state
and setState
functions as arguments to the nested functions. This is what we do in the main
function.
index.js
import { data } from "./data.js";
import { renderSVG } from "./renderSVG.js";
import { clickableCircles } from "./clickableCircles.js";
export const main = (container, { state, setState }) => {
const svg = renderSVG(container);
clickableCircles(svg, { data, state, setState });
};
The main
function takes a container
DOM element as an argument, and an object that contains the state
and setState
functions. The main
function then calls the renderSVG
function with the container
element to create an SVG element, and then calls the clickableCircles
function with the SVG element, the data
array, and the state
and setState
functions to render circles on the SVG. This is how we can give access to the state and the ability to update the state to nested functions.
Making Circles Clickable
The goal is to make it so when you click on a circle, it becomes highlighted. There are several steps to make this happen:
- Add an affordance to the circles to indicate they are clickable
- Add an event listener to the circles to respond to click events
- Update the state when a circle is clicked
- Update the rendering logic to highlight the clicked circle
renderCircles.js
export const renderCircles = (svg, { data }) =>
svg
.selectAll("circle")
.data(data)
.join("circle")
.attr("cx", (d) => d.x)
.attr("cy", (d) => d.y)
.attr("r", (d) => d.r)
.attr("fill", (d) => d.fill)
.attr("opacity", 700 / 1000);
The renderCircles
function from the previous post remains unchanged. It is shown here for reference. Note that it returns the selection implicitly, which allows us to work with it in downstream code.
clickableCircles.js
import { renderCircles } from "./renderCircles.js";
export const clickableCircles = (svg, { data, state, setState }) => {
renderCircles(svg, { data })
.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();
};
The clickableCircles
function calls the previously defined renderCircles
function to render the circles on the SVG. Since that function returns a selection of the circles, we can further chain calls onto it. The first thing we do is set .attr("cursor", "pointer")
to indicate that the circles are clickable. This makes it so that when you hover over it, the cursor changes to indicate it is clickable. This is called an “affordance” of interaction. It’s very important to do this so users know the element is clickable.
Next, we add an event listener to the "click"
event using the on
method of D3 Selections. This lets us pass a callback function that gets invoked when the user actually clicks on the circle. The first argument to the callback is the DOM Event object (which we don’t need to use here), and the second argument is the data bound to the clicked circle. We take the approach of setting state.selectedDatum
to be the actual row object from the data
array that corresponds to the clicked circle.
In the click event listener callback, we use the “functional update” pattern to update the state. This pattern involves passing a function to setState
that takes the previous state as input and returns the next state as output. In this case, we use the spread operator to copy the previous state into a new object, and then add a new property selectedDatum
to the new object. Even though selectedDatum
is the only property on the state object for now, I like to use the spread operator here as a “best practice” to preserve other state properties that may be added in the future.
The last few lines here implement the rendering logic for making a given circle appear to be selected. We begin by re-setting all circles to have .attr("stroke", "none")
, which removes any stroke that may have been set previously. We then use the .filter
method to filter the selection of circles to only include the one that matches state.selectedDatum
. Then on that one circle, we then set the stroke color to black, the stroke width to 5, and raise the circle to the top of the SVG so it appears on top of the other circles. This is one way to implement rendering logic for making a given circle appear to be selected.
Conclusion
In this article, we covered the basics of state management for interactive graphics using D3.js. We implemented a simple example of clickable circles, and showed how to use unidirectional data flow to manage the state of the application. We also showed how to pass the state and setState functions to nested functions, and how to update the state in response to events. We also showed how to update the rendering logic to respond to changes in the state. This example serves as a foundation for future articles that explore more complex interactions and state management patterns. I hope you enjoyed this article, and I look forward to sharing more with you in the future!
Full code listing
styles.css
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
#viz-container {
width: 100%;
height: 100%;
}
renderSVG.js
import { select } from "d3";
export const renderSVG = (container) =>
select(container)
.selectAll("svg")
.data([null])
.join("svg")
.attr("width", container.clientWidth)
.attr("height", container.clientHeight)
.style("background", "#F0FFF4");
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" },
];