Interpolators

Non-numeric range

When the range of a continuous scale is not an interval between real numbers, but an array of values of a non-numeric type (such as color codes), the scale must use a function to compute intermediate values between two given values. Such a function is called an interpolator.

The default interpolator is d3.interpolate(a,b). Actually, d3.interpolate(a,b) automatically detects the type of the values, by looking at the type of value b, and picks the appropriate interpolator for that type. It recognizes color codes, numbers, strings containing numbers, JavaScript date objects, arrays and objects.

Interval [a,b] represents the range of the scale. When d3.interpolate(a,b) is used on a d3 scale (like in the example below), arguments a and b are not explicitly given. The range of the scale is automatically passed to arguments a and b of the interpolator. The arguments a and b are only explicitly used when the interpolator is used as a "stand-alone" function (see later).

The default interpolator can be changed by using myScale.interpolate(interpolator). The next example calls the default interpolator in three different ways. Scale d3.scaleLinear automatically picks .interpolate(d3.interpolate) which automatically picks .interpolate(d3.interpolateRgb) for color types.


let colorScale;
colorScale = d3.scaleLinear([0, 20], ["Tomato", "Turquoise"]);
// is equivalent to: 
colorScale = d3.scaleLinear([0, 20], ["Tomato", "Turquoise"])
  .interpolate(d3.interpolate);
// is equivalent to:  
colorScale = d3.scaleLinear([0, 20], ["Tomato", "Turquoise"])
  .interpolate(d3.interpolateRgb);

Result:

Next example changes the default interpolator to d3.interpolateHclLong:


const colorScale = d3.scaleLinear([0, 20], ["Tomato", "Turquoise"])
  .interpolate(d3.interpolateHclLong); 
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:

PS. Compare the scale above with the visual representation of the previous color scale that uses default d3.interpolateRgb.

In the next example the range is an interval of JavaScript date objects. By default, d3.interpolate picks d3.interpolateDate for JavaScript date types.


const days = d3.scaleLinear().range([new Date("2009-01-01"), new Date("2009-12-31")]);
console.log(days(0.5)); // logs: Thu Jul 02 2009

Note that if the domain of a scale is not specified, it defaults to [0, 1]. The same thing holds for the range.

Note that interpolators maintain the linearity of the linear scale. Interpolators on other kinds of continuous scales also maintain the mapping function of the scale.

Round ranges

In fact, not only continuous scales with non-numeric ranges, but all scales, including the ones with numeric ranges, use an interpolator to compute intermediate values between two given values. When d3.interpolate recognizes a numeric range, it picks the default d3.interpolateNumber interpolator for number types:


let numbers;
numbers = d3.scaleLinear().range([20, 60]);
console.log(numbers(0.5)); // logs: 40
// is equivalent to:
numbers = d3.scaleLinear().range([20, 60]).interpolate(d3.interpolate);
console.log(numbers(0.5)); // logs: 40
// is equivalent to:
numbers = d3.scaleLinear().range([20, 60]).interpolate(d3.interpolateNumber);
console.log(numbers(0.5)); // logs: 40

For continuous scales with ranges with number values, the default interpolator d3.interpolateNumber can be changed to d3.interpolateRound. This rounds the returned values to integers.


let numbers;

numbers = d3.scaleLinear().range([0, 3]).interpolate(d3.interpolateRound);
console.log(numbers(0.6)); // logs: 2
console.log(numbers(0.7)); // logs: 2

// or a shorter version:

numbers = d3.scaleLinear().rangeRound([0, 3]);
console.log(numbers(0.6)); // logs: 2
console.log(numbers(0.7)); // logs: 2

d3.interpolateRound maps from a continuous input domain to a discrete output range (integers are discrete). An example with continuous input and discrete output (integers connected to "grades"):


const students = [
  { name: "Joe", score: 35},
  { name: "Abby", score: 86},
  { name: "Casey", score: 44},
  { name: "Max", score: 62}
];

const grades = d3.scaleLinear()
  .rangeRound([0, 5]);
  
d3.select("#container")
  .selectAll("span")
  .data(students)
  .join("span")
    .text(d => ` ${d.name} scored a ${["F", "E", "D", "C", "B", "A"][grades(d.score / 100)]}.`); // ["F", "E", "D", "C", "B", "A"][2] === "D"

Result:

BTW: The same thing could have been achieved by using d3.scaleQuantize instead of d3.scaleLinear (later more about quantize scales).

String and object ranges

By default, d3.interpolate uses d3.interpolateString on string valued ranges. The string interpolator finds numbers embedded in the strings and interpolate them. See "Stand alone" interpolators for an example.

For array or object valued ranges, d3.interpolate uses d3.interpolateArray and d3.interpolateObject respectively. If the array or object contains string values, d3.interpolateString will be used to interpolate, and if the array or object contains nested arrays of objects, d3.interpolateArray or d3.interpolateObject will be used recursively. Eventually d3.interpolateNumber will be used to interpolate all embedded numbers or embedded values that can be coerced to numbers.


const myScale = d3.scaleLinear()
  .range([[0, "0.5 mile", [12]], [10, "28 miles", [36]]]);
console.log(myScale(0.5)); // logs: [ 5, "14.25 miles", [24] ]

const myScale = d3.scaleLinear()
  .range([
    { time: 0, distance: "0.5 mile", details: { cost: 12 } },
    { time: 10, distance: "28 miles", details: { cost: 36 } }
  ]);
console.log(myScale(0.5)); // logs: { time: 5, distance: "14.25 miles", details: { cost: 24 } }

If object b in range([a, b]) contains enumerable properties that are not present in object a, then these properties are copied to the resulting interpolated object, without being changed. This provides a way to only interpolate some properties and leave the others unchanged:


const myScale = d3.scaleLinear()
  .range([
    { a: 0, c: 12 },
    { a: "10", b: "5 do not change", c: 36 }
  ]);
console.log(myScale(0.5)); // logs: { b: "5 do not change", a: "5", c: 24 }

The returned interpolated object is not a deep clone and is for every interpolation the same object:


const myScale = d3.scaleLinear()
  .range([
    { prop1: 0,  prop2: "0 cats",  prop3: { k: 0 } },
    { prop1: 10, prop2: "10 dogs", prop3: { k: 10 } }
  ]);
  
const a = myScale(0.5);
const b = myScale(0.7);
console.log(a.prop3 === b.prop3); // logs: true // (not a deep clone)
console.log(a.prop3); // logs: { k: 7 }
console.log(a === b); // logs: true
console.log(a); // logs: { prop1: 7, prop2: "7 dogs", prop3: { k: 7 } }