Skip to content

Instantly share code, notes, and snippets.

@guilhermesimoes
Last active April 6, 2020 01:01
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save guilhermesimoes/e6356aa90a16163a6f917f53600a2b4a to your computer and use it in GitHub Desktop.
Save guilhermesimoes/e6356aa90a16163a6f917f53600a2b4a to your computer and use it in GitHub Desktop.
D3.js: Encoding values as circles
license: gpl-3.0

This gist demonstrates why it's a mistake to linearly map a data value to a circle radius.

Notice that in the first example, while 50 is only 2 times smaller than 100, the circle that encodes the value 50 is 4 times smaller than the circle that encodes the value 100. Even worse, while 10 is only 10 times smaller than 100, the circle that encodes the value 10 is 100 times smaller than the circle that encodes the value 100!

The occurrence of this mistake stems from 2 factors:

  1. Misunderstanding how we visually interpret data.

When we look at a bar chart we compare the length of each bar. When we look at a waffle chart we compare the number of squares of — or the area taken by — each category. Likewise, when we look at a bubble chart we compare the area of each bubble.

Perceptually, we understand the overall amount of “ink” or pixels to reflect the data value. 1

  1. Drawing an SVG circle requires a radius attribute.

To draw an SVG circle, the cx and cy attributes are used to position the center of the element and the r attribute is used to define the radius of the element. It is this r attribute that makes it natural to assume a direct mapping between data value and circle radius.

But why does this mapping visually distort data?

Let's use two simple data values: 6 and 3. 6 is twice as large as 3 so the proportion between the two numbers is 2.

Now, if we'll recall, the area of a circle equals pi times the radius squared, or A = πr².

