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
rectelement for eachtextelement in a dataset. - Set the size of that
rectelement to match the correspondingtextelement. - Ensure the
rectelement sits behind thetextelement, 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
textelements - Measure the sizes
- Remove the
textelements - Add the
rectelements, - Re-add the
textelements
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
svgobject that will contain all our D3 elements. - Create the
textelements (using the play dataset included in theheadsection). 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 thetextelements. - Add the
textelements to the DOM. - Save the measurements of the
textelements to the dataset usinggetBBox. - Remove the
textelements. - Add the
rectelements using the saved dimensions (plus margins if wanted). Note that we have to usetransformto ensure the rectangles are positioned correctly. - Add the
textelements 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