flowrs-engine/crates/dpb-plot/src/ticks.rs
flowRS fd958c0547 flowRS engine, cut from flowRS 3451b065
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 19:04:39 -05:00

582 lines
19 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Ticks as data: nice numbers (1, 2 and 5 × 10ᵏ; minutes and hours on long
//! time axes), one SI prefix per axis, and time written as seconds from an
//! origin or as a clock (m:ss.fff). Everything writes into inline
//! [`Label`]s, so an axis is relabelled every frame without allocating.
//!
//! A tick's value is computed as an integer times the step's mantissa, scaled
//! by an exact power of ten, so 0.3 is 3 / 10 and never 0.30000000000000004.
use crate::label::Label;
use core::fmt::Write;
use libm::{ceil, fabs, floor, log10, round};
/// Evenly spaced ticks: tick `i` is `(k0 + i) · mantissa · 10^exp`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TickSet {
/// The first tick's multiple of the step.
pub k0: i64,
/// How many ticks.
pub count: u32,
/// The step's mantissa: 1, 2 or 5, or a whole number of seconds on a
/// minute or hour time axis (exp 0).
pub mantissa: u32,
/// The step's power of ten.
pub exp: i32,
}
impl TickSet {
/// No ticks.
pub const EMPTY: TickSet = TickSet {
k0: 0,
count: 0,
mantissa: 1,
exp: 0,
};
/// The step between ticks.
pub fn step(&self) -> f64 {
scale10(f64::from(self.mantissa), self.exp)
}
/// Tick `i`'s value (exact to the last bit for decimal steps).
pub fn value(&self, i: u32) -> f64 {
let k = self.k0 + i64::from(i);
scale10(k as f64 * f64::from(self.mantissa), self.exp)
}
/// The first and last tick values, or None when there are none.
pub fn bounds(&self) -> Option<(f64, f64)> {
(self.count > 0).then(|| (self.value(0), self.value(self.count - 1)))
}
}
const POW10: [f64; 23] = [
1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, 1e12, 1e13, 1e14, 1e15, 1e16,
1e17, 1e18, 1e19, 1e20, 1e21, 1e22,
];
/// 10^|e| exactly up to 10^22 (every power of ten up to there is a double).
fn pow10_abs(e: i32) -> f64 {
let a = e.unsigned_abs() as usize;
if a < POW10.len() {
POW10[a]
} else {
libm::pow(10.0, f64::from(e.unsigned_abs()))
}
}
/// `x · 10^e`, dividing for negative `e` so the result is correctly rounded.
pub fn scale10(x: f64, e: i32) -> f64 {
if e >= 0 {
x * pow10_abs(e)
} else {
x / pow10_abs(e)
}
}
/// The nice step for `span` split into about `target` intervals: a mantissa
/// of 1, 2 or 5 and a power of ten.
pub fn nice_step(span: f64, target: u32) -> (u32, i32) {
let raw = fabs(span) / f64::from(target.max(1));
if raw <= 0.0 || !raw.is_finite() {
return (1, 0);
}
let mut e = floor(log10(raw)) as i32;
let mut f = scale10(raw, -e);
// log10 can land a hair off at exact powers of ten.
if f >= 10.0 {
e += 1;
f = scale10(raw, -e);
} else if f < 1.0 {
e -= 1;
f = scale10(raw, -e);
}
if f < 1.5 {
(1, e)
} else if f < 3.5 {
(2, e)
} else if f < 7.5 {
(5, e)
} else {
(1, e + 1)
}
}
fn span_ticks(lo: f64, hi: f64, mantissa: u32, exp: i32) -> TickSet {
let step = scale10(f64::from(mantissa), exp);
if !(lo.is_finite() && hi.is_finite() && step > 0.0) || hi < lo {
return TickSet::EMPTY;
}
// A bound within a millionth of a step of a tick counts as on it.
let k0 = ceil(lo / step - 1e-6) as i64;
let k1 = floor(hi / step + 1e-6) as i64;
let count = (k1 - k0 + 1).clamp(0, 10_000) as u32;
TickSet {
k0,
count,
mantissa,
exp,
}
}
/// About `target` nice ticks inside `[lo, hi]`.
pub fn ticks(lo: f64, hi: f64, target: u32) -> TickSet {
let (m, e) = nice_step(hi - lo, target);
span_ticks(lo, hi, m, e)
}
/// `[lo, hi]` widened outward to whole multiples of its nice step, so an
/// axis begins and ends on a tick.
pub fn nice_bounds(lo: f64, hi: f64, target: u32) -> (f64, f64) {
let (m, e) = nice_step(hi - lo, target);
let step = scale10(f64::from(m), e);
if !(step > 0.0 && lo.is_finite() && hi.is_finite()) {
return (lo, hi);
}
let a = floor(lo / step + 1e-9);
let b = ceil(hi / step - 1e-9);
(scale10(a * f64::from(m), e), scale10(b * f64::from(m), e))
}
/// The SI prefix symbol for a power of ten that is a multiple of 3.
pub fn si_prefix(exp3: i32) -> &'static str {
match exp3 {
-15 => "f",
-12 => "p",
-9 => "n",
-6 => "µ",
-3 => "m",
3 => "k",
6 => "M",
9 => "G",
12 => "T",
15 => "P",
_ => "",
}
}
/// The prefix for a magnitude: below 1 the prefix that puts it in 1 … 999
/// (0.5 → 500 m); from 1 to 9 999 none; from 10 000 up, k, M, G, …
pub fn si_exp3(maxabs: f64) -> i32 {
if maxabs <= 0.0 || !maxabs.is_finite() {
return 0;
}
let l = floor(log10(maxabs) + 1e-12) as i32;
if (0..4).contains(&l) {
return 0;
}
(3 * l.div_euclid(3)).clamp(-15, 15)
}
/// Writes `x` with `decimals` decimals, a true minus sign (U+2212) and no
/// negative zero.
fn write_fixed(out: &mut Label, x: f64, decimals: u8) {
let half = 0.5 * scale10(1.0, -i32::from(decimals));
let mut x = x;
if fabs(x) < half {
x = 0.0;
}
if x < 0.0 {
out.push('−');
x = -x;
}
let _ = write!(out, "{:.*}", usize::from(decimals), x);
}
/// How the numbers on one value axis are written: one SI prefix for the whole
/// axis (it joins the unit: "mV") and as many decimals as the step needs.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NumberFormat {
/// The prefix's power of ten (a multiple of 3; 0 for none).
pub exp3: i32,
/// Decimals after the prefix is applied.
pub decimals: u8,
}
impl NumberFormat {
/// The format for a tick set: with `si`, a prefix chosen by the largest
/// magnitude on the axis; decimals from the step.
pub fn for_ticks(ts: &TickSet, si: bool) -> NumberFormat {
let exp3 = if si {
match ts.bounds() {
Some((a, b)) => si_exp3(fabs(a).max(fabs(b))),
None => 0,
}
} else {
0
};
NumberFormat {
exp3,
decimals: (exp3 - ts.exp).clamp(0, 12) as u8,
}
}
/// The prefix symbol ("m", "k", "" …).
pub fn prefix(&self) -> &'static str {
si_prefix(self.exp3)
}
/// Writes `v` (in base units) into `out` after clearing it.
pub fn write(&self, v: f64, out: &mut Label) {
out.clear();
self.append(v, out);
}
/// Appends `v` (in base units) to `out`.
pub fn append(&self, v: f64, out: &mut Label) {
if !v.is_finite() {
out.push('—');
return;
}
write_fixed(out, scale10(v, -self.exp3), self.decimals);
}
/// The same prefix with `extra` more decimals (a readout reads finer than
/// the axis's ticks).
pub fn finer(&self, extra: u8) -> NumberFormat {
NumberFormat {
exp3: self.exp3,
decimals: (self.decimals + extra).min(12),
}
}
/// `prefix + unit` into `out` ("mV"); a unit marked not SI ("dB", "%")
/// gets no prefix, so the format must then have been made without one.
pub fn unit_label(&self, unit: &str, out: &mut Label) {
out.clear();
out.push_str(self.prefix());
out.push_str(unit);
}
}
/// Writes `v` with `sig` significant digits and its own SI prefix (when
/// `si`) into `out`, returning the prefix's power of ten.
pub fn write_value(v: f64, sig: u8, si: bool, out: &mut Label) -> i32 {
out.clear();
if !v.is_finite() {
out.push('—');
return 0;
}
let exp3 = if si { si_exp3(fabs(v)) } else { 0 };
let s = scale10(v, -exp3);
let l = if s == 0.0 {
0
} else {
floor(log10(fabs(s)) + 1e-12) as i32
};
let decimals = (i32::from(sig.max(1)) - 1 - l).clamp(0, 12) as u8;
write_fixed(out, s, decimals);
exp3
}
/// How a time axis reads.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TimeStyle {
/// Seconds from an origin, signed: a live scope's "−2.5 … 0" before now,
/// or a triggered sweep's time from its trigger. In s, ms or µs; past two
/// minutes, −m:ss.
Relative,
/// A clock from an origin (a recording's start): m:ss, m:ss.fff, h:mm:ss.
Clock,
}
/// Steps for long time axes, in seconds.
const TIME_STEPS: [u32; 15] = [
10, 15, 30, 60, 120, 300, 600, 900, 1800, 3600, 7200, 10_800, 21_600, 43_200, 86_400,
];
/// About `target` ticks on a time axis from `lo` to `hi` seconds (both
/// measured from the axis's origin): decimal steps below ten seconds, then
/// 10 s, 15 s, 30 s, minutes and hours.
pub fn time_ticks(lo: f64, hi: f64, target: u32) -> TickSet {
let raw = (hi - lo) / f64::from(target.max(1));
if !(raw.is_finite() && raw > 0.0) {
return TickSet::EMPTY;
}
if raw < 7.5 {
return ticks(lo, hi, target);
}
let mut best = TIME_STEPS[0];
let mut err = f64::INFINITY;
for &s in &TIME_STEPS {
let d = fabs(libm::log(f64::from(s) / raw));
if d < err {
err = d;
best = s;
}
}
span_ticks(lo, hi, best, 0)
}
/// How the labels of one time axis are written.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct TimeFormat {
/// The style.
pub style: TimeStyle,
/// The seconds' prefix for decimal labels (0, −3 or −6).
pub exp3: i32,
/// Decimals (of seconds after the prefix, or of the clock's seconds).
pub decimals: u8,
/// Written as a clock: 0 no, 1 m:ss, 2 h:mm:ss.
pub clock: u8,
}
impl TimeFormat {
/// The format for a tick set from [`time_ticks`] (values from the origin).
pub fn for_ticks(ts: &TickSet, style: TimeStyle) -> TimeFormat {
let (a, b) = ts.bounds().unwrap_or((0.0, 0.0));
let maxabs = fabs(a).max(fabs(b));
let clock_decimals = (-ts.exp).clamp(0, 6) as u8;
let clock_kind = if maxabs >= 3600.0 { 2 } else { 1 };
match style {
TimeStyle::Relative if maxabs < 120.0 => {
let exp3 = si_exp3(maxabs).clamp(-6, 0);
TimeFormat {
style,
exp3,
decimals: (exp3 - ts.exp).clamp(0, 9) as u8,
clock: 0,
}
}
_ => TimeFormat {
style,
exp3: 0,
decimals: clock_decimals,
clock: clock_kind,
},
}
}
/// The unit for the axis title: "s", "ms", "µs", "m:ss" or "h:mm:ss".
pub fn unit(&self) -> &'static str {
match (self.clock, self.exp3) {
(1, _) => "m:ss",
(2, _) => "h:mm:ss",
(_, -3) => "ms",
(_, -6) => "µs",
_ => "s",
}
}
/// The same format with `extra` more decimals (the cursor's time).
pub fn finer(&self, extra: u8) -> TimeFormat {
let cap = if self.clock > 0 { 6 } else { 9 };
TimeFormat {
decimals: (self.decimals + extra).min(cap),
..*self
}
}
/// Writes `t` seconds from the origin into `out` after clearing it.
pub fn write(&self, t: f64, out: &mut Label) {
out.clear();
if !t.is_finite() {
out.push('—');
return;
}
if self.clock == 0 {
write_fixed(out, scale10(t, -self.exp3), self.decimals);
return;
}
// Whole units of 10^-decimals seconds, split without float error.
let unit = scale10(1.0, -i32::from(self.decimals));
let mut n = round(t / unit) as i64;
if n < 0 {
out.push('−');
n = -n;
}
let per_s = scale10(1.0, i32::from(self.decimals)) as i64;
let frac = n % per_s;
let secs = n / per_s;
let (h, m, s) = (secs / 3600, (secs / 60) % 60, secs % 60);
if self.clock == 2 {
let _ = write!(out, "{h}:{m:02}:{s:02}");
} else {
let _ = write!(out, "{}:{s:02}", secs / 60);
}
if self.decimals > 0 {
let _ = write!(out, ".{:0width$}", frac, width = usize::from(self.decimals));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
extern crate std;
use std::vec::Vec;
fn labels_num(ts: &TickSet, f: &NumberFormat) -> Vec<std::string::String> {
(0..ts.count)
.map(|i| {
let mut l = Label::new();
f.write(ts.value(i), &mut l);
std::string::String::from(l.as_str())
})
.collect()
}
fn labels_time(ts: &TickSet, f: &TimeFormat) -> Vec<std::string::String> {
(0..ts.count)
.map(|i| {
let mut l = Label::new();
f.write(ts.value(i), &mut l);
std::string::String::from(l.as_str())
})
.collect()
}
#[test]
fn nice_steps_are_1_2_5() {
assert_eq!(nice_step(10.0, 5), (2, 0));
assert_eq!(nice_step(1.0, 5), (2, -1));
assert_eq!(nice_step(7.0, 5), (1, 0));
assert_eq!(nice_step(0.003, 4), (1, -3));
assert_eq!(nice_step(100.0, 10), (1, 1));
assert_eq!(nice_step(1000.0, 10), (1, 2));
assert_eq!(nice_step(0.0, 5), (1, 0));
assert_eq!(nice_step(f64::NAN, 5), (1, 0));
// Every power of ten, both ways, lands on mantissa 1.
for e in -12..=12 {
assert_eq!(nice_step(scale10(5.0, e), 5), (1, e), "10^{e}");
}
}
#[test]
fn tick_values_are_exact() {
let ts = ticks(0.1, 0.35, 5);
assert_eq!(ts.step(), 0.05);
let v: Vec<f64> = (0..ts.count).map(|i| ts.value(i)).collect();
assert_eq!(v, [0.1, 0.15, 0.2, 0.25, 0.3, 0.35]);
let ts = ticks(-1.0, 1.0, 5);
let v: Vec<f64> = (0..ts.count).map(|i| ts.value(i)).collect();
assert_eq!(v, [-1.0, -0.5, 0.0, 0.5, 1.0]);
// Bounds a hair inside a tick still include it.
let ts = ticks(0.30000000000000004, 0.9, 6);
assert_eq!(ts.value(0), 0.3);
}
#[test]
fn bounds_widen_to_ticks() {
assert_eq!(nice_bounds(0.13, 0.87, 5), (0.1, 0.9));
assert_eq!(nice_bounds(-3.2, 7.9, 5), (-4.0, 8.0));
assert_eq!(nice_bounds(2.0, 4.0, 4), (2.0, 4.0));
}
#[test]
fn si_prefixes_per_axis() {
let ts = ticks(0.0, 0.002, 4);
let f = NumberFormat::for_ticks(&ts, true);
assert_eq!(f.prefix(), "m");
assert_eq!(labels_num(&ts, &f), ["0.0", "0.5", "1.0", "1.5", "2.0"]);
let ts = ticks(0.0, 50_000.0, 5);
let f = NumberFormat::for_ticks(&ts, true);
assert_eq!(f.prefix(), "k");
assert_eq!(labels_num(&ts, &f), ["0", "10", "20", "30", "40", "50"]);
let ts = ticks(0.0, 2000.0, 4);
let f = NumberFormat::for_ticks(&ts, true);
assert_eq!(f.prefix(), "");
assert_eq!(labels_num(&ts, &f), ["0", "500", "1000", "1500", "2000"]);
let ts = ticks(-0.5, 0.5, 4);
let f = NumberFormat::for_ticks(&ts, true);
assert_eq!(f.prefix(), "m");
assert_eq!(labels_num(&ts, &f), ["−400", "−200", "0", "200", "400"]);
let ts = ticks(-1.0, 1.0, 4);
let f = NumberFormat::for_ticks(&ts, false);
assert_eq!(labels_num(&ts, &f), ["−1.0", "−0.5", "0.0", "0.5", "1.0"]);
let ts = ticks(0.0, 4e-7, 4);
let f = NumberFormat::for_ticks(&ts, true);
assert_eq!(f.prefix(), "n");
let mut u = Label::new();
f.unit_label("V", &mut u);
assert_eq!(u, "nV");
assert_eq!(si_exp3(0.5), -3);
assert_eq!(si_exp3(0.0005), -6);
assert_eq!(si_exp3(9999.0), 0);
assert_eq!(si_exp3(1e7), 6);
assert_eq!(si_exp3(1e30), 15);
}
#[test]
fn values_with_significant_digits() {
let mut l = Label::new();
assert_eq!(write_value(0.0123456, 4, true, &mut l), -3);
assert_eq!(l, "12.35");
assert_eq!(write_value(-1234.6, 4, true, &mut l), 0);
assert_eq!(l, "−1235");
assert_eq!(write_value(98_765.0, 3, true, &mut l), 3);
assert_eq!(l, "98.8");
write_value(f64::NAN, 3, true, &mut l);
assert_eq!(l, "—");
write_value(-0.0, 3, false, &mut l);
assert_eq!(l, "0.00");
}
#[test]
fn relative_time_axes() {
let ts = time_ticks(-30.0, 0.0, 6);
let f = TimeFormat::for_ticks(&ts, TimeStyle::Relative);
assert_eq!(f.unit(), "s");
assert_eq!(
labels_time(&ts, &f),
["−30", "−25", "−20", "−15", "−10", "−5", "0"]
);
let ts = time_ticks(-0.1, 0.0, 5);
let f = TimeFormat::for_ticks(&ts, TimeStyle::Relative);
assert_eq!(f.unit(), "ms");
assert_eq!(
labels_time(&ts, &f),
["−100", "−80", "−60", "−40", "−20", "0"]
);
let ts = time_ticks(-0.025, 0.075, 4);
let f = TimeFormat::for_ticks(&ts, TimeStyle::Relative);
assert_eq!(labels_time(&ts, &f), ["−20", "0", "20", "40", "60"]);
let ts = time_ticks(-2.5, 0.0, 5);
let f = TimeFormat::for_ticks(&ts, TimeStyle::Relative);
assert_eq!(
labels_time(&ts, &f),
["−2.5", "−2.0", "−1.5", "−1.0", "−0.5", "0.0"]
);
let ts = time_ticks(-300.0, 0.0, 5);
let f = TimeFormat::for_ticks(&ts, TimeStyle::Relative);
assert_eq!(f.unit(), "m:ss");
assert_eq!(
labels_time(&ts, &f),
["−5:00", "−4:00", "−3:00", "−2:00", "−1:00", "0:00"]
);
}
#[test]
fn clock_time_axes() {
let ts = time_ticks(0.0, 300.0, 5);
let f = TimeFormat::for_ticks(&ts, TimeStyle::Clock);
assert_eq!(
labels_time(&ts, &f),
["0:00", "1:00", "2:00", "3:00", "4:00", "5:00"]
);
let ts = time_ticks(61.25, 61.75, 5);
let f = TimeFormat::for_ticks(&ts, TimeStyle::Clock);
assert_eq!(
labels_time(&ts, &f),
["1:01.3", "1:01.4", "1:01.5", "1:01.6", "1:01.7"]
);
let ts = time_ticks(3600.0, 7200.0, 6);
let f = TimeFormat::for_ticks(&ts, TimeStyle::Clock);
assert_eq!(f.unit(), "h:mm:ss");
assert_eq!(labels_time(&ts, &f)[..3], ["1:00:00", "1:10:00", "1:20:00"]);
let ts = time_ticks(0.0, 0.01, 5);
let f = TimeFormat::for_ticks(&ts, TimeStyle::Clock).finer(1);
let mut l = Label::new();
f.write(0.0123456, &mut l);
assert_eq!(l, "0:00.0123");
// Rounding never shows 60 seconds.
let f = TimeFormat {
style: TimeStyle::Clock,
exp3: 0,
decimals: 1,
clock: 1,
};
f.write(59.96, &mut l);
assert_eq!(l, "1:00.0");
}
}