How I made an interactive Venn diagram with d3

Calder M. Myers
12 min readMay 17, 2019

--

I’ve had the idea to use a Venn diagram on the landing page of my personal site for awhile now. My life is and always will be best represented by a Venn diagram. I am happiest when my core talents (verbal, expressed in both writing and teaching; technical; visual) intersect in interesting ways — teaching math to fashion students*, starting a clothing line, building a natural language generator to compose sonnets using the vocabulary of classic novels. Writing about data visualization is a happy place for me since it combines all three of these things.

*actually that job was kind of awful but it seemed like a good idea at the time.

First, a bit of armor in the form of disclaimers:

  • I’m learning JS and D3 as I go along. I know there were some significant changes in JS standards with ES6, and I’m sure my code is a mix of pre- and post-ES6 standards.
  • I’m aware that a lot of this code could be cleaner, more terse, more automated. My intention was to document my process, edited for clarity but still with plenty of messy bits. This includes pointing out moments where I did go back and change something to make a later step easier to deal with. However, the finished example I will leave you with represents the state of my code at the point that it was doing what I set out to have it do. There is still plenty to improve.
  • This work is presented in good faith, with the intention to help others trying to figure out similar problems. I welcome constructive criticism if it is coming from the same place. Don’t be a jerk.

Circles (this is the easy part)

I’ll assume the reader has at least a passing familiarity with HTML and JavaScript, and can follow this piece of sample code. Basically, if you can make a circle in d3 I think we’re good to go.

Here’s my version of a circle, with no fill and a dark grey stroke on a light grey background. (I like to think of the svg (scalable vector graphic) object as a drawing pad and the g object as a piece of paper.)

<!DOCTYPE html>
<meta charset="utf-8">
<style>
body {
background-color: #f0f0f0;
}
circle {
fill: none;
stroke: #999999;
stroke-width: 3px;
}
}
</style>
<svg width="800" height="500"></svg>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script>
//drawing board
const svg = d3.select("svg")
const margin = {
top: 20,
right: 20,
bottom: 30,
left: 40
}
const width = +svg.attr("width") - margin.left - margin.right
const height = +svg.attr("height") - margin.top - margin.bottom
// piece of paper
const g = svg.append("g")
.attr("transform",
"translate(" +
margin.left + "," +
margin.top + ")");
const circleRad = 100
const xCenter = 150
const yCenter = 150
g.append("circle")
.attr("r", circleRad)
.attr('transform',
"translate(" +
xCenter + "," +
yCenter + ")");
</script>

Output:

a circle!

To add the second circle, we just shift the xCenter value over by some amount while holding yCenter constant. Add the following code to create another circle with the xCenter shifted by the length of radius plus 20 percent:

const xCenter2 = xCenter + 1.2*circleRadg.append("circle")
.attr("r", circleRad)
.attr('transform',
"translate(" +
xCenter2 + "," +
yCenter + ")");
two circles!

(There’s nothing special about the 20%, I just think it looks “right” with those proportions.)

This next step will require a skotch of geometry. We know the horizontal center of the third circle should be halfway between the centers of the circles we already have. In other words, the original center plus half the shift, or xCenter + .6*circleRad. Rather than compute that 0.6 by hand, let’s create a variable for the offset factor by revising the top line of the previous code block:

const offsetFactor = 1.2
const xCenter2 = xCenter + offsetFactor*circleRad

This allows us to compute the xCenter of the third circle as xCenter + offsetRatio*circleRad / 2.

TheyCenter is where some legit geometry comes in. We know we want the distance between the centers of the circles to all be equal. Imagine overlaying an equilateral triangle on the diagram like so:

The length of one leg of this triangle is the distance between the centers of our first two circles, offsetFactor*circleRad . The height of an equilateral triangle is equal to sqrt(3)*length_of_leg / 2 . (Here’s everything you ever wanted to know about equilateral triangles and more from WolframAlpha.) So the y-value of the tip of this triangle will be the original yCenter plussqrt(3)*offsetFactor*circleRad / 2 . Also, this offsetFactor*circleRad is showing up enough that it should be its own variable, so let’s revise the code above again to be

const offsetFactor = 1.2
const offset = offsetFactor*circleRad
const xCenter2 = xCenter + offset

and finally compute the center of the third circle

const xCenter3 = xCenter + offset / 2
const yCenter3 = yCenter + Math.sqrt(3)*offset / 2
g.append("circle")
.attr("r", circleRad)
.attr('transform',
"translate(" +
xCenter3 + "," +
yCenter3 + ")");
three circles!

Remember how I said that was the easy part? Remember how I said this was supposed to be a *interactive* Venn diagram? To do that, we’re going to need….

Intermission: some more geometry

There are seven regions in the diagram above (not counting the area outside the circles). Each one is defined by three distinct curves, or arcs.

