use super::AxisScale;
use crate::core::TickFormatter;
pub fn generate_ticks(min: f64, max: f64, target_count: usize) -> Vec<f64> {
if target_count == 0 || (max - min).abs() < f64::EPSILON {
return vec![min, max];
}
let (min, max) = if min <= max { (min, max) } else { (max, min) };
let max_ticks = target_count.clamp(3, 10);
generate_nice_ticks(min, max, max_ticks)
}
pub fn generate_minor_ticks(major_ticks: &[f64], count: usize) -> Vec<f64> {
if major_ticks.len() < 2 || count == 0 {
return Vec::new();
}
let mut minor_ticks = Vec::new();
for window in major_ticks.windows(2) {
let start = window[0];
let end = window[1];
let step = (end - start) / (count + 1) as f64;
for i in 1..=count {
minor_ticks.push(start + step * i as f64);
}
}
minor_ticks
}
pub fn generate_ticks_for_scale(
min: f64,
max: f64,
target_count: usize,
scale: &AxisScale,
) -> Vec<f64> {
match scale {
AxisScale::Linear => generate_ticks(min, max, target_count),
AxisScale::Log => generate_log_ticks(min, max, target_count),
AxisScale::SymLog { linthresh } => {
generate_symlog_ticks(min, max, *linthresh, target_count)
}
}
}
pub fn generate_log_ticks(min: f64, max: f64, target_count: usize) -> Vec<f64> {
let (min, max) = if min <= max { (min, max) } else { (max, min) };
if min <= 0.0 || max <= 0.0 || !min.is_finite() || !max.is_finite() {
let mut fallback = vec![min, max];
fallback.retain(|tick| tick.is_finite() && *tick > 0.0);
fallback.dedup_by(|left, right| left == right);
return fallback;
}
if target_count == 0 || min == max {
return vec![min, max];
}
let log_min = min.log10().floor() as i32;
let log_max = max.log10().ceil() as i32;
let decades = (log_max - log_min) as usize;
let mut ticks = Vec::new();
if decades <= target_count {
for exp in log_min..=log_max {
let tick = 10.0_f64.powi(exp);
if tick >= min && tick <= max {
ticks.push(tick);
}
}
if decades <= target_count / 2 {
for exp in log_min..log_max {
let base = 10.0_f64.powi(exp);
for &mult in &[2.0, 5.0] {
let tick = base * mult;
if tick >= min && tick <= max {
ticks.push(tick);
}
}
}
}
} else {
let step = ((decades as f64) / (target_count as f64)).ceil() as i32;
let start_exp = log_min;
let mut exp = start_exp;
while exp <= log_max {
let tick = 10.0_f64.powi(exp);
if tick >= min && tick <= max {
ticks.push(tick);
}
exp += step;
}
}
if ticks.len() < 2 {
ticks.extend([min, max]);
}
ticks.sort_by(f64::total_cmp);
ticks.dedup_by(|left, right| left == right);
ticks
}
pub fn generate_symlog_ticks(min: f64, max: f64, linthresh: f64, target_count: usize) -> Vec<f64> {
if target_count == 0 || (max - min).abs() < f64::EPSILON {
return vec![min, max];
}
let (min, max) = if min <= max { (min, max) } else { (max, min) };
let mut ticks = Vec::new();
let lin_min = min.max(-linthresh);
let lin_max = max.min(linthresh);
if lin_min < lin_max {
if min < 0.0 && max > 0.0 {
ticks.push(0.0);
}
if lin_min < 0.0 {
ticks.push(lin_min);
}
if lin_max > 0.0 {
ticks.push(lin_max);
}
}
if max > linthresh {
let log_ticks = generate_log_ticks(linthresh, max, target_count / 2);
for tick in log_ticks {
if tick > linthresh && tick <= max {
ticks.push(tick);
}
}
}
if min < -linthresh {
let log_ticks = generate_log_ticks(linthresh, -min, target_count / 2);
for tick in log_ticks {
let neg_tick = -tick;
if neg_tick < -linthresh && neg_tick >= min {
ticks.push(neg_tick);
}
}
}
ticks.sort_by(|a, b| a.partial_cmp(b).unwrap());
ticks.dedup_by(|a, b| relative_ticks_overlap(*a, *b));
ticks
}
fn relative_ticks_overlap(left: f64, right: f64) -> bool {
left == right || (left - right).abs() <= left.abs().max(right.abs()) * f64::EPSILON * 8.0
}
pub fn generate_log_minor_ticks(major_ticks: &[f64]) -> Vec<f64> {
if major_ticks.len() < 2 {
return Vec::new();
}
let mut minor_ticks = Vec::new();
for window in major_ticks.windows(2) {
let start = window[0];
let end = window[1];
if (end / start - 10.0).abs() < 0.01 {
for mult in 2..=9 {
let tick = start * mult as f64;
if tick > start && tick < end {
minor_ticks.push(tick);
}
}
}
}
minor_ticks
}
pub(crate) const MAX_TICK_STEPS: usize = 100;
const STEP_LADDER: [(f64, f64); 4] = [(1.0, 1.0), (2.0, 0.9), (2.5, 0.45), (5.0, 0.8)];
const STEP_EXPONENT_BELOW: i32 = 1;
const STEP_EXPONENT_ABOVE: i32 = 2;
const WEIGHT_DENSITY: f64 = 0.55;
const WEIGHT_SIMPLICITY: f64 = 0.30;
const WEIGHT_COVERAGE: f64 = 0.15;
const OVERSHOOT_PENALTY: f64 = 3.0;
const AXIS_LABEL_BUDGET_CHARS: f64 = 60.0;
const LABEL_GAP_CHARS: f64 = 2.0;
const MAX_PLAIN_DECIMALS: i32 = 6;
const SCIENTIFIC_LABEL_CHARS: usize = 6;
fn generate_nice_ticks(min: f64, max: f64, target_count: usize) -> Vec<f64> {
if max - min <= 0.0 {
return vec![min];
}
best_step_and_ticks(min, max, target_count)
.map(|(_, ticks)| ticks)
.unwrap_or_else(|| vec![min, max])
}
pub(crate) fn select_nice_step(min: f64, max: f64, target_count: usize) -> Option<f64> {
best_step_and_ticks(min, max, target_count).map(|(step, _)| step)
}
fn best_step_and_ticks(min: f64, max: f64, target_count: usize) -> Option<(f64, Vec<f64>)> {
let range = max - min;
if !range.is_finite() || range <= 0.0 {
return None;
}
let target = target_count.max(2) as f64;
let rough_step = range / (target - 1.0);
if !rough_step.is_finite() || rough_step <= f64::EPSILON {
return None;
}
let base_exponent = rough_step.log10().floor();
if !base_exponent.is_finite() {
return None;
}
let base_exponent = base_exponent as i32;
let mut best_fitting: Option<(f64, f64, Vec<f64>)> = None;
let mut best_overflowing: Option<(f64, f64, Vec<f64>)> = None;
for exponent in (base_exponent - STEP_EXPONENT_BELOW)..=(base_exponent + STEP_EXPONENT_ABOVE) {
let magnitude = 10.0_f64.powi(exponent);
if !magnitude.is_finite() || magnitude <= 0.0 {
continue;
}
for (mantissa, simplicity) in STEP_LADDER {
let step = mantissa * magnitude;
if !step.is_finite() || step <= 0.0 {
continue;
}
let Some(ticks) = ticks_for_step(min, max, step) else {
continue;
};
if ticks.len() < 2 {
continue;
}
let ceiling = tick_ceiling(target, &ticks, mantissa, exponent);
let score = score_candidate(&ticks, range, ceiling, simplicity);
let bucket = if ticks.len() as f64 <= ceiling {
&mut best_fitting
} else {
&mut best_overflowing
};
if bucket
.as_ref()
.is_none_or(|(best_score, _, _)| score > *best_score)
{
*bucket = Some((score, step, ticks));
}
}
}
best_fitting
.or(best_overflowing)
.map(|(_, step, ticks)| (step, ticks))
}
fn score_candidate(ticks: &[f64], range: f64, ceiling: f64, simplicity: f64) -> f64 {
let count = ticks.len() as f64;
let density = if count <= ceiling {
2.0 - ceiling / count
} else {
let excess = count / ceiling - 1.0;
1.0 - OVERSHOOT_PENALTY * excess * excess
};
let span = ticks[ticks.len() - 1] - ticks[0];
let coverage = if range > 0.0 && span.is_finite() {
(span / range).clamp(0.0, 1.0)
} else {
0.0
};
WEIGHT_DENSITY * density + WEIGHT_SIMPLICITY * simplicity + WEIGHT_COVERAGE * coverage
}
fn tick_ceiling(target: f64, ticks: &[f64], mantissa: f64, exponent: i32) -> f64 {
let chars = estimated_label_chars(ticks, mantissa, exponent) as f64;
let allowed = (AXIS_LABEL_BUDGET_CHARS / (chars + LABEL_GAP_CHARS)).floor();
target.min(allowed).max(2.0)
}
fn estimated_label_chars(ticks: &[f64], mantissa: f64, exponent: i32) -> usize {
let sign = usize::from(ticks.iter().any(|tick| *tick < 0.0));
let decimals = step_decimals(mantissa, exponent);
if decimals > MAX_PLAIN_DECIMALS {
return SCIENTIFIC_LABEL_CHARS + sign;
}
let max_abs = ticks
.iter()
.fold(0.0_f64, |widest, tick| widest.max(tick.abs()));
let integer_digits = if max_abs >= 1.0 && max_abs.is_finite() {
max_abs.log10().floor() as usize + 1
} else {
1
};
let decimals = decimals as usize;
sign + integer_digits + if decimals > 0 { decimals + 1 } else { 0 }
}
fn step_decimals(mantissa: f64, exponent: i32) -> i32 {
let fractional = i32::from((mantissa - mantissa.round()).abs() > 1e-9);
(fractional - exponent).max(0)
}
fn ticks_for_step(min: f64, max: f64, step: f64) -> Option<Vec<f64>> {
let first_index = (min / step).floor();
let last_index = (max / step).ceil();
if !first_index.is_finite() || !last_index.is_finite() {
return None;
}
let start = first_index * step;
if !start.is_finite() || start + step <= start {
return None;
}
let steps = last_index - first_index;
if !steps.is_finite() || steps < 0.0 || steps > MAX_TICK_STEPS as f64 {
return None;
}
let steps = steps.round() as usize;
let mut ticks = Vec::with_capacity(steps + 1);
let epsilon = step * 1e-10;
for i in 0..=steps {
let tick = start + (i as f64) * step;
if tick.is_finite() && tick >= min - epsilon && tick <= max + epsilon {
ticks.push(clean_float(tick, step));
}
}
ticks.dedup();
Some(ticks)
}
pub(crate) fn clean_float(value: f64, step: f64) -> f64 {
let decimals = if step >= 1.0 {
i32::from((step - step.round()).abs() > 1e-9)
} else {
(-step.log10().floor()) as i32 + 1
};
let mult = 10.0_f64.powi(decimals);
if !mult.is_finite() || mult <= 0.0 {
return value;
}
let cleaned = (value * mult).round() / mult;
if !cleaned.is_finite() {
return value;
}
if cleaned == 0.0 { 0.0 } else { cleaned }
}
fn shared_formatter() -> &'static TickFormatter {
static FORMATTER: std::sync::LazyLock<TickFormatter> =
std::sync::LazyLock::new(TickFormatter::default);
&FORMATTER
}
pub fn format_tick_label(value: f64) -> String {
shared_formatter().format_tick(value)
}
pub fn format_tick_labels(values: &[f64]) -> Vec<String> {
shared_formatter().format_ticks(values)
}
pub fn format_tick_labels_for_scale(values: &[f64], scale: &AxisScale) -> Vec<String> {
let plain = format_tick_labels(values);
match scale {
AxisScale::Log => values
.iter()
.zip(plain)
.map(|(&value, plain)| {
log_decade_label(value)
.or_else(|| log_scientific_label(&plain))
.unwrap_or(plain)
})
.collect(),
_ => plain,
}
}
pub fn format_log_tick_label(value: f64) -> String {
format_tick_labels_for_scale(std::slice::from_ref(&value), &AxisScale::Log)
.into_iter()
.next()
.unwrap_or_default()
}
fn log_decade_label(value: f64) -> Option<String> {
if !value.is_finite() || value <= 0.0 {
return None;
}
let exponent = value.log10();
if (exponent.round() - exponent).abs() >= 1e-10 {
return None;
}
Some(format!(
"10{}",
superscript_exponent(exponent.round() as i32)
))
}
fn log_scientific_label(plain: &str) -> Option<String> {
let (mantissa, exponent) = plain.split_once(['e', 'E'])?;
let exponent: i32 = exponent.parse().ok()?;
let mantissa = mantissa.trim_end_matches('0').trim_end_matches('.');
if mantissa.is_empty() || mantissa == "-" {
return None;
}
Some(format!("{mantissa}×10{}", superscript_exponent(exponent)))
}
fn superscript_exponent(exponent: i32) -> String {
let exponent = exponent as i64;
let mut formatted = String::new();
if exponent < 0 {
formatted.push('⁻');
}
for digit in exponent.abs().to_string().chars() {
let superscript = match digit {
'0' => '⁰',
'1' => '¹',
'2' => '²',
'3' => '³',
'4' => '⁴',
'5' => '⁵',
'6' => '⁶',
'7' => '⁷',
'8' => '⁸',
'9' => '⁹',
_ => digit,
};
formatted.push(superscript);
}
formatted
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_generate_ticks_basic() {
let ticks = generate_ticks(0.0, 10.0, 5);
assert!(!ticks.is_empty());
assert!(ticks.len() <= 10);
assert!(ticks[0] >= 0.0);
assert!(*ticks.last().unwrap() <= 10.0);
}
#[test]
fn test_generate_ticks_nice_numbers() {
let ticks = generate_ticks(0.0, 100.0, 6);
for tick in &ticks {
let tick_int = *tick as i64;
assert!(tick_int % 10 == 0 || tick_int % 5 == 0 || tick_int % 2 == 0);
}
}
#[test]
fn test_generate_minor_ticks() {
let major = vec![0.0, 10.0, 20.0];
let minor = generate_minor_ticks(&major, 4);
assert_eq!(minor.len(), 8); assert!(minor[0] > 0.0 && minor[0] < 10.0);
}
#[test]
fn test_invalid_range() {
let ticks = generate_ticks(10.0, 5.0, 5);
assert!(!ticks.is_empty());
assert!(ticks.windows(2).all(|window| window[0] <= window[1]));
assert!(ticks[0] >= 5.0);
assert!(*ticks.last().unwrap() <= 10.0);
}
#[test]
fn test_generate_ticks_ordinary_ranges() {
assert_eq!(generate_ticks(0.0, 10.0, 5), vec![0.0, 2.5, 5.0, 7.5, 10.0]);
assert_eq!(generate_ticks(0.0, 10.0, 3), vec![0.0, 5.0, 10.0]);
assert_eq!(
generate_ticks(0.0, 10.0, 10),
vec![0.0, 2.0, 4.0, 6.0, 8.0, 10.0]
);
assert_eq!(
generate_ticks(-5.0, 5.0, 5),
vec![-4.0, -2.0, 0.0, 2.0, 4.0]
);
assert_eq!(
generate_ticks(-5.0, 5.0, 7),
vec![-4.0, -2.0, 0.0, 2.0, 4.0]
);
assert_eq!(generate_ticks(0.7, 9.3, 5), vec![2.0, 4.0, 6.0, 8.0]);
assert_eq!(generate_ticks(0.7, 9.3, 8), vec![2.0, 4.0, 6.0, 8.0]);
assert_eq!(
generate_ticks(0.0, 100.0, 6),
vec![0.0, 20.0, 40.0, 60.0, 80.0, 100.0]
);
assert_eq!(generate_ticks(10.0, 5.0, 5), vec![6.0, 8.0, 10.0]);
}
#[test]
fn test_generate_ticks_never_returns_a_lone_tick() {
for target in 3..=10 {
for (min, max) in [(0.7, 9.3), (0.0, 1.0), (-0.217, 0.982), (3.0, 3.4)] {
let ticks = generate_ticks(min, max, target);
assert!(
ticks.len() >= 2,
"({min}, {max}) target {target} produced {ticks:?}"
);
}
}
}
#[test]
fn test_generate_ticks_awkward_range_lands_on_round_numbers() {
let ticks = generate_ticks(-0.217, 0.982, 6);
assert_eq!(ticks, vec![-0.2, 0.0, 0.2, 0.4, 0.6, 0.8]);
let step = ticks[1] - ticks[0];
for tick in &ticks {
let multiples = tick / step;
assert!(
(multiples - multiples.round()).abs() < 1e-9,
"{tick} is not a multiple of the step {step}"
);
}
}
fn widest_label(ticks: &[f64]) -> usize {
format_tick_labels(ticks)
.iter()
.map(|label| label.chars().count())
.max()
.unwrap_or(0)
}
#[test]
fn test_generate_ticks_target_is_a_hard_ceiling() {
assert_eq!(
generate_ticks(0.0, 1.0, 8),
vec![0.0, 0.2, 0.4, 0.6, 0.8, 1.0]
);
assert_eq!(
generate_ticks(0.0, 1.0, 10),
vec![0.0, 0.2, 0.4, 0.6, 0.8, 1.0]
);
for target in 3..=10 {
for (min, max) in [
(0.0, 1.0),
(0.0, 10.0),
(0.0, 100.0),
(0.0, 2.5e7),
(-1.0, 1.0),
(-5.0, 5.0),
(0.7, 9.3),
(-0.8, 16.8),
(-0.217, 0.982),
(99_000.0, 101_000.0),
(0.0, 1e9),
(3.0, 3.4),
] {
let ticks = generate_ticks(min, max, target);
assert!(
ticks.len() <= target,
"({min}, {max}) target {target} emitted {} ticks: {ticks:?}",
ticks.len()
);
}
}
}
#[test]
fn test_generate_ticks_readability_wins_survive_the_ceiling() {
assert_eq!(
generate_ticks(-0.8, 16.8, 8),
vec![0.0, 2.5, 5.0, 7.5, 10.0, 12.5, 15.0]
);
assert_eq!(
generate_ticks(-1.936_068_671_663_616, 1.935_948_520_314_347_8, 8),
vec![-1.5, -1.0, -0.5, 0.0, 0.5, 1.0, 1.5]
);
}
#[test]
fn test_generate_ticks_long_labels_lower_the_ceiling() {
let ticks = generate_ticks(0.0, 2.5e7, 10);
assert_eq!(
ticks,
vec![0.0, 5e6, 1e7, 1.5e7, 2e7, 2.5e7],
"long labels must not be packed at the requested density"
);
let chars = widest_label(&ticks);
assert_eq!(chars, 8, "expected labels like \"25000000\"");
assert!(
(ticks.len() as f64) * (chars as f64 + LABEL_GAP_CHARS) <= AXIS_LABEL_BUDGET_CHARS,
"{} labels of {chars} chars do not fit the {AXIS_LABEL_BUDGET_CHARS}-char budget",
ticks.len()
);
}
#[test]
fn test_generate_ticks_label_budget_is_monotone_in_label_width() {
let mut previous = usize::MAX;
let mut previous_chars = 0;
for exponent in 0..9 {
let max = 2.5 * 10.0_f64.powi(exponent);
let ticks = generate_ticks(0.0, max, 10);
let chars = widest_label(&ticks);
assert!(
chars < previous_chars || ticks.len() <= previous,
"0..{max:e} widened labels to {chars} chars yet grew to {} ticks (was {previous})",
ticks.len()
);
previous = ticks.len();
previous_chars = chars;
}
}
#[test]
fn test_generate_ticks_are_evenly_spaced() {
for (min, max) in [
(0.0, 25.0),
(0.0, 10.0),
(0.0, 2.5),
(-8.34, 14.86),
(268.879, 277.254),
] {
for target in 3..=10 {
let ticks = generate_ticks(min, max, target);
if ticks.len() < 3 {
continue;
}
let step = ticks[1] - ticks[0];
for pair in ticks.windows(2) {
assert!(
(pair[1] - pair[0] - step).abs() <= step.abs() * 1e-6,
"({min}, {max}) target {target} is unevenly spaced: {ticks:?}"
);
}
}
}
}
#[test]
fn test_generate_ticks_step_below_ulp_terminates() {
let min = 1e16;
let max = 1e16 + 2.0;
let ticks = generate_ticks(min, max, 6);
assert_eq!(ticks, vec![min, max]);
}
#[test]
fn test_generate_ticks_unit_step_below_ulp_terminates() {
let min = 1e16;
let max = 1e16 + 5.0;
let ticks = generate_ticks(min, max, 6);
assert_eq!(ticks, vec![min, min + 2.0, max]);
assert!(ticks.windows(2).all(|pair| pair[0] < pair[1]));
}
#[test]
fn test_generate_ticks_returns_quickly_for_ulp_trap() {
let start = std::time::Instant::now();
for target in 3..=10 {
let ticks = generate_ticks(1e16, 1e16 + 5.0, target);
assert!(ticks.len() <= MAX_TICK_STEPS + 1);
}
assert!(
start.elapsed() < std::time::Duration::from_secs(1),
"tick generation near 1e16 did not return promptly"
);
}
#[test]
fn test_generate_ticks_bounded_for_extreme_ranges() {
let cases = [
(0.0, f64::MAX),
(-f64::MAX, f64::MAX),
(1e300, 1e300 + 1.0),
(-1e16, -1e16 + 2.0),
(f64::MIN_POSITIVE, 1.0),
];
for (min, max) in cases {
let ticks = generate_ticks(min, max, 6);
assert!(
ticks.len() <= MAX_TICK_STEPS + 1,
"unbounded tick count for ({min}, {max}): {}",
ticks.len()
);
assert!(
ticks.iter().all(|tick| tick.is_finite()),
"non-finite tick for ({min}, {max}): {ticks:?}"
);
}
}
#[test]
fn test_generate_ticks_never_emits_negative_zero() {
for target in 3..=10 {
for (min, max) in [(-0.217, 0.982), (-0.4, 1.0), (-1.0, 3.0), (-0.05, 0.15)] {
for tick in generate_ticks(min, max, target) {
assert!(
!(tick == 0.0 && tick.is_sign_negative()),
"negative zero from ({min}, {max}) target {target}"
);
}
}
}
}
#[test]
fn test_format_tick_labels_share_one_precision() {
assert_eq!(
format_tick_labels(&[0.0, 0.5, 1.0, 1.5, 2.0]),
vec!["0", "0.5", "1", "1.5", "2"]
);
assert_eq!(format_tick_labels(&[]), Vec::<String>::new());
}
#[test]
fn test_no_axis_mixes_plain_and_scientific_notation() {
let cases: [(f64, f64, AxisScale); 10] = [
(0.0, 1.0, AxisScale::Linear),
(0.0, 1e6, AxisScale::Linear),
(99000.0, 101000.0, AxisScale::Linear),
(0.0, 0.001, AxisScale::Linear),
(1e-6, 5e-6, AxisScale::Linear),
(-1e7, 1e7, AxisScale::Linear),
(1.0, 1e5, AxisScale::Log),
(1e-6, 1.0, AxisScale::Log),
(1e-8, 1e-6, AxisScale::Log),
(1.0, 100.0, AxisScale::Log),
];
for (min, max, scale) in cases {
let ticks = generate_ticks_for_scale(min, max, 6, &scale);
let labels = format_tick_labels_for_scale(&ticks, &scale);
assert_eq!(labels.len(), ticks.len());
assert!(
labels.iter().all(|label| !label.is_empty()),
"empty label for ({min}, {max}) {scale:?}: {labels:?}"
);
let candidates = labels.iter().filter(|label| label.as_str() != "0").count();
let scientific = labels
.iter()
.filter(|label| label.contains(['e', 'E']))
.count();
assert!(
scientific == 0 || scientific == candidates,
"({min}, {max}) {scale:?} mixed notations: {labels:?}"
);
}
}
#[test]
fn test_plain_representable_axes_stay_plain() {
for (min, max) in [(0.0, 1.0), (0.0, 1e6), (99000.0, 101000.0), (-1e7, 1e7)] {
let ticks = generate_ticks(min, max, 6);
let labels = format_tick_labels(&ticks);
assert!(
labels.iter().all(|label| !label.contains(['e', 'E'])),
"unexpected scientific notation for ({min}, {max}): {labels:?}"
);
}
}
#[test]
fn test_log_labels_use_superscript_decades() {
let labels = format_tick_labels_for_scale(&[1.0, 10.0, 100.0, 1000.0], &AxisScale::Log);
assert_eq!(labels, vec!["10⁰", "10¹", "10²", "10³"]);
assert_eq!(format_log_tick_label(0.001), "10⁻³");
assert_eq!(format_log_tick_label(20.0), "20");
assert_eq!(
format_tick_labels_for_scale(&[1e-8, 2e-8, 5e-8, 1e-7], &AxisScale::Log),
vec!["10⁻⁸", "2×10⁻⁸", "5×10⁻⁸", "10⁻⁷"]
);
assert_eq!(format_log_tick_label(0.0), "0");
assert_eq!(format_log_tick_label(-5.0), "-5");
}
#[test]
fn test_linear_labels_ignore_log_decade_formatting() {
let labels = format_tick_labels_for_scale(&[1.0, 10.0, 100.0], &AxisScale::Linear);
assert_eq!(labels, vec!["1", "10", "100"]);
}
#[test]
fn test_log_ticks_powers_of_10() {
let ticks = generate_log_ticks(1.0, 10000.0, 10);
assert!(ticks.contains(&1.0));
assert!(ticks.contains(&10.0));
assert!(ticks.contains(&100.0));
assert!(ticks.contains(&1000.0));
assert!(ticks.contains(&10000.0));
}
#[test]
fn test_log_ticks_few_decades() {
let ticks = generate_log_ticks(1.0, 100.0, 10);
assert!(ticks.len() > 3); }
#[test]
fn test_log_ticks_invalid_range() {
let ticks = generate_log_ticks(-10.0, 100.0, 5);
assert!(!ticks.is_empty());
}
#[test]
fn test_log_ticks_preserve_sub_epsilon_range() {
let min = f64::EPSILON / 1024.0;
let max = f64::EPSILON / 16.0;
let ticks = generate_log_ticks(min, max, 8);
assert!(
ticks.len() >= 2,
"expected distinct sub-epsilon ticks: {ticks:?}"
);
assert!(ticks.iter().all(|tick| *tick >= min && *tick <= max));
assert!(ticks.windows(2).all(|pair| pair[0] < pair[1]));
}
#[test]
fn test_log_ticks_fall_back_to_narrow_sub_epsilon_endpoints() {
let min = f64::EPSILON / 1024.0;
let max = min * 1.5;
let ticks = generate_log_ticks(min, max, 8);
assert_eq!(ticks, vec![min, max]);
}
#[test]
fn test_symlog_ticks_includes_zero() {
let ticks = generate_symlog_ticks(-100.0, 100.0, 1.0, 10);
assert!(ticks.contains(&0.0));
}
#[test]
fn test_symlog_ticks_both_regions() {
let ticks = generate_symlog_ticks(-1000.0, 1000.0, 1.0, 10);
let has_positive = ticks.iter().any(|&t| t > 1.0);
let has_negative = ticks.iter().any(|&t| t < -1.0);
assert!(has_positive);
assert!(has_negative);
}
#[test]
fn test_log_minor_ticks() {
let major = vec![1.0, 10.0, 100.0];
let minor = generate_log_minor_ticks(&major);
assert_eq!(minor.len(), 16); assert!(minor.contains(&2.0));
assert!(minor.contains(&5.0));
assert!(minor.contains(&20.0));
assert!(minor.contains(&50.0));
}
#[test]
fn test_generate_ticks_for_scale() {
let linear_ticks = generate_ticks_for_scale(0.0, 100.0, 5, &AxisScale::Linear);
assert!(!linear_ticks.is_empty());
let reversed_linear_ticks = generate_ticks_for_scale(4.0, 0.0, 5, &AxisScale::Linear);
assert_eq!(reversed_linear_ticks.first().copied(), Some(0.0));
assert_eq!(reversed_linear_ticks.last().copied(), Some(4.0));
let log_ticks = generate_ticks_for_scale(1.0, 1000.0, 5, &AxisScale::Log);
assert!(log_ticks.contains(&10.0));
assert!(log_ticks.contains(&100.0));
let reversed_log_ticks = generate_ticks_for_scale(1000.0, 1.0, 5, &AxisScale::Log);
assert_eq!(reversed_log_ticks.first().copied(), Some(1.0));
assert_eq!(reversed_log_ticks.last().copied(), Some(1000.0));
let symlog_ticks = generate_ticks_for_scale(-100.0, 100.0, 10, &AxisScale::symlog(1.0));
assert!(symlog_ticks.contains(&0.0) || symlog_ticks.iter().any(|&t| t.abs() < 0.1));
}
}