Continuous non-linear scales
Scales for continuous quantitative data
In previous chapters we covered linear scales d3.scaleLinear
, d3.scaleTime
, d3.scaleUtc
,
d3.scaleSequential
and d3.scaleDiverging
.
D3 also provides some non-linear scales for continuous quantitative data.
In this chapter we will explore power scales, and more specifically the square root scale, and logarithmic scales.
Power scales
A power scale maps each range value y to a domain value x by the function y = mxk + b, where k is the exponent. The default exponent is 1, which yields a linear scale, and the exponent can be any real number, except 0. Constants m and b are defined by the given domain and range.
let myScale = d3.scalePow()
.domain([0, 100])
.range([0, 16])
.exponent(2);
console.log(myScale(50)); // logs: 4 // 50 (1/2 * 100) => 4 (1/4 * 16)
Square root scales
d3.scaleSqrt
is short for d3.scalePow().exponent(0.5)
.
A square root scale is often used where the output y is a radius of a disk,
but it is the disk's area that needs to be proportional
to the input value x.
The area is proportional to the square of the radius
(A = π r 2),
so conversely, the radius y needs to be proportional to the square root of the input value x.
π y 2 = cx // area proportional to input value y 2 = nx y = m√x
const countries = [
{name: "Germany", population: 84_270_625},
{name: "France", population: 68_042_591},
{name: "Spain", population: 47_222_613},
{name: "the Netherlands", population: 17_882_900},
{name: "Sweden", population: 10_481_937},
];
const largestPopulation = d3.max(countries, d => d.population);
const areaScale = d3.scaleSqrt()
.domain([0, largestPopulation])
.range([0, 50]);
const colorScale = d3.scaleOrdinal(d3.schemeCategory10.slice(10 - countries.length)); //*
const largestDiskDiameter = areaScale(largestPopulation) * 2;
const fontSize = 10;
const svg = d3.select("svg")
.attr("width", "100%")
.attr("height", "100%")
.attr("font-family", "sans-serif")
.attr("font-size", fontSize)
.attr("text-anchor", "middle")
.attr("viewBox", `0 0 ${countries.length * largestDiskDiameter} ${largestDiskDiameter}`);
const disks = svg.selectAll("g")
.data(countries)
.join("g")
.attr("transform", (d, i) => `translate(${i * largestDiskDiameter + largestDiskDiameter/2}, ${largestDiskDiameter/2})`)
.call(g => g.append("circle")
.style('fill', (d, i) => colorScale(d.name))
.attr("r", (d, i) => areaScale(d.population))
)
.call(g => g.append("text")
.style('fill', "var(--main-color)")
.attr('dy', fontSize/2)
.text(d => d.name)
);
Result:
*) See d3 categorical schemes for d3.schemeCategory10
.
d3.scaleOrdinal
will be explained later.
When an input value is negative, the scale first computes the output for the absolute (positive) value, and then negates the result.
Also scale d3.scaleRadial
is available that
does the same thing as d3.scaleSqrt
for numeric ranges.
Unlike d3.scaleSqrt
, radial scales do not support interpolate.
let sqrtScale = d3.scaleSqrt()
.domain([0, 100])
.range([0, 30]);
console.log(sqrtScale(25)); // logs: 15
console.log(sqrtScale(-25)); // logs: -15
sqrtScale = d3.scaleRadial()
.domain([0, 100])
.range([0, 30]);
console.log(sqrtScale(25)); // logs: 15
console.log(sqrtScale(-25)); // logs: -15
sqrtScale = d3.scaleSqrt([0, 100], ["red", "blue"]);
console.log(sqrtScale(25)); // logs: "rgb(128, 0, 128)"
sqrtScale = d3.scaleRadial([0, 100], ["red", "blue"]);
console.log(sqrtScale(25)); // logs: undefined
Logarithmic scales
Logarithmic scales are obviously suitable for data that are intrinsically logarithmic, like magnitude on the Richter scale as a function of amplitude. Logarithmic scales are also very suitable for wide-range data visualizations.
Suppose our data are the grains of wheat on each chessboard square in the wheat and chessboard problem (and we stop at square 15). This results in a very wide range of values. The numbers of grains on the first 4 squares are successively 1, 2, 4, 8, but on square 15 there are 16 384 grains. Visualizing this in a graph makes it impossible to distinguish the smallest values, unless the graph has the height of an apartment building.
For wide-range problems we can use a logarithmic scale. Instead of equally spacing the numbers 0, 1, 2, 3, 4..., we now equally space b 0, b 1, b 2, b 3 ..., Where b is the base of the logarithm. A logarithmic scale maps domain values x to range values y by the function y = m logb(x) + c. Note that in this function x and y are domain (input) values and range (output) values of the scale, not of the plotted function. Each individual axis in a data visualization may or may not have a logarithmic scale.
See "a closer look at the logarithmic scale" for more information on logarithmic scales.
Next are two plots of the wheat and chessboard problem. The first has a linear scaled x-axis and y-axis, the second has a logarithmic scale (base 2) on the y-axis and a linear scale on the x-axis. The logarithmic scale spans several orders of magnitude, yet it is still possible to read the smallest values on the graph.
A linear plot (linear scale on the y-axis, linear scale on the x-axis) of f(n) = 2 n.
A log–linear plot (logarithmic scale on the y-axis, linear scale on the x-axis) of f(n) = 2 n. The base of the logarithm is 2.
The domain of a logarithmic scale must not include or cross zero, since logb(x) → −∞ when x → 0 for b > 1 and logb(x) → ∞ when x → 0 for b < 1. The behavior of the scale is undefined if you pass a negative value to a log scale with a positive domain or vice versa. If the domain spans negative numbers, the absolute value is taken.
The base can be specified by a base()
method:
const myScale = d3.scaleLog()
.base(2)
.domain([2**-10, 2**20]) // !! A log scale domain must be strictly-positive or strictly-negative, the domain must not include or cross zero.
.range([ height, 0 ])
.nice();
If base()
is not present, the default base 10 will be used. Next example uses base 10:
const objects = [
{ name: "Milky Way Galaxy", size: 1e18 }, // sizes in km
{ name: "Nearest Star", size: 1e13 },
{ name: "The Solar System", size: 1e9 },
{ name: "The Sun", size: 1e6 },
{ name: "The Earth", size: 1e3 },
{ name: "A Mountain", size: 75 },
{ name: "A Human", size: 1e-3 },
{ name: "A Cell", size: 1e-8 },
{ name: "An Atom", size: 1e-12 },
{ name: "A Proton", size: 1e-15 }
];
const htmlContainer = d3.select("#container");
const margin = {top: 10, right: 10, bottom: 30, left: 60},
svgWidth = 300,
svgHeight = 400,
width = svgWidth - margin.left - margin.right,
height = svgHeight - margin.top - margin.bottom;
const svgContainer = htmlContainer
.append("svg")
.attr("width", svgWidth)
.attr("height", svgHeight)
.append("g")
.attr("transform",
`translate(${margin.left},${margin.top})`);
const scaleUniverse = d3.scaleLog()
.domain([1e-15, 1e20])
.range([ height, 0 ])
.nice();
const vertAxis = svgContainer.append("g")
.attr("transform", "translate(0,0)")
.call(d3.axisLeft(scaleUniverse).ticks(10, "~e"))
const marks = svgContainer.append("g")
.selectAll('g')
.data(objects)
.join('g')
.attr("transform", d => `translate(0, ${scaleUniverse(d.size)})`)
.call(g => g.append("circle")
.attr('r', 4)
.attr("fill", "steelblue")
)
.call(g => g.append("text")
.style('fill', "gray")
.attr("font-size", 12)
.attr("dominant-baseline", "middle")
.attr('dx', 10)
.text(d => d.name)
);
Result:
Adapted from Observable notebook: Continuous scales.
Symlog scales
d3.scaleSymLog
provides a modified logarithmic transformation, a bi-symmetric log transformation,
particularly suitable for representing wide-range data that has both positive and negative components.
Unlike a log scale, a scaleSymLog
domain can include zero.
A single constant (symlog.constant()
) (defaults to 1) is provided to tune the transformation's behavior around the "region near zero".
The next example uses d3.scaleSymLog
for data much denser around zero than further away from zero
(the days around "now" are much denser in data points than the long-gone past and far future):
const days = [
// l: label ; v: value
{ l: "The Big Bang", v: -13.8e9 * 365.24 },
{ l: "Dinosaur extinction", v: -65e6 * 365.24 },
{ l: "The founding of Rome", v: -(800 + 2019) * 365.24 },
{ l: "Last year", v: -365 },
{ l: "Yesterday", v: -1 },
{ l: "Now", v: +0 },
{ l: "Tomorrow", v: +1 },
{ l: "Next year", v: +365 },
{ l: "2100", v: +365.24 * 91 },
{ l: "Asimov’s Foundation", v: +12000 * 365.24 },
{ l: "Sun dies", v: 6e9 * 365 }
];
const htmlContainer = d3.select("#container");
const margin = {top: 10, right: 10, bottom: 30, left: 60},
svgWidth = 300,
svgHeight = 600,
width = svgWidth - margin.left - margin.right,
height = svgHeight - margin.top - margin.bottom;
const svgContainer = htmlContainer
.append("svg")
.attr("width", svgWidth)
.attr("height", svgHeight)
.append("g")
.attr("transform",
`translate(${margin.left},${margin.top})`);
const scale = d3.scaleSymlog()
//.domain(d3.extent(days, d => d.v))
.domain([d3.max(days, d => d.v), d3.min(days, d => d.v)])
.constant(0.1)
.range([ height, 0 ]);
const mark = svgContainer.append("g")
.selectAll('g')
.data(days)
.join('g')
.attr("transform", d => `translate(0, ${scale(d.v)})`)
.call(g => g.append("circle")
.attr('r', 4)
.attr("fill", d => (d.l === "Now" ? "red" : "steelblue"))
)
.call(g => g.append("text")
.style('fill', "gray")
.attr("font-size", 12)
.attr("dominant-baseline", "middle")
.attr('dx', 10 )
.text(d => d.l )
);
const vertLine = svgContainer.append("line")
.attr("x1", 0)
.attr("x2", 0)
.attr("y1", scale.range()[0])
.attr("y2", scale.range()[1])
.attr("stroke", "gray");
Result:
Example adapted from Observable notebook: Continuous scales.
BTW: The slider is an addition to the code presented above. View the source code to see how the slider is implemented.