There is no language I find quite so humbling as D3.js. Just when I think I understand it, I get stumped for a whole day trying to do something as simple as adding a colored background to a text
element so the letters are readable against the rainbow-colored monstrosity sitting behind it. Well, if you found this post amongst the sea of woefully inadequate advice that I had to wade through, hopefully you have finally found your answer. In short, we are going to look at how to use D3.js to:
- Add a
rect
element for eachtext
element in a dataset. - Set the size of that
rect
element to match the correspondingtext
element. - Ensure the
rect
element sits behind thetext
element, not on top of it.
Note: for this explainer we are working with D3.js version 6.
The break through occurred for me after reading this comment from the man himself, Mike Bostock (inventor of D3.js in case you didn’t know). After reading through many posts on this topic, I was aware that I needed to use the getBBox
method to get the dimensions of a given text
element, but every time I checked what getBBox
was returning, it was all zeros.
In short, what I had been missing up until I read the above comment was that getBBox
will return zero unless the text
elements have actually been added to the DOM. This of course makes sense, but also prompts the question “Well, how do we add the rect
elements before the text
elements when we have to add the text
to get the dimensions for the rect
elements?” Turns out there are two methods for doing this.
Method 1: Add, delete, re-add
The first method boils down to the following:
- Add the
text
elements - Measure the sizes
- Remove the
text
elements - Add the
rect
elements, - Re-add the
text
elements
Let’s take a look at how we can do this using a dataset containing different length text
elements, which is what we will almost always be doing. Note, the following is a fully self contained example, you can download and open it with a browser to see the results.
<!DOCTYPE html>
<html>
<head>
<title>D3.js Text Elements with Background | Brett Romero</title>
<meta content="text/html;charset=utf-8" http-equiv="Content-Type">
<meta content="utf-8" http-equiv="encoding">
<!-- D3.js -->
<script src="https://d3js.org/d3.v6.js"></script>
<script>
var data = [
{"name": "foo", "x": 10, "y": 20},
{"name": "bar", "x": 30, "y": 60},
{"name": "stuff", "x": 15, "y": 175},
{"name": "something really long", "x": 40, "y": 212},
{"name": "something even longer akhdihfi", "x": 2, "y": 300}
]
</script>
</head>
<body>
<div>
<div id="chart-container">
</div>
</div>
<script>
const height = 0.8 * window.innerHeight;
const width = document.getElementById("chart-container").clientWidth;
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height]);
// Create the text elements
svg.selectAll("text")
.data(data)
.join("text")
.style("font", "14px sans-serif")
.text(d => d.name);
// Add the elements to the DOM
document.getElementById("chart-container").append(svg.node());
// Save the dimensions of the text elements
svg.selectAll("text")
.each(function(d) { d.bbox = this.getBBox(); });
// Remove the text elements
d3.selectAll("text").remove();
// Now add the rectangles, using the sizes we just added to the data
const xMargin = 4
const yMargin = 2
svg.append("g")
.selectAll("rect")
.data(data)
.join("rect")
.attr("x", d => d.x)
.attr("y", d => d.y)
.attr("width", d => d.bbox.width + 2 * xMargin)
.attr("height", d => d.bbox.height + 2 * yMargin)
.attr('transform', function(d) {
return `translate(-${xMargin}, -${d.bbox.height * 0.8 + yMargin})`
})
.style("fill", "black")
.style("opacity", "0.5");
// Re-add the text, this time with positioning
svg.append("g")
.style("font", "14px sans-serif")
.style("fill", "#FFF")
.selectAll("text")
.data(data)
.join("text")
.attr("x", d => d.x)
.attr("y", d => d.y)
.text(d => d.name);
// Add new elements to the DOM
document.getElementById("chart-container").append(svg.node());
</script>
</body>
</html>
I believe the code is fairly self-explanatory, but let’s walk through it:
- Create an
svg
object that will contain all our D3 elements. - Create the
text
elements (using the play dataset included in thehead
section). Note that we do not need to worry about positioning and colors at this stage, but we do have to worry about the font type and size. This is because we are trying to find the dimensions of thetext
elements. - Add the
text
elements to the DOM. - Save the measurements of the
text
elements to the dataset usinggetBBox
. - Remove the
text
elements. - Add the
rect
elements using the saved dimensions (plus margins if wanted). Note that we have to usetransform
to ensure the rectangles are positioned correctly. - Add the
text
elements back in, this time with styling and positioning.
Method 2: Re-size rect
elements
This one I came up with all by myself, although I assume I am not the first person to think of this. The underlying problem is that we need the dimensions of a text
element to correctly size a rect
element that needs to be underneath the corresponding text
element. So we can do the “add, delete, add” dance as above, or we can add the rect
elements first and update their sizes after we have measured the text
elements. The following code shows how we could do this:
<!DOCTYPE html>
<html>
<head>
<title>D3.js Text Elements with Background | Brett Romero</title>
<meta content="text/html;charset=utf-8" http-equiv="Content-Type">
<meta content="utf-8" http-equiv="encoding">
<!-- D3.js -->
<script src="https://d3js.org/d3.v6.js"></script>
<script>
var data = [
{"name": "foo", "x": 10, "y": 20},
{"name": "bar", "x": 30, "y": 60},
{"name": "stuff", "x": 15, "y": 175},
{"name": "something really long", "x": 40, "y": 212},
{"name": "something even longer akhdihfi", "x": 2, "y": 300}
]
</script>
</head>
<body>
<div>
<div id="chart-container">
</div>
</div>
<script>
const height = 0.8 * window.innerHeight;
const width = document.getElementById("chart-container").clientWidth;
const svg = d3.create("svg")
.attr("viewBox", [0, 0, width, height]);
// Add the rect elements, these are placeholders
svg.append("g")
.selectAll("rect")
.data(data)
.join("rect")
.attr("x", d => d.x)
.attr("y", d => d.y)
.style("fill", "black")
.style("opacity", "0.5");
// Add the text
svg.append("g")
.style("font", "14px sans-serif")
.style("fill", "#FFF")
.selectAll("text")
.data(data)
.join("text")
.attr("x", d => d.x)
.attr("y", d => d.y)
.text(d => d.name);
// Add the elements to the DOM
document.getElementById("chart-container").append(svg.node());
// Save the dimensions of the text elements
svg.selectAll("text")
.each(function(d) { d.bbox = this.getBBox(); });
// Update the rectangles using the sizes we just added to the data
const xMargin = 4
const yMargin = 2
svg.selectAll("rect")
.data(data)
.join("rect")
.attr("width", d => d.bbox.width + 2 * xMargin)
.attr("height", d => d.bbox.height + 2 * yMargin)
.attr('transform', function(d) {
return `translate(-${xMargin}, -${d.bbox.height * 0.8 + yMargin})`
});
</script>
</body>
</html>
If anything, this solution seems cleaner and more concise than Method 1 as we only add each element once and avoid having to add and delete. Potentially there could be problems if you have more than one type of rect
element, but then you can use classes to differentiate between them.
Wrapping Up
After spending a couple of days tearing my hair out, the problem turned out to be part lack of understanding on my part, and part clunky interface that makes it complicated to do something that I would think is a very common use case. In the end though, I am happy to present you with two reasonable (and understandable) ways to add fitted backgrounds to your text elements in D3.js.
Leave a Reply