Functional Weave
Code in Python

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.

  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
  4. log_scale (domain: float[], range: float[], value: float, clamp: bool) -> float
  5. time_scale (domain: date[], range: float[], value: date, clamp: bool) -> float
  6. band_scale (domain: string[], range: float[], value: string, paddingInner: float, paddingOuter: float, align: float) -> Band
  7. point_scale (domain: string[], range: float[], value: string, padding: float, align: float) -> float

The types it declares, generated into your project

@dataclass(frozen=True)
class NiceDomain:
    """A domain widened to round numbers, and the tick step that made them round."""

    #: [from, to], in the order given
    domain: List[float]
    #: 1, 2 or 5 times a power of ten
    step: float

@dataclass(frozen=True)
class Band:
    """Where one category's band sits along the range."""

    #: the band's lower coordinate
    start: float
    #: start + width / 2, where a label or tick goes
    center: float
    #: the band's width, the same for every category
    width: float

Once installed, your code imports each one from the group's module.

linear_scale throws on bad input 14 tests

def linear_scale(domain: Sequence[float], range: Sequence[float], value: float, clamp: bool) -> float
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
from fune.charts.scale import linear_scale  # charts.scale@^1
impl/python/linear_scale.py · 43 lines · open · raw
import math
from typing import Sequence, Tuple


def linear_scale(domain: Sequence[float], range: Sequence[float], value: float, clamp: bool) -> float:
    """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.
    """
    d0, d1 = pair(domain, "domain")
    r0, r1 = pair(range, "range")
    t = normalise(d0, d1, value, "domain")
    if clamp:
        t = max(0.0, min(1.0, t))
    return 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.


def pair(values: Sequence[float], what: str) -> Tuple[float, float]:
    if len(values) != 2:
        raise ValueError(f"{what} must have exactly 2 values, [from, to]; got {len(values)}")
    return float(values[0]), float(values[1])


def normalise(start: float, end: float, value: float, what: str) -> float:
    # Equal ends would divide by zero, which Python raises as a bare
    # ZeroDivisionError that does not say which end is wrong.
    if start == end:
        raise ValueError(f"{what} has no width: both ends are {start}")
    return (value - start) / (end - start)


def interpolate(start: float, end: float, t: float) -> float:
    return start + t * (end - start)


def round6(x: float) -> float:
    """Half up to 6 decimal places, the same way in every language."""
    return math.floor(x * 1e6 + 0.5) / 1e6

invert_linear throws on bad input 8 tests

def invert_linear(domain: Sequence[float], range: Sequence[float], value: float) -> float
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
from fune.charts.scale import invert_linear  # charts.scale@^1
impl/python/invert_linear.py · 14 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.

from typing import Sequence

from .charts_scale_linear_scale import interpolate, normalise, pair, round6  ← linearScale, another function of this group · built into the same file, even by a slim install


def invert_linear(domain: Sequence[float], range: Sequence[float], value: float) -> float:
    """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.
    """
    d0, d1 = pair(domain, "domain")
    r0, r1 = pair(range, "range")
    return round6(interpolate(d0, d1, normalise(r0, r1, value, "range")))

nice_domain throws on bad input 12 tests

def nice_domain(domain: Sequence[float], count: int) -> 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
from fune.charts.scale import nice_domain  # charts.scale@^1
impl/python/nice_domain.py · 64 lines · open · raw
import math
from typing import Sequence

from .charts_scale_types import NiceDomain


def nice_domain(domain: Sequence[float], count: int) -> NiceDomain:
    """Widen a domain to multiples of a round tick step chosen for about count ticks.

    Widening can change the best step, so it repeats until the step settles,
    as d3's nice() does.
    """
    if len(domain) != 2:
        raise ValueError(f"domain must have exactly 2 values, [from, to]; got {len(domain)}")
    if isinstance(count, bool) or not isinstance(count, int) or count < 1:
        raise ValueError(f"count must be a whole number of at least 1, got {count}")
    reversed_ = domain[1] < domain[0]
    lo = float(domain[1] if reversed_ else domain[0])
    hi = float(domain[0] if reversed_ else domain[1])
    if lo == hi:
        return NiceDomain(domain=[float(domain[0]), float(domain[1])], step=0.0)

    step = 0.0
    for _ in range(10):
        nxt = _tick_step(lo, hi, count)
        if nxt == step:
            break
        step = nxt
        if step >= 1:
            lo = math.floor(lo / step) * step
            hi = math.ceil(hi / step) * step
        else:
            # A fractional step divides badly (0.3 / 0.1 is 2.9999999999999996),
            # so multiply by its whole-number inverse instead.
            inverse = math.floor(1 / step + 0.5)
            lo = math.floor(lo * inverse) / inverse
            hi = math.ceil(hi * inverse) / inverse
    ends = [_round6(hi), _round6(lo)] if reversed_ else [_round6(lo), _round6(hi)]
    return NiceDomain(domain=ends, step=_round6(step))