To create these arcs, we need to identify the six points of intersection that define the seven regions. Let’s go back that equilateral triangle that helped us define the center of the third circle:

The upper intersection of circles 1 and 2 will have an x-value equal to the center of circle 3. In order to compute the y-value of that point, we need to consider the right triangle formed by connecting the center of circle 1 to the intersection of circles 1 and 2. Since that looks suspiciously like another 30–60–90 triangle, I want to look at another version with a greater offset factor to make the relationship clearer. Here’s what that same code produces with an offset factor of 1.8:

(it’s of note that with an offset factor this large, the seventh region no longer exists — there is no area at which circles 1, 2 and 3 all intersect. This won’t matter for the current computation, but an interesting side question would be “what is the offset factor necessary for the three circles to intersect at a single point?”)

The base of our hypothetical triangle runs from the center of circle 1 until it intersects with the blue line. Its length is one-half the offset ( offset / 2 ). The hypotenuse of the triangle is a radius of circle 1, so it has length circleRad. Thus, the height of the triangle is sqrt(circleRad**2 — (offset / 2)**2) , and the y-value of the intersection point is yCenter — height (remember that the y-axis counts from top to bottom, so to get from a lower point to a higher point we need to subtract).

Let’s test that out with some code.

const triHeight = Math.sqrt(circleRad**2 - (offset / 2)**2)
const yValIsectCir1Cir2Upper = yCenter - triHeight
const xValIsectCir1Cir2 = xCenter3

Result of plotting a line from the center of circle 1 to the intersection point:

wooo! The math doesn’t lie.

Finding the intersection of circles 1 and 3 (or 2 and 3) is a little more complicated since we don’t have that vertical line to use as reference. Instead, we have for reference the line that bisects the triangle through the center of circle 2:

At this point I’m going to be a little hand-wavey with how I got to the rest of the points of intersection since this wasn’t really meant to be as geometry lesson. Here is the code I used to compute the x- and y-values of all six of the intersections:

//compute first points of intersection
const triHeight = Math.sqrt(circleRad**2 - (offset / 2)**2)
//outer intersection of Circles 1 and 2
const xIsect1 = xCenter3
const yIsect1 = yCenter1 - triHeight
//inner intersection of Circles 1 and 2
const xIsect4 = xCenter3
const yIsect4 = yCenter1 + triHeight
//treat "triHeight" as the hypoteneuse of a 30.60.90 triangle.
//this tells us the shift from the midpoint of a leg of the triangle
//to the point of intersection
const xDelta = triHeight * Math.sqrt(3) / 2
const yDelta = triHeight / 2
const xMidpointC1C3 = (xCenter1 + xCenter3) / 2
const xMidpointC2C3 = (xCenter2 + xCenter3) / 2
const yMidpointBoth = (yCenter1 + yCenter3) / 2
//find the rest of the points of intersection
const xIsect2 = xMidpointC1C3 - xDelta
const yIsect2 = yMidpointBoth + yDelta
const xIsect3 = xMidpointC2C3 + xDelta
const yIsect3 = yMidpointBoth + yDelta
const xIsect5 = xMidpointC1C3 + xDelta
const yIsect5 = yMidpointBoth - yDelta
const xIsect6 = xMidpointC2C3 - xDelta
const yIsect6 = yMidpointBoth - yDelta

And here are the labelled points (with offset factor returned to 1.2):

Now that that’s done, we can actually move on to creating the…

Paths!

d3 has functions to create SVG paths, like moveTo and arcTo, but to be honest I found them harder to deal with than just straight SVG, especially since I need to make use of into some of Path arguments that don’t appear to be available in arcTo. This post from the Mozilla Developers network provided an invaluable resource about arcs, and SVG paths in general.

Arcs are not that big a deal once you get the hang of them. A basic arc path might look like this:

<path d="M 20 20 A 50 50 0 0 0 100 20" stroke="blue" fill="none"/>

and would create an arc that looked like this:

small sweep, counterclockwise from (20, 20) to (100, 20)

The arc began at point 20, 20 and traced itself counter-clockwise along a circle with radius 50, ending at point 100, 20.

The three zeroes in the string above, between the radius parameters and end-point parameters, represent the x-axis rotation, the “large arc” flag, and the “sweep” (direction) flag. Since this project only uses circles, the rotation argument is irrelevant (consult that Mozilla post for more info about ellipse rotations). Setting the large arc flag to 1

<path d="M 20 20 A 50 50 0 1 0 100 20" stroke="blue" fill="none"/>

produces this arc:

large sweep, counterclockwise from (20, 20) to (100, 20)

Basically, it goes the long way around, following the other possible circle with radius 50 that passes through those two points. Setting the “sweep” flag to 1 tells the arc to travel clockwise rather than counter-clockwise:

<path d="M 20 20 A 50 50 0 0 1 100 20" stroke="blue" fill="none"/>
small sweep, clockwise from (20,20) to (100, 20)

