Functional Weave
Code in Rust

charts.scale

Map data values onto screen positions and back: linear, log, time, band and point scales, and nice domains.

1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra

Pinned by 34 tests, run in TypeScript, Python and Rust.linearScale 14 · invertLinear 8 · niceDomain 12

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: 3 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.

  1. linear_scale (domain: float[], range: float[], value: float, clamp: bool) -> float
  2. invert_linear (domain: float[], range: float[], value: float) -> float
  3. nice_domain (domain: float[], count: int) -> NiceDomain

The type 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,
}

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
domainfloat[]the data extent [from, to]; from and to may be in either order but not equal
rangefloat[]the output extent [from, to], usually pixels; may be reversed for a y axis
valuefloat
clampboolkeep the result inside the range when value is outside the domain
returnsfloatrounded to 6 decimal places

For example

  • linear_scale(0, 100, 0, 500, 25, false) → 125 a quarter of the way along a 500 pixel axis
  • linear_scale(0, 100, 0, 500, 0, false) → 0 the start of the domain is the start of the range
  • linear_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(…)
impl/rust/linear_scale.rs · 60 lines · open · raw

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
domainfloat[]
rangefloat[]
valuefloata position in the range, such as a mouse coordinate
returnsfloatthe 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 value
  • invert_linear(0, 100, 0, 500, 0) → 0 the start of the range is the start of the domain
  • invert_linear(0, 50, 300, 0, 240) → 10 a reversed range
fune!(charts.scale@^1);  // then call invert_linear(…)
impl/rust/invert_linear.rs · 19 lines · open · raw

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
domainfloat[]
countintroughly how many ticks the axis will have, at least 1
returnsNiceDomain

For example

  • nice_domain(3, 97, 10) → domain 0, 100, step 10 widens to whole tens
  • nice_domain(0.13, 0.87, 10) → domain 0.1, 0.9, step 0.1 small numbers widen to tenths
  • nice_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(…)
impl/rust/nice_domain.rs · 83 lines · open · raw

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()))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and nothing else, 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
Download for Rust charts.scale-1.0.0-rust.fune · 16,774 bytes sha256 d57be96aa1c801ca21f8f858d7c82a41153d682ec5961b784dfa8659babd164b

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./charts.scale-1.0.0-rust.fune, or fetch it from a terminal with fune pull charts.scale@1.0.0:rust.

The whole function, every language, is one file too: charts.scale-1.0.0.fune, 25,804 bytes, sha256 3b2f87385b03e0c3f4e8139e79ee5270cc7b4a60f23f951474f461fe8d250251. 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

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

replace — it requires no other capability, so there is no dependency to replace.

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

CaseArgumentsExpected
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
CaseArgumentsExpected
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

CaseArgumentsExpected
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

CaseArgumentsExpected
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
CaseArgumentsExpected
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]

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.

Files

PathBytes
README.md1,745
impl/python/invert_linear.py528
impl/python/linear_scale.py1,503
impl/python/nice_domain.py2,218
impl/rust/invert_linear.rs772
impl/rust/linear_scale.rs1,867
impl/rust/nice_domain.rs2,738
impl/typescript/invert_linear.ts590
impl/typescript/linear_scale.ts1,639
impl/typescript/nice_domain.ts2,047
vectors.json4,570