def _tick_step(lo: float, hi: float, count: int) -> float:
    raw = (hi - lo) / count
    # Powers of ten by repeated multiplication rather than log10, whose last
    # digit is not guaranteed to agree between languages.
    power = 1.0
    while power * 10 <= raw:
        power *= 10
    while power > raw:
        power /= 10
    error = raw / power
    if error >= math.sqrt(50):
        factor = 10
    elif error >= math.sqrt(10):
        factor = 5
    elif error >= math.sqrt(2):
        factor = 2
    else:
        factor = 1
    return factor * power


def _round6(x: float) -> float:
    return math.floor(x * 1e6 + 0.5) / 1e6

log_scale throws on bad input 17 tests

def log_scale(domain: Sequence[float], range: Sequence[float], value: float, clamp: bool) -> float
domainfloat[][from, to], both greater than zero and not equal
rangefloat[]
valuefloatgreater than zero
clampbool
returnsfloatrounded 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 decades
  • log_scale(1, 1,000, 0, 300, 100, false) → 200 two decades
  • log_scale(1, 100, 0, 1, 1, false) → 0 the start of the domain
from fune.charts.scale import log_scale  # charts.scale@^1
impl/python/log_scale.py · 26 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.

from typing import Sequence

from .charts_scale_linear_scale import interpolate, pair  ← linearScale, another function of this group · built into the same file, even by a slim install
from .math_ln import ln  ← from math.ln ^1.0.0 · built alongside by fune
from .math_round_float import round_float  ← from math.round-float ^1.0.0 · built alongside by fune


def log_scale(domain: Sequence[float], range: Sequence[float], value: float, clamp: bool) -> float:
    """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
    math.log, so every language lands on the same double.
    """
    d0, d1 = pair(domain, "domain")
    r0, r1 = pair(range, "range")
    if not (d0 > 0) or not (d1 > 0):
        raise ValueError(f"log scale domain must be positive; got [{d0}, {d1}]")
    if d0 == d1:
        raise ValueError(f"domain has no width: both ends are {d0}")
    if not (value > 0):
        raise ValueError(f"value must be positive for a log scale; got {value}")
    l0 = ln(d0)
    t = (ln(value) - l0) / (ln(d1) - l0)
    if clamp:
        t = max(0.0, min(1.0, t))
    return round_float(interpolate(r0, r1, t), 6)

time_scale throws on bad input 15 tests

def time_scale(domain: Sequence[str], range: Sequence[float], value: str, clamp: bool) -> float
domaindate[][from, to] ISO dates, not equal; may run backwards
rangefloat[]
valuedatean ISO date; days before or after the domain map outside the range unless clamped
clampbool
returnsfloatrounded 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 January
  • time_scale(2026-01-01, 2026-01-31, 0, 300, 2026-01-01, false) → 0 the first day is the start of the range
  • time_scale(2026-01-01, 2026-01-31, 0, 300, 2026-01-31, false) → 300 the last day is the end of the range
from fune.charts.scale import time_scale  # charts.scale@^1
impl/python/time_scale.py · 23 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.

from typing import Sequence

from .charts_scale_linear_scale import interpolate, pair  ← linearScale, another function of this group · built into the same file, even by a slim install
from .dates_days_between import days_between  ← from dates.days-between ^1.0.0 · built alongside by fune
from .math_round_float import round_float  ← from math.round-float ^1.0.0 · built alongside by fune


def time_scale(domain: Sequence[str], range: Sequence[float], value: str, clamp: bool) -> float:
    """Where an ISO date lands on the range, linearly by calendar days.

    Days are counted with dates.days-between, never through datetime, so no
    time zone can move a point by a day.
    """
    if len(domain) != 2:
        raise ValueError(f"domain must have exactly 2 values, [from, to]; got {len(domain)}")
    r0, r1 = pair(range, "range")
    span = days_between(domain[0], domain[1])
    if span == 0:
        raise ValueError(f"domain has no width: both ends are {domain[0]}")
    t = days_between(domain[0], value) / span
    if clamp:
        t = max(0.0, min(1.0, t))
    return round_float(interpolate(r0, r1, t), 6)

band_scale throws on bad input 16 tests

def band_scale(domain: Sequence[str], range: Sequence[float], value: str, padding_inner: float, padding_outer: float, align: float) -> Band
domainstring[]the categories in order, no repeats
rangefloat[]
valuestringone of the categories
padding_innerfloat0 to 1: the share of each step left as a gap between bands
padding_outerfloat0 or more, in steps: space before the first and after the last band
alignfloat0 to 1: where the outer space goes; 0.5 centres the bands
returnsBand

For example

  • band_scale(a, b, c, 0, 300, b, 0, 0, 0.5) → start 100, center 150, width 100 three bands with no padding
  • band_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 band
  • band_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