Applying part of this formula we square our values and get 36 and 9. Notice now that 36 is 4 times as large as 9 so the proportion between the two numbers changed from 2 to 4. (Multiplying each value by π won't change the proportion between them given that π is a constant.)

It is this change in proportion that leads to a misrepresentation of the data.

Since it is the quadratic function that is causing this, we need to counteract its effects by applying its inverse function — the square-root function.

If we square-root our values 36 and 9 we get 6 and 3 — right back where we started. 6 is twice as large as 3 so the proportion between the two numbers remains 2.

There are many ways to solve this issue in the D3 world, as evidenced in this gist, but the simplest one is to use a square-root scale to compute the appropriate circle radius. This way the area of each circle is proportional to the data value it's representing.


1. This is why the US presidential election map is not only unhelpful but actually misleading. A more perceptive and informative alternative is a tilegram or hexagon tile grid map.

<!DOCTYPE html>
<meta charset="utf-8">
<style>
html, body {
height: 100%;
margin: 0;
}
.chart-container {
box-sizing: border-box;
height: 100%;
padding: 15px;
}
.chart {
width: 100%;
height: 100%;
overflow: visible;
}
.chart .axis path,
.chart .axis line {
fill: none;
stroke: none;
}
.chart .axis text {
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 16px;
}
.chart .linear-scale-value-radius {
fill: rgb(200, 70, 50);
}
.chart .linear-scale-value-area,
.chart .linear-scale-sqrt-value-radius {
fill: rgb(160, 200, 80);
}
.chart .sqrt-scale-value-radius {
fill: rgb(70, 180, 130);
}
</style>
<body>
<div class="chart-container">
<svg class="chart js-chart"></svg>
</div>
<script src="https://d3js.org/d3.v5.min.js"></script>
<script type="text/javascript">
"use strict";
/* global d3, document */
var Chart = function (selector, options) {
this.el = d3.select(selector)
.attr("viewBox", "0 0 " + options.width + " " + options.height);
this.width = options.width - this.margin.left - this.margin.right;
this.height = options.height - this.margin.top - this.margin.bottom;
this.setScales();
this.setAxes();
this.setLayers();
};
Chart.prototype = {
margin: { top: 20, right: 40, bottom: 0, left: 100 },
scales: {},
axes: {},
layers: {},
setScales: function () {
this.scales.x = d3.scalePoint()
.range([0, this.width])
.padding(0.5)
.align(1);
this.scales.y = d3.scaleBand()
.range([0, this.height])
.paddingInner(0.3);
},
setAxes: function () {
this.axes.x = d3.axisTop()
.scale(this.scales.x);
this.axes.y = d3.axisRight()
.scale(this.scales.y);
},
setLayers: function () {
this.layers.main = this.el.append("g")
.attr("transform", "translate(" + this.margin.left + "," + this.margin.top + ")");
},
draw: function (data) {
var strategies;
strategies = [
{
desc: "linear scale value ↦ radius",
className: "linear-scale-value-radius",
scaleBuilder: this.buildLinearScaleValueToRadius.bind(this)
},
{
desc: "linear scale value ↦ area",
className: "linear-scale-value-area",
scaleBuilder: this.buildLinearScaleValueToArea.bind(this)
},
{
desc: "linear scale √value ↦ radius",
className: "linear-scale-sqrt-value-radius",
scaleBuilder: this.buildLinearScaleSqrtValueToRadius.bind(this)
},
{
desc: "sqrt scale value ↦ radius",
className: "sqrt-scale-value-radius",
scaleBuilder: this.buildSqrtScaleValueToRadius.bind(this)
}
];
this.scales.x.domain(data);
this.scales.y.domain(strategies.map(function (strategy) {
return strategy.desc;
}));
strategies.forEach(function (strategy) {
this.drawCircles(strategy, data);
}, this);
this.drawAxes();
},
drawCircles: function (strategy, data) {
var scale, strategyLayer, circles;
scale = strategy.scaleBuilder(data);
strategyLayer = this.layers.main.append("g")
.attr("class", strategy.className)
.attr("transform", "translate(0," + this.scales.y(strategy.desc) + ")");
circles = strategyLayer.selectAll(".circle").data(data)
.enter().append("circle")
.attr("class", "circle")
.attr("cx", this.scales.x)
.attr("cy", this.scales.y.bandwidth() / 2)
.attr("r", scale);
circles.append("title")
.text(function (d) {
var radius, area;
radius = scale(d);
area = Math.PI * Math.pow(radius, 2);
return "Area: " + d3.format("r")(area);
});
},
buildLinearScaleValueToRadius: function (data) {
var maxValue, maxCircleRadius;
maxValue = d3.max(data);
maxCircleRadius = d3.min([this.scales.y.bandwidth(), this.scales.x.step()]) / 2;
return d3.scaleLinear()
.domain([0, maxValue])
.range([0, maxCircleRadius]);
},
buildLinearScaleValueToArea: function (data) {
var maxValue, maxCircleRadius, maxCircleArea, circleAreaScale;
maxValue = d3.max(data);
maxCircleRadius = d3.min([this.scales.y.bandwidth(), this.scales.x.step()]) / 2;
maxCircleArea = Math.PI * Math.pow(maxCircleRadius, 2);
circleAreaScale = d3.scaleLinear()
.domain([0, maxValue])
.range([0, maxCircleArea]);
return function circleRadius (d) {
var area;
area = circleAreaScale(d);
return Math.sqrt(area / Math.PI);
};
},
buildLinearScaleSqrtValueToRadius: function (data) {
var maxValue, maxCircleRadius, circleRadiusScale;
maxValue = Math.sqrt(d3.max(data));
maxCircleRadius = d3.min([this.scales.y.bandwidth(), this.scales.x.step()]) / 2;
circleRadiusScale = d3.scaleLinear()
.domain([0, maxValue])
.range([0, maxCircleRadius]);
return function circleRadius (d) {
return circleRadiusScale(Math.sqrt(d));
};
},
buildSqrtScaleValueToRadius: function (data) {
var maxValue, maxCircleRadius;
maxValue = d3.max(data);
maxCircleRadius = d3.min([this.scales.y.bandwidth(), this.scales.x.step()]) / 2;
return d3.scalePow().exponent(0.5)
.domain([0, maxValue])
.range([0, maxCircleRadius]);
},
drawAxes: function () {
this.layers.main.append("g")
.attr("class", "axis axis--x")
.call(this.axes.x);
this.layers.main.append("g")
.attr("class", "axis axis--y")
.attr("transform", "translate(-" + this.margin.left + ",0)")
.call(this.axes.y);
}
};
var options = {
width: 600,
height: 300
};
var chart = new Chart(".js-chart", options);
chart.draw([10, 50, 100]);
</script>
</body>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment