Linear scales
Scales
In data visualization, scales are functions that map data (the input domain) to visual variables (the output range).
Typically the data will be the result of an observation, measured in some unit (degrees Celsius, Euros, seconds, an amount etc.). The range will typically represent visual variables on the screen, like a position (e.g. coordinates in pixels), color, stroke width in millimeters, symbol size, etc. Scales are an important and fundamental concept in data visualization. A well known example is the scale of a map, defining the ratio of a length on the map to the corresponding (measured) distance on the real-world ground, which is a linear scale.
D3 provides a number of different scale types, each with its own mapping function and types of input and output (quantitative or qualitative, continuous or discrete). Next an example of a linear scale with a specified domain and range (continuous intervals):
const domain = [0, 5];
const range = [0, 600];
let myRange;
myScale = d3.scaleLinear(domain, range);
// or:
myScale = d3.scaleLinear()
.domain(domain)
.range(range);
console.log(myScale(0)); // logs: 0
console.log(myScale(2)); // logs: 240
console.log(myScale(5)); // logs: 600
d3.scaleLinear
Linear scales are probably the most commonly used scales. Each domain value x is mapped to a range value y by the linear function y = mx + b. Constants m and b are defined by the given domain and range. Linear scales are first choice for continuous quantitative data because a difference between domain values is proportional to a corresponding differences between range values:
y1 = mx1 + b y2 = mx2 + b ======== − y1 − y2 = m(x1 − x2)
The next example uses d3.scaleLinear(domain, range)
:
const data = [
{ x: 1, y: 35},
{ x: 10, y: 16},
{ x: 8, y: 44},
{ x: 4, y: 62},
{ x: 2, y: 23},
];
const height = 100; // height of the canvas.
const width = 200; // width of the canvas.
const padding = {bottom: 10, top: 0, left: 15, right: 15}
const barWidth = 12;
const fontSize = 8;
const maxY = d3.max(data, d => d.y);
const minMaxX = d3.extent(data, d => d.x); // d3.extent() returns [min, max]. Equals:
// [d3.min(data, d => d.x), d3.max(data, d => d.x)]
const scaleX = d3.scaleLinear()
.domain(minMaxX).nice() // .nice() will be explained below.
.range([padding.left, width - padding.right]); // available horizontal space for the x-values in px
const scaleY = d3.scaleLinear()
.domain([0, maxY]).nice() // .nice() will be explained below.
.range([padding.top, height - padding.bottom]); // available vertical space for the bars in px
const svg = d3.select("svg")
.attr("font-family", "sans-serif")
.attr("font-size", fontSize)
.attr("text-anchor", "middle")
.attr("viewBox", `0 0 ${width} ${height}`);
const bar = svg
.selectAll('g')
.data(data)
.join('g')
.attr("transform", (d, i) => `translate(${scaleX(d.x)}, 0)`)
.call(g => g.append("rect")
.style('fill', "steelblue")
.attr('width', barWidth)
.attr('x', -barWidth/2)
.attr('y', padding.top)
.attr('height', (d, i) => scaleY(d.y))
)
.call(g => g.append("text")
.style('fill', "gray")
.attr('dy', (d, i) => (scaleY(d.y) + fontSize))
.text((d, i) => d.y )
);
Result:
PS. As mentioned before, this construct is not suitable for situations where data gets updated.
Nice ticks
Method nice()
extends the domain so that it starts (rounding down) and ends (rounding up) with "nice" values.
The bounds are not necessarily rounded to the nearest integer. They are rounded based on approximately
10 values, so called ticks, uniformly spaced within the domain, and having "nice" human-readable values.
The number of ticks can be specified (but is only a hint), changing the step size between the ticks, and consequently changing the rounding of the bounds.
let x;
x = d3.scaleLinear([0.241079, 0.969679], [0, 960]).nice();
console.log(x.domain()); // logs: [ 0.2, 1 ]
console.log(x.ticks()); // logs: [ 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1 ]
console.log(x.ticks().length); // logs: 9
x = d3.scaleLinear([0.241079, 0.969679], [0, 960]).nice(40);
console.log(x.domain()); // logs: [ 0.24, 0.98 ]
console.log(x.ticks(40)); // logs: [ 0.24, 0.26, 0.28, 0.3, 0.32, 0.34, 0.36, 0.38, 0.4, 0.42, … ]
console.log(x.ticks(40).length); // logs: 38
Flipping the range
In <canvas>
and SVG the coordinate system's origin, (0,0), is the top left corner.
The orientation of the y-axis is from top to bottom.
However, in a data visualization we usually expect the minimum value to be placed "lower" on the image than the maximal value.
We can "flip" the range, in the sense that it goes from height
to 0
, to adjust the visualization more to the viewer's expectations:
//...
const scaleX = d3.scaleLinear()
.domain(minMaxX).nice()
.range([padding.left, width - padding.right]);
const scaleY = d3.scaleLinear()
.domain([0, maxY]).nice()
.range([height - padding.bottom, padding.top]); // flipped range
//...
svg.selectAll('g')
.data(data)
.join('g')
.attr("transform", (d, i) => `translate(${scaleX(d.x)}, 0)`)
.call(g => g.append("rect")
.style('fill', "steelblue")
.attr('width', barWidth)
.attr('x', -barWidth/2)
.attr('y', d => scaleY(d.y))
.attr('height', d => height - padding.bottom - scaleY(d.y))
)
.call(g => g.append("text")
.style('fill', "gray")
.attr('dy', (d, i) => (scaleY(d.y) - fontSize/4))
.text((d, i) => d.y )
);
Result:
Clamping
If the data contains a value outside the scale's domain, the scale will calculate the mapped value
using y = mx + b.
But this may not be desirable. Method scale.clamp()
enforces strict bounds on the domain.
A clamped scale returns the lower or upper range bound, for data outside the domain interval.
const myScale = d3.scaleLinear()
.domain([0, 10])
.range([0, 1])
.clamp(true);
console.log(myScale(-1)); // logs: 0
console.log(myScale(15)); // logs: 1
myScale.clamp(false)
console.log(myScale(-1)); // logs: -0.1
console.log(myScale(15)); // logs: 1.5
Unknown
Method unknown(value)
can be used to specifying what value the scale must return for missing or invalid data
(NaN
, undefined
, or a value that cannot be converted into a number).
If value
is not specified, the default value undefined
is returned.
The next example specifies a gray color for unknown data:
const colorScale = d3.scaleLinear([0, 20], ["magenta", "lime"]).unknown("gray");
d3.select("#container")
.selectAll("span")
.data([NaN,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20])
.join("span")
.style("background", d => colorScale(d))
Result:
See next chapter for color code ranges.
Piecewise scales
Domain and range can be an array of more than two values. With 3 or more values the scale is subdivided into multiple segments:
const colorScale = d3.scaleLinear([0, 10, 20], ["magenta", "cyan", "lime"]);
d3.select("#container")
.selectAll("span")
.data([0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20])
.join("span")
.style("background", d => colorScale(d))
Result:
Invert
Returns the corresponding value from the domain, given a value from the range. Scales with a non-numeric range (such as a color range) are not invertible.
const myScale = d3.scaleLinear()
.domain([0, 10])
.range([0, 100]);
console.log(myScale.invert(50)); // logs: 5
console.log(myScale.invert(100)); // logs: 10
Inversion is commonly used for interactions, like returning the data value corresponding to the position of the mouse:
<div id="container">
<svg>
<g>
<g id="axisX"></g>
<g id="axisY"></g>
<rect></rect>
</g>
</svg><br/>
<span>Click in the graph</span>
</div>
<script>
const width = 400;
const height = 200;
const scaleX = d3.scaleLinear()
.domain([-50, 50]).nice()
.range([0, width]);
const scaleY = d3.scaleLinear()
.domain([0, 50]).nice()
.range([0, height]);
d3.select("#container svg")
.attr("width", width + 30)
.attr("height", height + 30);
d3.select("#container svg > g")
.attr("transform", "translate(20, 10)")
d3.select("#container rect")
.attr("width", width)
.attr("height", height)
.style("cursor", "crosshair")
.on('click', function(e) {
const [x, y] = d3.pointer(e);
const valueX = scaleX.invert(x);
const valueY = scaleY.invert(y);
d3.select('#container span')
.text('You clicked ' + valueX.toFixed(2) + ', ' + valueY.toFixed(2));
});
// Construct axes:
d3.select('#axisX')
.attr("transform", `translate(0, ${height})`)
.call(d3.axisBottom(scaleX));
d3.select('#axisY')
.call(d3.axisLeft(scaleY));
</script>
Result:
Click in the graph
BTW: The construction of axes will be covered later.
BTW: Earlier we showed an example that returned the mouse position representing the SVG dimensions of the rectangle, instead of representing coordinates in the graph in accordance with the scales.
Time scales
Time scales are similar to d3.scaleLinear
, except their domain expect an array
of JavaScript Date
objects.
They are very useful in visualizations where data is presented in time marks.
d3.scaleTime()
The domain of d3.scaleTime()
defaults to one day, from 00:00 hours on 2000-01-01 to 00:00 hours on 2000-01-02, in the time zone
set in the user’s environment (e.g. their browser).
The default range is [0,1]
, the same as the default range of d3.scaleLinear
.
const myTimeScale = d3.scaleTime();
console.log(myTimeScale.domain()); // logged on my computer: [Sat Jan 01 2000 00:00:00 GMT+0100, Sun Jan 02 2000 00:00:00 GMT+0100]
console.log(myTimeScale.range()); // logs: [ 0, 1 ]
const myTimeScale = d3.scaleTime()
.domain([new Date(2023, 0, 1), new Date(2023, 6, 12)])
.range([ 0, width ])
.nice();
d3.scaleUtc()
d3.scaleUtc()
acts the same as d3.scaleTime()
,
except the returned time scale operates in
Coordinated Universal Time,
rather than local time.
d3.scaleUtc()
behaves more predictable than d3.scaleTime()
:
days are always twenty-four hours and the scale does not depend on the browser’s time zone.
d3.scaleUtc()
should in most cases be the preferred scale.
An example:
const myData = [
new Date(2023, 0, 2),
new Date(2023, 2, 13),
new Date(2023, 4, 22),
new Date(2023, 6, 12)];
const htmlContainer = d3.select("#container");
const margin = {top: 30, right: 20, bottom: 20, left: 20},
svgWidth = 400,
svgHeight = 60,
width = svgWidth - margin.left - margin.right,
height = svgHeight - margin.top - margin.bottom;
const svgContainer = htmlContainer
.append("svg")
.attr("width", "100%")
.attr("height", "100%")
.attr("viewBox", `0 0 ${svgWidth} ${svgHeight}`)
.attr("font-family", "sans-serif")
.attr("font-size", 8)
.attr("text-anchor", "middle")
.append("g")
.attr("transform",
`translate(${margin.left},${margin.top})`);
const myTimeScale = d3.scaleUtc()
.domain(d3.extent(myData))
.range([ 0, width ])
.nice();
const horAxis = svgContainer.append("g")
.attr("transform", "translate(0,0)")
.call(d3.axisBottom(myTimeScale).ticks(5, "%b %y"));
const marks = svgContainer.append("g");
const mark = marks
.selectAll('g')
.data(myData)
.join('g')
.attr("transform", d => `translate(${myTimeScale(d)},0)`)
.call(g => g.append("circle")
.style('fill', "steelblue")
.attr('r', 3)
.attr('cx', 0)
.attr('cy', 0)
)
.call(g => g.append("text")
.style('fill', "gray")
.attr('dy', -10)
.text((d, i) => d.toLocaleString("en-US",
{
year: "numeric",
month: "short",
day: "numeric",
}))
);
Result:
BTW: The construction of axes will be covered later.
BTW:
The example above uses
Date.toLocaleString
(native JavaScript), which is supported in modern browsers, to format the dates above the bullets.