from fune.charts.scale import band_scale  # charts.scale@^1
impl/python/band_scale.py · 68 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.

from typing import Sequence, Tuple

from .charts_scale_linear_scale import pair  ← linearScale, another function of this group · built into the same file, even by a slim install
from .charts_scale_types import Band
from .math_round_float import round_float  ← from math.round-float ^1.0.0 · built alongside by fune


def band_scale(
    domain: Sequence[str],
    range: Sequence[float],
    value: str,
    padding_inner: float,
    padding_outer: float,
    align: float,
) -> Band:
    """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."""
    if not (0 <= padding_inner <= 1):
        raise ValueError(f"paddingInner must be between 0 and 1; got {padding_inner}")
    check_outer(padding_outer, "paddingOuter")
    check_align(align)
    start, width = band_position(domain, range, value, padding_inner, padding_outer, align)
    return Band(start=round_float(start, 6), center=round_float(start + width / 2, 6), width=round_float(width, 6))


# Exported for point_scale, which is a band scale whose bands have no width.


def check_outer(padding: float, what: str) -> None:
    if not (padding >= 0):
        raise ValueError(f"{what} must be 0 or more; got {padding}")


def check_align(align: float) -> None:
    if not (0 <= align <= 1):
        raise ValueError(f"align must be between 0 and 1; got {align}")


def band_position(
    domain: Sequence[str],
    extent: Sequence[float],
    value: str,
    padding_inner: float,
    padding_outer: float,
    align: float,
) -> Tuple[float, float]:
    """The unrounded lower coordinate of value's band, and the band width."""
    r0, r1 = pair(extent, "range")
    n = len(domain)
    if n == 0:
        raise ValueError("domain must not be empty")
    index = -1
    for i in range(n):
        for j in range(i):
            if domain[j] == domain[i]:
                raise ValueError(f'domain has a repeated value: "{domain[i]}"')
        if domain[i] == value:
            index = i
    if index < 0:
        raise ValueError(f'value is not in the domain: "{value}"')
    reverse = r1 < r0
    start = r1 if reverse else r0
    stop = r0 if reverse else r1
    step = (stop - start) / max(1.0, n - float(padding_inner) + float(padding_outer) * 2)
    start += (stop - start - step * (n - float(padding_inner))) * float(align)
    slot = n - 1 - index if reverse else index
    return start + step * slot, step * (1 - float(padding_inner))

point_scale throws on bad input 11 tests

def point_scale(domain: Sequence[str], range: Sequence[float], value: str, padding: float, align: float) -> float
domainstring[]the categories in order, no repeats
rangefloat[]
valuestringone of the categories
paddingfloat0 or more, in steps: space before the first and after the last point
alignfloat0 to 1; 0.5 centres the points
returnsfloatthe 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 points
  • point_scale(a, b, c, 0, 100, a, 0, 0.5) → 0 the first point sits on the start with no padding
  • point_scale(a, b, c, 0, 100, c, 0, 0.5) → 100 the last point sits on the end with no padding
from fune.charts.scale import point_scale  # charts.scale@^1
impl/python/point_scale.py · 12 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.

from typing import Sequence

from .charts_scale_band_scale import band_position, check_align, check_outer  ← bandScale, another function of this group · built into the same file, even by a slim install
from .math_round_float import round_float  ← from math.round-float ^1.0.0 · built alongside by fune


def point_scale(domain: Sequence[str], range: Sequence[float], value: str, padding: float, align: float) -> float:
    """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."""
    check_outer(padding, "padding")
    check_align(align)
    return round_float(band_position(domain, range, value, 1, padding, align)[0], 6)

Install

fune build

With that line in your source, in a Python project (language python in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Python 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. 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 Python charts.scale-1.1.0-python.fune · 38,512 bytes sha256 64ca487882bc49e3804437714b2da8a0fb7e20b4853ba357af7974f21ad88ee7

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

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

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]

logScale 17 tests

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

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

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

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

Files

PathBytes
README.md3,731
impl/python/band_scale.py2,411
impl/python/invert_linear.py528
impl/python/linear_scale.py1,503
impl/python/log_scale.py1,007
impl/python/nice_domain.py2,218
impl/python/point_scale.py563
impl/python/time_scale.py907
impl/rust/band_scale.rs3,286
impl/rust/invert_linear.rs772
impl/rust/linear_scale.rs1,867
impl/rust/log_scale.rs1,331
impl/rust/nice_domain.rs2,738
impl/rust/point_scale.rs925
impl/rust/time_scale.rs1,285
impl/typescript/band_scale.ts2,538
impl/typescript/invert_linear.ts590
impl/typescript/linear_scale.ts1,639
impl/typescript/log_scale.ts1,167
impl/typescript/nice_domain.ts2,047
impl/typescript/point_scale.ts637
impl/typescript/time_scale.ts990
vectors.json13,419