charts.scale
Map data values onto screen positions and back: linear, log, time, band and point scales, and nice domains.
1.1.0 · published 2026-10-03 by charlie · Anterra
Pinned by 93 tests, run in TypeScript, Python and Rust.linearScale 14 · invertLinear 8 · niceDomain 12 · logScale 17 · timeScale 15 · bandScale 16 · pointScale 11
What it does
A linear scale maps a data value onto a position: `linearScale([0, 100], [0, 500], 25, false)` is `125`, a quarter of the way along a 500 pixel axis. `invertLinear` goes the other way, from a pointer position back to the value under it, and `niceDomain` widens a data extent such as `[0.13, 9.7]` to `[0, 10]` so the axis starts and ends on a tick.
This is a group: three functions that only make sense together, in one package, each in its own file. Install only what you call with `require charts.scale ^1.0.0 only=linearScale`; `invertLinear` brings `linearScale` with it, because it reuses its arithmetic.
The functions
A group: 7 functions that work together, each in its own file, each pinned by its own tests in TypeScript, Python and Rust. A project can install only the ones it calls.
- linear_scale (domain: float[], range: float[], value: float, clamp: bool) -> float
- invert_linear (domain: float[], range: float[], value: float) -> float
- nice_domain (domain: float[], count: int) -> NiceDomain
- log_scale (domain: float[], range: float[], value: float, clamp: bool) -> float
- time_scale (domain: date[], range: float[], value: date, clamp: bool) -> float
- band_scale (domain: string[], range: float[], value: string, paddingInner: float, paddingOuter: float, align: float) -> Band
- point_scale (domain: string[], range: float[], value: string, padding: float, align: float) -> float
The types it declares, generated into your project
/// A domain widened to round numbers, and the tick step that made them round.
#[derive(Debug, Clone, PartialEq)]
pub struct NiceDomain {
/// [from, to], in the order given
pub domain: Vec<f64>,
/// 1, 2 or 5 times a power of ten
pub step: f64,
}
/// Where one category's band sits along the range.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Band {
/// the band's lower coordinate
pub start: f64,
/// start + width / 2, where a label or tick goes
pub center: f64,
/// the band's width, the same for every category
pub width: f64,
}
Once installed, your code imports each one from the group's module.
linear_scale throws on bad input 14 tests
pub fn linear_scale(domain: &[f64], range: &[f64], value: f64, clamp: bool) -> f64
| domain | float[] | the data extent [from, to]; from and to may be in either order but not equal |
| range | float[] | the output extent [from, to], usually pixels; may be reversed for a y axis |
| value | float | |
| clamp | bool | keep the result inside the range when value is outside the domain |
| returns | float | rounded to 6 decimal places |
For example
linear_scale(0, 100, 0, 500, 25, false)→ 125 a quarter of the way along a 500 pixel axislinear_scale(0, 100, 0, 500, 0, false)→ 0 the start of the domain is the start of the rangelinear_scale(0, 100, 0, 500, 100, false)→ 500 the end of the domain is the end of the range
fune!(charts.scale@^1); // then call linear_scale(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
/// Where `value` lands on the range, as a straight-line map from the domain.
/// Outside the domain the line carries on, unless `clamp` stops it at the
/// range's ends.
///
/// # Panics
/// Panics if the domain or range does not have exactly two values, or the
/// domain's ends are equal.
pub fn linear_scale(domain: &[f64], range: &[f64], value: f64, clamp: bool) -> f64 {
let (d0, d1) = pair(domain, "domain");
let (r0, r1) = pair(range, "range");
let mut t = normalise(d0, d1, value, "domain");
if clamp {
t = t.max(0.0).min(1.0);
}
round6(interpolate(r0, r1, t))
}
// The helpers below are shared with invert_linear, which is the same line read
// the other way; they are not part of the group's contract.
pub fn pair(values: &[f64], what: &str) -> (f64, f64) {
if values.len() != 2 {
panic!("{} must have exactly 2 values, [from, to]; got {}", what, values.len());
}
(values[0], values[1])
}
pub fn normalise(from: f64, to: f64, value: f64, what: &str) -> f64 {
// Equal ends would divide by zero and answer NaN or infinity, which then
// draws nothing without saying why.
if from == to {
panic!("{} has no width: both ends are {}", what, from);
}
(value - from) / (to - from)
}
pub fn interpolate(from: f64, to: f64, t: f64) -> f64 {
from + t * (to - from)
}
/// Half up to 6 decimal places, the same way in every language.
pub fn round6(x: f64) -> f64 {
(x * 1e6 + 0.5).floor() / 1e6
}
/// A JSON list of numbers, for the vector adapters.
pub fn floats(value: &Value) -> Vec<f64> {
value.as_arr().iter().map(|v| v.as_f64()).collect()
}
pub fn fune_vector(args: &[Value]) -> Value {
Value::Float(linear_scale(
&floats(&args[0]),
&floats(&args[1]),
args[2].as_f64(),
args[3].as_bool(),
))
}invert_linear throws on bad input 8 tests
pub fn invert_linear(domain: &[f64], range: &[f64], value: f64) -> f64
| domain | float[] | |
| range | float[] | |
| value | float | a position in the range, such as a mouse coordinate |
| returns | float | the data value at that position, rounded to 6 decimal places |
For example
invert_linear(0, 100, 0, 500, 125)→ 25 a pixel back to its valueinvert_linear(0, 100, 0, 500, 0)→ 0 the start of the range is the start of the domaininvert_linear(0, 50, 300, 0, 240)→ 10 a reversed range
fune!(charts.scale@^1); // then call invert_linear(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::charts_scale_linear_scale::{floats, interpolate, normalise, pair, round6}; ← linearScale, another function of this group · built into the same file, even by a slim install
/// The domain value at a position in the range: linear_scale read backwards.
/// Never clamped; a position past the end of the axis is a value past the end
/// of the data.
///
/// # Panics
/// Panics if the domain or range does not have exactly two values, or the
/// range's ends are equal.
pub fn invert_linear(domain: &[f64], range: &[f64], value: f64) -> f64 {
let (d0, d1) = pair(domain, "domain");
let (r0, r1) = pair(range, "range");
round6(interpolate(d0, d1, normalise(r0, r1, value, "range")))
}
pub fn fune_vector(args: &[Value]) -> Value {
Value::Float(invert_linear(&floats(&args[0]), &floats(&args[1]), args[2].as_f64()))
}nice_domain throws on bad input 12 tests
pub fn nice_domain(domain: &[f64], count: i64) -> NiceDomain
| domain | float[] | |
| count | int | roughly how many ticks the axis will have, at least 1 |
| returns | NiceDomain |
For example
nice_domain(3, 97, 10)→ domain 0, 100, step 10 widens to whole tensnice_domain(0.13, 0.87, 10)→ domain 0.1, 0.9, step 0.1 small numbers widen to tenthsnice_domain(0.13, 9.7, 5)→ domain 0, 10, step 2 a messy extent becomes 0 to 10
fune!(charts.scale@^1); // then call nice_domain(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
/// Widen a domain to multiples of a round tick step (1, 2 or 5 times a power
/// of ten) chosen for about `count` ticks. Widening can change the best step,
/// so it repeats until the step settles, as d3's nice() does.
///
/// # Panics
/// Panics if the domain does not have exactly two values or `count` is less
/// than 1.
pub fn nice_domain(domain: &[f64], count: i64) -> NiceDomain {
if domain.len() != 2 {
panic!("domain must have exactly 2 values, [from, to]; got {}", domain.len());
}
if count < 1 {
panic!("count must be a whole number of at least 1, got {}", count);
}
let reversed = domain[1] < domain[0];
let (mut lo, mut hi) = if reversed { (domain[1], domain[0]) } else { (domain[0], domain[1]) };
if lo == hi {
return NiceDomain { domain: vec![domain[0], domain[1]], step: 0.0 };
}
let mut step = 0.0;
for _ in 0..10 {
let next = tick_step(lo, hi, count as f64);
if next == step {
break;
}
step = next;
if step >= 1.0 {
lo = (lo / step).floor() * step;
hi = (hi / step).ceil() * step;
} else {
// A fractional step divides badly (0.3 / 0.1 is 2.9999999999999996),
// so multiply by its whole-number inverse instead.
let inverse = (1.0 / step + 0.5).floor();
lo = (lo * inverse).floor() / inverse;
hi = (hi * inverse).ceil() / inverse;
}
}
let ends = if reversed { vec![round6(hi), round6(lo)] } else { vec![round6(lo), round6(hi)] };
NiceDomain { domain: ends, step: round6(step) }
}
fn tick_step(lo: f64, hi: f64, count: f64) -> f64 {
let raw = (hi - lo) / count;
// Powers of ten by repeated multiplication rather than log10, whose last
// digit is not guaranteed to agree between languages.
let mut power = 1.0;
while power * 10.0 <= raw {
power *= 10.0;
}
while power > raw {
power /= 10.0;
}
let error = raw / power;
let factor = if error >= 50f64.sqrt() {
10.0
} else if error >= 10f64.sqrt() {
5.0
} else if error >= 2f64.sqrt() {
2.0
} else {
1.0
};
factor * power
}
fn round6(x: f64) -> f64 {
(x * 1e6 + 0.5).floor() / 1e6
}
pub fn nice_domain_to_value(nice: &NiceDomain) -> Value {
Value::obj(vec![
("domain", Value::Arr(nice.domain.iter().map(|x| Value::Float(*x)).collect())),
("step", Value::Float(nice.step)),
])
}
pub fn fune_vector(args: &[Value]) -> Value {
let domain: Vec<f64> = args[0].as_arr().iter().map(|v| v.as_f64()).collect();
nice_domain_to_value(&nice_domain(&domain, args[1].as_i64()))
}log_scale throws on bad input 17 tests
pub fn log_scale(domain: &[f64], range: &[f64], value: f64, clamp: bool) -> f64
| domain | float[] | [from, to], both greater than zero and not equal |
| range | float[] | |
| value | float | greater than zero |
| clamp | bool | |
| returns | float | rounded to 6 decimal places, half away from zero |
For example
log_scale(1, 1,000, 0, 300, 10, false)→ 100 a decade is a third of three decadeslog_scale(1, 1,000, 0, 300, 100, false)→ 200 two decadeslog_scale(1, 100, 0, 1, 1, false)→ 0 the start of the domain
fune!(charts.scale@^1); // then call log_scale(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::charts_scale_linear_scale::{floats, interpolate, pair}; ← linearScale, another function of this group · built into the same file, even by a slim install
use super::math_ln::ln; ← from math.ln ^1.0.0 · built alongside by fune
use super::math_round_float::round_float; ← from math.round-float ^1.0.0 · built alongside by fune
/// Where `value` lands on a logarithmic axis: equal ratios take equal lengths.
/// The base cancels out, so there is none to pass. `ln` is math.ln, not
/// `f64::ln`, so every language lands on the same double.
///
/// # Panics
/// Panics if the domain or range does not have two values, either domain end
/// is not positive, the ends are equal, or `value` is not positive.
pub fn log_scale(domain: &[f64], range: &[f64], value: f64, clamp: bool) -> f64 {
let (d0, d1) = pair(domain, "domain");
let (r0, r1) = pair(range, "range");
if !(d0 > 0.0) || !(d1 > 0.0) {
panic!("log scale domain must be positive; got [{}, {}]", d0, d1);
}
if d0 == d1 {
panic!("domain has no width: both ends are {}", d0);
}
if !(value > 0.0) {
panic!("value must be positive for a log scale; got {}", value);
}
let l0 = ln(d0);
let mut t = (ln(value) - l0) / (ln(d1) - l0);
if clamp {
t = t.max(0.0).min(1.0);
}
round_float(interpolate(r0, r1, t), 6)
}
pub fn fune_vector(args: &[Value]) -> Value {
Value::Float(log_scale(&floats(&args[0]), &floats(&args[1]), args[2].as_f64(), args[3].as_bool()))
}time_scale throws on bad input 15 tests
pub fn time_scale(domain: &[String], range: &[f64], value: &str, clamp: bool) -> f64
| domain | date[] | [from, to] ISO dates, not equal; may run backwards |
| range | float[] | |
| value | date | an ISO date; days before or after the domain map outside the range unless clamped |
| clamp | bool | |
| returns | float | rounded to 6 decimal places, half away from zero |
For example
time_scale(2026-01-01, 2026-01-31, 0, 300, 2026-01-16, false)→ 150 the middle of Januarytime_scale(2026-01-01, 2026-01-31, 0, 300, 2026-01-01, false)→ 0 the first day is the start of the rangetime_scale(2026-01-01, 2026-01-31, 0, 300, 2026-01-31, false)→ 300 the last day is the end of the range
fune!(charts.scale@^1); // then call time_scale(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::charts_scale_linear_scale::{floats, interpolate, pair}; ← linearScale, another function of this group · built into the same file, even by a slim install
use super::dates_days_between::days_between; ← from dates.days-between ^1.0.0 · built alongside by fune
use super::math_round_float::round_float; ← from math.round-float ^1.0.0 · built alongside by fune
/// Where an ISO date lands on the range, linearly by calendar days, counted
/// with dates.days-between so no time zone can move a point by a day.
///
/// # Panics
/// Panics if the domain or range does not have two values, a date is not a
/// real ISO date, or the domain's ends are the same day.
pub fn time_scale(domain: &[String], range: &[f64], value: &str, clamp: bool) -> f64 {
if domain.len() != 2 {
panic!("domain must have exactly 2 values, [from, to]; got {}", domain.len());
}
let (r0, r1) = pair(range, "range");
let span = days_between(&domain[0], &domain[1]);
if span == 0 {
panic!("domain has no width: both ends are {}", domain[0]);
}
let mut t = days_between(&domain[0], value) as f64 / span as f64;
if clamp {
t = t.max(0.0).min(1.0);
}
round_float(interpolate(r0, r1, t), 6)
}
pub fn fune_vector(args: &[Value]) -> Value {
let domain: Vec<String> = args[0].as_arr().iter().map(|v| v.as_str().to_string()).collect();
Value::Float(time_scale(&domain, &floats(&args[1]), args[2].as_str(), args[3].as_bool()))
}band_scale throws on bad input 16 tests
pub fn band_scale(domain: &[String], range: &[f64], value: &str, padding_inner: f64, padding_outer: f64, align: f64) -> Band
| domain | string[] | the categories in order, no repeats |
| range | float[] | |
| value | string | one of the categories |
| padding_inner | float | 0 to 1: the share of each step left as a gap between bands |
| padding_outer | float | 0 or more, in steps: space before the first and after the last band |
| align | float | 0 to 1: where the outer space goes; 0.5 centres the bands |
| returns | Band |
For example
band_scale(a, b, c, 0, 300, b, 0, 0, 0.5)→ start 100, center 150, width 100 three bands with no paddingband_scale(a, b, c, d, 0, 100, a, 0.2, 0.1, 0.5)→ start 2.5, center 12.5, width 20 padding: the first bandband_scale(a, b, c, d, 0, 100, d, 0.2, 0.1, 0.5)→ start 77.5, center 87.5, width 20 padding: the last band
fune!(charts.scale@^1); // then call band_scale(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::charts_scale_linear_scale::{floats, pair}; ← linearScale, another function of this group · built into the same file, even by a slim install
use super::math_round_float::round_float; ← from math.round-float ^1.0.0 · built alongside by fune
/// The band a category occupies, as d3.scaleBand lays it out (without
/// rounding to whole pixels). A reversed range reverses the order of the
/// bands, not their direction, so `start` is always the lower coordinate.
///
/// # Panics
/// Panics on an empty or repeating domain, a value not in it, a range without
/// two values, or padding or align out of bounds.
pub fn band_scale(domain: &[String], range: &[f64], value: &str, padding_inner: f64, padding_outer: f64, align: f64) -> Band {
if !(padding_inner >= 0.0 && padding_inner <= 1.0) {
panic!("paddingInner must be between 0 and 1; got {}", padding_inner);
}
check_outer(padding_outer, "paddingOuter");
check_align(align);
let (start, width) = band_position(domain, range, value, padding_inner, padding_outer, align);
Band {
start: round_float(start, 6),
center: round_float(start + width / 2.0, 6),
width: round_float(width, 6),
}
}
// Exported for point_scale, which is a band scale whose bands have no width.
pub fn check_outer(padding: f64, what: &str) {
if !(padding >= 0.0) {
panic!("{} must be 0 or more; got {}", what, padding);
}
}
pub fn check_align(align: f64) {
if !(align >= 0.0 && align <= 1.0) {
panic!("align must be between 0 and 1; got {}", align);
}
}
/// The unrounded lower coordinate of value's band, and the band width.
pub fn band_position(domain: &[String], range: &[f64], value: &str, padding_inner: f64, padding_outer: f64, align: f64) -> (f64, f64) {
let (r0, r1) = pair(range, "range");
let n = domain.len();
if n == 0 {
panic!("domain must not be empty");
}
let mut index: Option<usize> = None;
for i in 0..n {
for j in 0..i {
if domain[j] == domain[i] {
panic!("domain has a repeated value: \"{}\"", domain[i]);
}
}
if domain[i] == value {
index = Some(i);
}
}
let index = match index {
Some(i) => i,
None => panic!("value is not in the domain: \"{}\"", value),
};
let reverse = r1 < r0;
let mut start = if reverse { r1 } else { r0 };
let stop = if reverse { r0 } else { r1 };
let nf = n as f64;
let step = (stop - start) / (nf - padding_inner + padding_outer * 2.0).max(1.0);
start += (stop - start - step * (nf - padding_inner)) * align;
let slot = if reverse { n - 1 - index } else { index };
(start + step * slot as f64, step * (1.0 - padding_inner))
}
pub fn band_to_value(band: &Band) -> Value {
Value::obj(vec![
("start", Value::Float(band.start)),
("center", Value::Float(band.center)),
("width", Value::Float(band.width)),
])
}
/// A JSON list of strings, for the vector adapters.
pub fn band_strings(value: &Value) -> Vec<String> {
value.as_arr().iter().map(|v| v.as_str().to_string()).collect()
}
pub fn fune_vector(args: &[Value]) -> Value {
band_to_value(&band_scale(
&band_strings(&args[0]),
&floats(&args[1]),
args[2].as_str(),
args[3].as_f64(),
args[4].as_f64(),
args[5].as_f64(),
))
}point_scale throws on bad input 11 tests
pub fn point_scale(domain: &[String], range: &[f64], value: &str, padding: f64, align: f64) -> f64
| domain | string[] | the categories in order, no repeats |
| range | float[] | |
| value | string | one of the categories |
| padding | float | 0 or more, in steps: space before the first and after the last point |
| align | float | 0 to 1; 0.5 centres the points |
| returns | float | the point's position, rounded to 6 decimal places |
For example
point_scale(a, b, c, 0, 100, b, 0, 0.5)→ 50 the middle of three pointspoint_scale(a, b, c, 0, 100, a, 0, 0.5)→ 0 the first point sits on the start with no paddingpoint_scale(a, b, c, 0, 100, c, 0, 0.5)→ 100 the last point sits on the end with no padding
fune!(charts.scale@^1); // then call point_scale(…)
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
use super::funejson::Value; ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::charts_scale_band_scale::{band_position, band_strings, check_align, check_outer}; ← bandScale, another function of this group · built into the same file, even by a slim install
use super::charts_scale_linear_scale::floats; ← linearScale, another function of this group · built into the same file, even by a slim install
use super::math_round_float::round_float; ← from math.round-float ^1.0.0 · built alongside by fune
/// The position of a category on an axis of evenly spaced points, as
/// d3.scalePoint: a band scale with inner padding 1, so bands have no width.
///
/// # Panics
/// Panics on an empty or repeating domain, a value not in it, a range without
/// two values, or padding or align out of bounds.
pub fn point_scale(domain: &[String], range: &[f64], value: &str, padding: f64, align: f64) -> f64 {
check_outer(padding, "padding");
check_align(align);
round_float(band_position(domain, range, value, 1.0, padding, align).0, 6)
}
pub fn fune_vector(args: &[Value]) -> Value {
Value::Float(point_scale(&band_strings(&args[0]), &floats(&args[1]), args[2].as_str(), args[3].as_f64(), args[4].as_f64()))
}Install
fune build
With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Rust package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:
fune add charts.scale
That builds the whole group. To build only what you call, and whatever it uses inside the group:
fune add charts.scale --only linearScale
The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./charts.scale-1.1.0-rust.fune, or fetch it from a terminal with fune pull charts.scale@1.1.0:rust.
The whole function, every language, is one file too: charts.scale-1.1.0.fune, 61,533 bytes, sha256 c1ff13b925cdadfa1c52e5ae9c82981d412d1778305d2bec4a47e4e0b15f4ba1. It installs into a project of any language.
Customise it in your app
The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.
before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.
// fune: before charts.scale.linearScale
// fune: before charts.scale.invertLinear
// fune: before charts.scale.niceDomain
// fune: before charts.scale.logScale
// fune: before charts.scale.timeScale
// fune: before charts.scale.bandScale
// fune: before charts.scale.pointScale
after — your function gets the result and the arguments, and returns the final result.
// fune: after charts.scale.linearScale
// fune: after charts.scale.invertLinear
// fune: after charts.scale.niceDomain
// fune: after charts.scale.logScale
// fune: after charts.scale.timeScale
// fune: after charts.scale.bandScale
// fune: after charts.scale.pointScale
replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.
// fune: replace dates.days-between in charts.scale
// fune: replace math.ln in charts.scale
// fune: replace math.round-float in charts.scale
step — your function runs at a numbered point inside a function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show charts.scale --steps.
// fune: step charts.scale.<fn> after <n|label>
Tests
A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.
linearScale 14 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a quarter of the way along a 500 pixel axis | 0, 100, 0, 500, 25, false | → | 125 |
| the start of the domain is the start of the range | 0, 100, 0, 500, 0, false | → | 0 |
| the end of the domain is the end of the range | 0, 100, 0, 500, 100, false | → | 500 |
| a reversed range, as a y axis drawn downwards | 0, 50, 300, 0, 10, false | → | 240 |
| a reversed domain | 100, 0, 0, 1, 75, false | → | 0.25 |
| negative values | -20, 40, 0, 600, -5, false | → | 150 |
| past the end runs on without clamp | 0, 10, 0, 100, 15, false | → | 150 |
| past the end stops at the range with clamp | 0, 10, 0, 100, 15, true | → | 100 |
| before the start stops at the range with clamp | 0, 10, 100, 200, -3, true | → | 100 |
| clamp changes nothing inside the domain | 0, 10, 0, 100, 2.5, true | → | 25 |
Show the other 4 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a third is rounded to 6 decimal places | 0, 3, 0, 1, 1, false | → | 0.333 |
| fractional domain | 0.1, 0.3, 0, 10, 0.2, false | → | 5 |
| a domain with no width is an error | 5, 5, 0, 100, 5, false | → | error: domain has no width |
| a domain needs two ends | 0, 1, 2, 0, 100, 1, false | → | error: domain must have exactly 2 values, [from, to] |
invertLinear 8 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a pixel back to its value | 0, 100, 0, 500, 125 | → | 25 |
| the start of the range is the start of the domain | 0, 100, 0, 500, 0 | → | 0 |
| a reversed range | 0, 50, 300, 0, 240 | → | 10 |
| past the end of the axis is past the end of the data | 0, 10, 0, 100, 150 | → | 15 |
| a third is rounded to 6 decimal places | 0, 1, 0, 3, 1 | → | 0.333 |
| negative domain | -20, 40, 0, 600, 150 | → | -5 |
| a range with no width is an error | 0, 10, 7, 7, 7 | → | error: range has no width |
| a range needs two ends | 0, 10, 7, 7 | → | error: range must have exactly 2 values, [from, to] |
niceDomain 12 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| widens to whole tens | 3, 97, 10 | → | domain 0, 100, step 10 |
| small numbers widen to tenths | 0.13, 0.87, 10 | → | domain 0.1, 0.9, step 0.1 |
| a messy extent becomes 0 to 10 | 0.13, 9.7, 5 | → | domain 0, 10, step 2 |
| an already nice domain is unchanged | 0, 100, 10 | → | domain 0, 100, step 10 |
| negative to positive | -12.5, 37.2, 5 | → | domain -20, 40, step 10 |
| a reversed domain stays reversed | 97, 3, 10 | → | domain 100, 0, step 10 |
| large values step in fives of a power of ten | 1,234, 98,765, 20 | → | domain 0, 100,000, step 5,000 |
| one tick | 2, 9, 1 | → | domain 0, 10, step 10 |
| a fractional step that divides badly | 0.3, 0.9, 6 | → | domain 0.3, 0.9, step 0.1 |
| equal ends are returned as they are | 4, 4, 10 | → | domain 4, 4, step 0 |
Show the other 2 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| count must be at least 1 | 0, 10, 0 | → | error: count must be a whole number of at least 1 |
| a domain needs two ends | 1, 5 | → | error: domain must have exactly 2 values, [from, to] |
logScale 17 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a decade is a third of three decades | 1, 1,000, 0, 300, 10, false | → | 100 |
| two decades | 1, 1,000, 0, 300, 100, false | → | 200 |
| the start of the domain | 1, 100, 0, 1, 1, false | → | 0 |
| the end of the domain | 1, 100, 0, 1, 100, false | → | 1 |
| a reversed range, as a y axis | 10, 1,000, 500, 0, 100, false | → | 250 |
| 2 sits at log10(2) of a decade, not a ninth of it as a linear scale would put it | 1, 10, 0, 100, 2, false | → | 30.103 |
| past the end runs on without clamp | 1, 10, 0, 100, 100, false | → | 200 |
| past the end stops at the range with clamp | 1, 10, 0, 100, 100, true | → | 100 |
| before the start runs on without clamp | 1, 10, 0, 100, 0.1, false | → | -100 |
| before the start stops with clamp | 1, 10, 0, 100, 0.1, true | → | 0 |
Show the other 7 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a reversed domain | 1,000, 1, 0, 300, 10, false | → | 200 |
| a domain below one | 0.01, 1, 0, 200, 0.1, false | → | 100 |
| a domain end of zero is an error | 0, 10, 0, 100, 1, false | → | error: log scale domain must be positive |
| a negative domain end is an error | -1, 10, 0, 100, 1, false | → | error: log scale domain must be positive |
| a value of zero is an error | 1, 10, 0, 100, 0, false | → | error: value must be positive for a log scale |
| a domain with no width is an error | 5, 5, 0, 100, 5, false | → | error: domain has no width |
| a domain needs two ends | 1, 10, 100, 0, 100, 5, false | → | error: domain must have exactly 2 values, [from, to] |
timeScale 15 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| the middle of January | 2026-01-01, 2026-01-31, 0, 300, 2026-01-16, false | → | 150 |
| the first day is the start of the range | 2026-01-01, 2026-01-31, 0, 300, 2026-01-01, false | → | 0 |
| the last day is the end of the range | 2026-01-01, 2026-01-31, 0, 300, 2026-01-31, false | → | 300 |
| a leap February has 29 days | 2024-02-01, 2024-03-01, 0, 29, 2024-02-29, false | → | 28 |
| a clock change is still one day (no time zone involved) | 2026-03-28, 2026-03-30, 0, 100, 2026-03-29, false | → | 50 |
| a reversed range over a year | 2026-01-01, 2027-01-01, 365, 0, 2026-07-02, false | → | 183 |
| a third of a three-day domain is rounded to 6 places | 2026-01-01, 2026-01-04, 0, 1, 2026-01-02, false | → | 0.333 |
| after the end runs on without clamp | 2026-01-01, 2026-01-11, 0, 100, 2026-01-21, false | → | 200 |
| after the end stops with clamp | 2026-01-01, 2026-01-11, 0, 100, 2026-01-21, true | → | 100 |
| before the start runs on without clamp | 2026-01-01, 2026-01-11, 0, 100, 2025-12-22, false | → | -100 |
Show the other 5 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| before the start stops with clamp | 2026-01-01, 2026-01-11, 0, 100, 2025-12-22, true | → | 0 |
| a reversed domain | 2026-01-11, 2026-01-01, 0, 100, 2026-01-03, false | → | 80 |
| a domain of one day has no width | 2026-01-01, 2026-01-01, 0, 100, 2026-01-01, false | → | error: domain has no width |
| an impossible date is an error | 2026-01-01, 2026-03-01, 0, 100, 2026-02-30, false | → | error: is not a real calendar date |
| a domain needs two ends | 2026-01-01, 0, 100, 2026-01-01, false | → | error: domain must have exactly 2 values, [from, to] |
bandScale 16 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| three bands with no padding | a, b, c, 0, 300, b, 0, 0, 0.5 | → | start 100, center 150, width 100 |
| padding: the first band | a, b, c, d, 0, 100, a, 0.2, 0.1, 0.5 | → | start 2.5, center 12.5, width 20 |
| padding: the last band | a, b, c, d, 0, 100, d, 0.2, 0.1, 0.5 | → | start 77.5, center 87.5, width 20 |
| align 0 puts the outer space at the end | a, b, c, d, 0, 100, a, 0.2, 0.1, 0 | → | start 0, center 10, width 20 |
| align 1 puts the outer space at the start | a, b, c, d, 0, 100, d, 0.2, 0.1, 1 | → | start 80, center 90, width 20 |
| a reversed range reverses the order, not the band | a, b, c, d, 100, 0, a, 0.2, 0.1, 0.5 | → | start 77.5, center 87.5, width 20 |
| one category | x, 0, 50, x, 0.5, 0, 0.5 | → | start 12.5, center 25, width 25 |
| inner padding 1 leaves bands of no width | a, b, 0, 10, b, 1, 0, 0.5 | → | start 10, center 10, width 0 |
| thirds are rounded to 6 places | a, b, c, 0, 100, b, 0, 0, 0.5 | → | start 33.333, center 50, width 33.333 |
| a range through zero | a, b, -50, 50, a, 0, 0.5, 0.5 | → | start -33.333, center -16.667, width 33.333 |
Show the other 6 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a value outside the domain is an error | a, b, 0, 10, z, 0, 0, 0.5 | → | error: value is not in the domain: "z" |
| a repeated category is an error | a, b, a, 0, 10, a, 0, 0, 0.5 | → | error: domain has a repeated value: "a" |
| an empty domain is an error | , 0, 10, a, 0, 0, 0.5 | → | error: domain must not be empty |
| inner padding above 1 is an error | a, 0, 10, a, 1.5, 0, 0.5 | → | error: paddingInner must be between 0 and 1 |
| negative outer padding is an error | a, 0, 10, a, 0, -1, 0.5 | → | error: paddingOuter must be 0 or more |
| align above 1 is an error | a, 0, 10, a, 0, 0, 2 | → | error: align must be between 0 and 1 |
pointScale 11 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| the middle of three points | a, b, c, 0, 100, b, 0, 0.5 | → | 50 |
| the first point sits on the start with no padding | a, b, c, 0, 100, a, 0, 0.5 | → | 0 |
| the last point sits on the end with no padding | a, b, c, 0, 100, c, 0, 0.5 | → | 100 |
| half a step of padding at each end | a, b, c, 0, 100, a, 0.5, 0.5 | → | 16.667 |
| half a step of padding, the last point | a, b, c, 0, 100, c, 0.5, 0.5 | → | 83.333 |
| a single point is centred | x, 0, 80, x, 0, 0.5 | → | 40 |
| a reversed range | a, b, c, 100, 0, a, 0, 0.5 | → | 100 |
| align 0 pushes the padding to the end | a, b, 0, 100, b, 1, 0 | → | 33.333 |
| a value outside the domain is an error | a, b, 0, 100, q, 0, 0.5 | → | error: value is not in the domain: "q" |
| negative padding is an error | a, b, 0, 100, a, -1, 0.5 | → | error: padding must be 0 or more |
Show the other 1 test
| Case | Arguments | Expected | |
|---|---|---|---|
| align below 0 is an error | a, b, 0, 100, a, 0, -0.1 | → | error: align must be between 0 and 1 |
More from the author
Scales are geometry, not money, so they work in floating point. Every result is rounded to 6 decimal places inside the function (half up, as `floor(x * 1e6 + 0.5) / 1e6`), which is far below a pixel and makes TypeScript, Python and Rust agree to the digit.
A domain or range is a two-element list, `[from, to]`. Either may run backwards: a y axis usually maps `[0, max]` onto `[height, 0]`. A domain whose two ends are equal has no width to map, and `linearScale` raises rather than divide by zero; `invertLinear` raises for a range with equal ends for the same reason.
Without `clamp`, a value outside the domain maps outside the range, which is what an axis drawing a point past its end wants. With it, the result stops at the range's ends.
`niceDomain` picks a tick step of 1, 2 or 5 times a power of ten, aiming for about `count` ticks across the domain, then moves each end out to a multiple of that step, repeating until the step stops changing. It returns the step with the domain, so the axis can draw ticks on exactly those multiples. A domain whose ends are equal is returned unchanged with a step of 0.
## New in 1.1.0: log, time, band and point scales
The three functions above are unchanged from 1.0.0, rounding included (half up, `floor(x * 1e6 + 0.5)`), so upgrading moves no existing point. The four new ones round to 6 decimal places with `math.round-float` (half away from zero on the exact double), which is what every newer charts capability uses.
**`logScale`** maps equal ratios to equal lengths: on `[1, 1000]` the value 10 is a third of the way along. The base of the logarithm cancels, so there is no base to pass. Both domain ends and the value must be greater than zero. The logarithm is `math.ln`, built from `+ - * /` only, because `Math.log`, `math.log` and `f64::ln` may differ in their last bit, which is enough to split two languages at a rounding boundary.
**`timeScale`** is a linear scale over calendar days: the position of an ISO date is its day count from the domain's first date (by `dates.days-between`) over the domain's length in days. There is no `Date` object and no time zone, so a clock change or a machine in another zone cannot move a point by an hour or a day. Dates only; a scale over times of day is out of scope.
**`bandScale`** is d3.scaleBand without pixel rounding. The range is divided into `n - paddingInner + 2 x paddingOuter` steps; each band is one step less `paddingInner` of it; `align` shares the leftover outer space (0 all at the end, 1 all at the start, 0.5 centred). It returns the band's `start` (always its lower coordinate), `center` and `width`. A reversed range reverses the order of the categories, not the direction of a band. Repeated categories are an error: d3 silently merges them, which draws two bars on top of each other.
**`pointScale`** is d3.scalePoint: a band scale with `paddingInner` 1, so the bands have no width and each category is a point; `padding` is in steps at each end.
Sources: Mike Bostock, d3-scale (github.com/d3/d3-scale), `band.js`, `log.js` and `time.js`, whose layout rules these follow.