use crate::numfmt::{CellFormatter, NumberFormat, NumberFormatSettings};
use polars::prelude::{AnyValue, DataType, Schema};
const MAX_AXIS_PLACES: i32 = 6;
const MAX_MANTISSA_PLACES: i32 = 12;
const SCIENTIFIC_FROM: f64 = 1e15;
#[derive(Clone, Debug, Default, PartialEq)]
pub struct AxisNumbers {
pub format: NumberFormat,
pub whole: bool,
}
impl AxisNumbers {
pub fn column(settings: &NumberFormatSettings, schema: Option<&Schema>, column: &str) -> Self {
match schema.and_then(|s| s.get(column)) {
Some(dtype) => Self {
format: table_number_format(settings, column, dtype),
whole: dtype.is_integer(),
},
None => Self::default(),
}
}
pub fn columns(
settings: &NumberFormatSettings,
schema: Option<&Schema>,
columns: &[String],
) -> Self {
let mut each = columns.iter().map(|c| Self::column(settings, schema, c));
let Some(first) = each.next() else {
return Self::default();
};
let whole = first.whole && each.all(|n| n.whole);
Self { whole, ..first }
}
pub fn count(settings: &NumberFormatSettings) -> Self {
Self {
format: table_number_format(settings, "Count", &DataType::UInt64),
whole: true,
}
}
pub fn measure(settings: &NumberFormatSettings, name: &str) -> Self {
Self {
format: table_number_format(settings, name, &DataType::Float64),
whole: false,
}
}
pub fn write(&self, v: f64) -> String {
write(&self.format, v, Notation::Table, 0.0)
}
pub fn fractional(self) -> Self {
Self {
whole: false,
..self
}
}
}
#[derive(Clone, Debug)]
pub struct AxisFormat {
format: NumberFormat,
full: Notation,
short: Option<Notation>,
zero_below: f64,
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Notation {
Fixed {
places: usize,
unit: f64,
suffix: &'static str,
},
Scientific { places: usize },
Prefixed,
Table,
}
impl AxisFormat {
pub fn new(ticks: &[f64], numbers: &AxisNumbers) -> Self {
let ticks: Vec<f64> = ticks.iter().copied().filter(|v| v.is_finite()).collect();
let top = ticks.iter().fold(0.0_f64, |top, v| top.max(v.abs()));
let gap = ticks
.windows(2)
.map(|w| (w[1] - w[0]).abs())
.filter(|gap| *gap > 0.0)
.fold(f64::INFINITY, f64::min);
let places = if top >= SCIENTIFIC_FROM {
None
} else if numbers.whole {
Some(0)
} else {
fixed_places(&ticks, top, gap)
};
let (full, short) = match places {
Some(places) => (
Notation::Fixed {
places,
unit: 1.0,
suffix: "",
},
short_notation(&ticks, top, gap),
),
None => {
let apart = if gap.is_finite() && top > 0.0 {
(magnitude(top) - magnitude(gap)).clamp(0, MAX_MANTISSA_PLACES) as usize
} else {
0
};
(
Notation::Scientific {
places: apart.max(2),
},
Some(Notation::Scientific { places: apart }),
)
}
};
Self {
format: numbers.format.clone(),
full,
short,
zero_below: if gap.is_finite() { gap * 1e-9 } else { 0.0 },
}
}
pub fn log(ticks: &[f64], numbers: &AxisNumbers) -> Self {
let ticks: Vec<f64> = ticks.iter().copied().filter(|v| v.is_finite()).collect();
let top = ticks.iter().fold(0.0_f64, |top, v| top.max(v.abs()));
let full = if top >= SCIENTIFIC_FROM {
Notation::Scientific { places: 0 }
} else {
Notation::Fixed {
places: fewest_places(&ticks, 1.0, 0, MAX_AXIS_PLACES),
unit: 1.0,
suffix: "",
}
};
Self {
format: numbers.format.clone(),
full,
short: (1e3..SCIENTIFIC_FROM)
.contains(&top)
.then_some(Notation::Prefixed),
zero_below: 0.0,
}
}
pub fn ends_and_middle([lo, hi]: [f64; 2], numbers: &AxisNumbers) -> Self {
Self::new(&[lo, (lo + hi) / 2.0, hi], numbers)
}
pub fn label(&self, v: f64, level: usize) -> Option<String> {
let notation = match level {
0 => self.full,
1 => self.short?,
_ => return None,
};
Some(write(&self.format, v, notation, self.zero_below))
}
}
fn write(format: &NumberFormat, v: f64, notation: Notation, zero_below: f64) -> String {
let (places, unit, suffix) = match notation {
Notation::Scientific { places } => {
let v = if v.abs() < zero_below { 0.0 } else { v };
return scientific(v, places, format.decimal_sep);
}
Notation::Table => {
let mut out = String::new();
if v.fract() == 0.0 || format.float_precision.is_some() || !v.is_finite() {
format.write_f64(v, &mut String::new(), &mut out);
} else {
format.regroup_decimal(&AnyValue::Float64(v).str_value(), &mut out);
}
return out;
}
Notation::Fixed { .. } | Notation::Prefixed if !v.is_finite() => {
return v.to_string();
}
Notation::Prefixed => {
let (unit, suffix) = [(1e12, "T"), (1e9, "G"), (1e6, "M"), (1e3, "k")]
.into_iter()
.find(|(unit, _)| v.abs() >= *unit)
.unwrap_or((1.0, ""));
let places = fewest_places(&[v], unit, 0, 2);
(places, unit, suffix)
}
Notation::Fixed {
places,
unit,
suffix,
} => (places, unit, suffix),
};
let fixed = NumberFormat {
float_precision: Some(places as u8),
..format.clone()
};
let mut out = String::new();
fixed.write_f64(v / unit, &mut String::new(), &mut out);
if !out.chars().any(|c| matches!(c, '1'..='9')) {
if unit > 1.0 {
return "0".to_string();
}
out.retain(|c| c != '-');
}
out.push_str(suffix);
out
}
fn fixed_places(ticks: &[f64], top: f64, gap: f64) -> Option<usize> {
let figures = if top > 0.0 { 2 - magnitude(top) } else { 0 };
let apart = if gap.is_finite() { -magnitude(gap) } else { 0 };
let places = figures.max(apart).max(0);
(places <= MAX_AXIS_PLACES).then(|| fewest_places(ticks, 1.0, apart.max(0), places))
}
fn fewest_places(ticks: &[f64], unit: f64, least: i32, most: i32) -> usize {
let exact = |places: i32| {
ticks.iter().all(|v| {
let scaled = v / unit * 10f64.powi(places);
(scaled - scaled.round()).abs() <= 1e-9 * scaled.abs().max(1.0)
})
};
(least..most).find(|&p| exact(p)).unwrap_or(most) as usize
}
fn short_notation(ticks: &[f64], top: f64, gap: f64) -> Option<Notation> {
let (unit, suffix) = [(1e12, "T"), (1e9, "G"), (1e6, "M"), (1e3, "k")]
.into_iter()
.find(|(unit, _)| top >= *unit)?;
let figures = 1 - magnitude(top / unit);
let apart = if gap.is_finite() {
-magnitude(gap / unit)
} else {
0
};
let most = figures.max(apart).clamp(0, 2);
Some(Notation::Fixed {
places: fewest_places(ticks, unit, apart.clamp(0, most), most),
unit,
suffix,
})
}
fn magnitude(v: f64) -> i32 {
(v.log10() + 1e-9).floor() as i32
}
fn scientific(v: f64, places: usize, decimal_sep: char) -> String {
let v = if v == 0.0 { 0.0 } else { v };
let text = format!("{v:.places$e}");
if decimal_sep == '.' {
text
} else {
text.replacen('.', decimal_sep.encode_utf8(&mut [0; 4]), 1)
}
}
pub fn table_number_format(
settings: &NumberFormatSettings,
column: &str,
dtype: &DataType,
) -> NumberFormat {
match settings.formatter_for(column, dtype) {
CellFormatter::Number(format) => format,
CellFormatter::Passthrough => NumberFormat::PLAIN,
}
}
pub fn format_bar_value(v: f64, integer: bool, format: &NumberFormat) -> String {
let places = format.float_precision.unwrap_or(2);
let smallest = 0.5 * 10f64.powi(-i32::from(places));
let notation =
if !v.is_finite() || v.abs() >= 1e15 || (!integer && v != 0.0 && v.abs() < smallest) {
Notation::Scientific { places: 2 }
} else {
Notation::Fixed {
places: if integer { 0 } else { places.into() },
unit: 1.0,
suffix: "",
}
};
write(format, v, notation, 0.0)
}
#[cfg(test)]
mod tests;