(The tippy top of the arc is cut off because it only had 20px to go up before it hits the top of the SVG object.)

For the diagram, we’ll need three different shapes:

The one that looks kind of like an iron.
The one that kind of looks like a setting sun.
The rounded triangle.

I used three very similar functions to produce the three different shapes. I’m sure I could DRY this code up, but I’m happy to have the distinctions for now.

const makeIronShapes = (x1, y1, x2, y2, x3, y3) => {
path = `M ${x1} ${y1}
A ${circleRad} ${circleRad} 0 0 1 ${x2} ${y2}
A ${circleRad} ${circleRad} 0 0 0 ${x3} ${y3}
A ${circleRad} ${circleRad} 0 0 1 ${x1} ${y1}`
return path
}
const makeSunShapes = (x1, y1, x2, y2, x3, y3) => {
path = `M ${x1} ${y1}
A ${circleRad} ${circleRad} 0 0 0 ${x2} ${y2}
A ${circleRad} ${circleRad} 0 0 0 ${x3} ${y3}
A ${circleRad} ${circleRad} 0 1 1 ${x1} ${y1}`
return path
}
const makeRoundedTri = (x1, y1, x2, y2, x3, y3) => {
path = `M ${x1} ${y1}
A ${circleRad} ${circleRad} 0 0 1 ${x2} ${y2}
A ${circleRad} ${circleRad} 0 0 1 ${x3} ${y3}
A ${circleRad} ${circleRad} 0 0 1 ${x1} ${y1}`
return path
}

To make the iron shape, we start at the pointy point (:D) — the point on the outside edge of the overall diagram, make a small clockwise sweep to the first interior point, then a small counterclockwise sweep to the second interior point, then a small clockwise sweep back to the exterior point. We use similar logic for the other two shapes. What’s nice is that we don’t need to know the angles of the arcs, just their starting and ending points.

At this point I’m hardcoding each “numbered” point, so, for example, I would call the bottom sun shape like this:

makeSunShapes(xisect3, yisect3, xisect4, yisect4, xisect2, yisect2)

since by the logic we’ve set up, it goes counterclockwise from point 2 to point 4, counterclockwise from point 4 to point 3, and then clockwise (with large sweep) from point 3 back to point 2:

Again, this can totally be cleaned up and further automated, but we’ve met the goal of being able to distinguish each of the 7 segments, so let’s celebrate that success and move on to:

Interactivity

For this visual, I knew that I wanted each segment to react in some way to a mouse hover, and then to link out on the click. As proof of concept, let’s make each segment change color on mouseOver, then return to its original color on mouseOut .

Start by giving the seven segments their own class. I am going to take a small step toward writing shorter code by making making arrays of the relevant x- and y-values, and then identifying the correct order of points to create each of the shapes:

xPoints = [xIsect1, xIsect2, xIsect3, xIsect4, xIsect5, xIsect6]
yPoints = [yIsect1, yIsect2, yIsect3, yIsect4, yIsect5, yIsect6]
ironPoints = [[1,5,6], [3,4,5], [2,6,4]]
sunPoints = [[3,5,1], [2,4,3], [1,6,2]]
roundedTriPoints = [[5,4,6]]

I also changed the way the functions take their arguments to be an array of first the x-points in order, then the y-points in order:

const makeIronShapes = ([x1, x2, x3, y1, y2, y3])

Then call the function for all of the iron shapes like this:

for (const points of ironPoints) {
const ptCycle = points.map(i=>xPoints[i-1]).concat(
points.map(i=>yPoints[i-1])
)
const shape = makeIronShapes(ptCycle)
g.append("path")
.attr("d", shape)
.attr("class", "segment")
.attr("fill", "red")
}

which produces:

and can be generalized to deal with all the shapes, and tweaked slight to differentiate them by color, or id, or some other attribute.

For now, let’s color each type of shape in it a different low-opacity color, then increase the opacity of a single segment on hover:

for (const points of ironPoints) {
const ptCycle = points.map(i => xPoints[i - 1]).concat(
points.map(i => yPoints[i - 1])
)
const shape = makeIronShapes(ptCycle)
g.append("path")
.attr("d", shape)
.attr("class", "segment")
.attr("fill", "#cc6666")
.attr("opacity", 0.4)
}

Repeat the above for the other two types of shapes, using different fill colors for each, to produce this:

Then add the mouse event commands by selecting all the segments and applying simple functions to the events:

g.selectAll("path.segment")
.on("mouseover", function () {
d3.select(this)
.transition()
.attr("opacity", 0.8)
.duration(500)
})
.on("mouseout", function () {
d3.select(this)
.transition()
.attr("opacity", 0.4)
.duration(500)
})

The transition() and duration() methods aren’t necessary, they just make the process of highlighting less jarring.

See it in action here.

Resources

These are some of the many resources I consulted while writing this post:

--

--