D3: Encoding values as circles
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:
-
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
-
Drawing an SVG circle requires a radius attribute.
To draw an SVG circle, the
cx
andcy
attributes are used to position the center of the element and ther
attribute is used to define the radius of the element. It is thisr
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.
-
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. ↩
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
<!DOCTYPE html>
<meta charset="utf-8">
<style>
html, body {
height: 100%;
margin: 0;
}
.chart {
width: 100%;
height: 100%;
display: block;
}
.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>
<svg class="chart js-chart"></svg>
<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>