Functional Weave
Code in Python

charts.scale@1.1.0

impl/python/nice_domain.py

2,218 bytes · the Python implementation · view 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