I teach a data visualization course, and recently one of my students has come to me with the idea of “half-filling map areas”. I was like that’s a great idea, go find an example on the internet, I’m sure there is plenty. Well, as it turns out, there isn’t plenty, so I’ve decided to do my own.
The starting point is the famous D3 liquid fill gauge visualization by Curtis Bratton. This visual has since been integrated in all the major BI platforms and developed kind of like a “cult” following in the dataviz community.
So the task is to recreate the above, but for maps. Well, maps are just a bunch of custom shapes, so first we need to start with recreating the visual for a custom shape. I did that here. We start with the 1.1 version of the gauge.
What we needed to do here is to change loadLiquidFillGauge
function. So we create a custom shape function (on top of the default circle
, rect
and polygon
). Then, when we create the clip path, we use this custom shape instead of the default circle
.
function liquidFillGaugeDefaultShape(g, radius, fillCircleRadius, color) {
g.append("polygon")
.attr("points", parseFloat(radius - fillCircleRadius) + ',' +
parseFloat(radius - fillCircleRadius) + ' ' +
parseFloat(radius) + ',' +
parseFloat(radius + fillCircleRadius) + ' ' +
parseFloat(radius + fillCircleRadius) + ',' +
parseFloat(radius + fillCircleRadius))
.style("fill", color)
}
(...)
var fillCircleGroup = gaugeGroup
.append("g")
.attr("clip-path", "url(#clipWave" + elementId + ")")
liquidFillGaugeDefaultShape(fillCircleGroup,
radius, fillCircleRadius, config.waveColor)
However, in order to convert this toy-visualization to a real map, we need to do some modifications regarding the clip shape. This is the desired end result.
Here we extend the loadLiquidFillGauge
function with an additional argument, shapeID
, which will be used as the path to be clipped, so a typical call will look like loadLiquidFillGauge(elementId, shapeID, value, config
). Then, we need to change the clipping method itself. This will be done in a few steps. Currently, the method assumes that the path to be clipped is located at 0, 0
. This leads to an incorrect clip path, therefore we need to get the bounding box and the location (center) of our shape. Then, the clipping path itself needs to be moved to the same location using transform translate
.
var BBox = d3.select('#' + shapeID).node().getBBox()
var radius = Math.min(parseInt(BBox.width), parseInt(BBox.height)) / 2;
var locationX = parseInt(BBox.width) / 2 - radius + BBox.x;
var locationY = parseInt(BBox.height) / 2 - radius + BBox.y;
(...)
fillCircleGroup.append(function () {
var shape = d3.select('#' + shapeID);
shape.attr('transform', 'translate(' +
(-locationX) + ',' + (-locationY) + ')')
.style("fill", config.waveColor)
return shape.node();
});
And there you have it! I’ve created two demo Observable notebooks as well, where you can play around with the code:
The visualization was created with D3.v3 because this is what I had more experience with and my lack of time to convert the legacy D3 liquid fill gauge to D3.v4 or D3.v5. So that’s a good plan for the near future. Also, there is a caveat that the liquid level inside the shapes will be just the distance between the bounding box‘s min and max y coordinates. So, theoretically, this could be improved by an area proportional representation to reduce distortion for highly asymmetric countries – i.e. the ones that are much narrower at their bottom than at their top such as Brazil or Norway. However, that’s a story for another time 🙂
If you like this stuff follow my GitHub, Twitter and Website. Or if it helps you a lot, consider a small donation on PayPal or in crypto.
Sharing is caring - and it also helps Dénes pay the rent :-)
Like this:
Like Loading...