use crate::core::stats::{ComCoord, StatScope, Stats};
pub enum StatsInput<'a> {
Curve {
xs: &'a [f64],
ys: &'a [f64],
},
Histogram {
xs: &'a [f64],
counts: &'a [f64],
},
Scatter {
xs: &'a [f64],
ys: &'a [f64],
values: &'a [f64],
},
Image {
data: &'a [f64],
width: usize,
height: usize,
origin: (f64, f64),
scale: (f64, f64),
},
}
impl StatsInput<'_> {
fn compute(&self, scope: StatScope) -> Stats {
match self {
StatsInput::Curve { xs, ys } => Stats::for_curve(xs, ys, scope),
StatsInput::Histogram { xs, counts } => Stats::for_curve(xs, counts, scope),
StatsInput::Scatter { xs, ys, values } => Stats::for_scatter(xs, ys, values, scope),
StatsInput::Image {
data,
width,
height,
origin,
scale,
} => Stats::for_image(data, *width, *height, *origin, *scale, scope),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum UpdateMode {
#[default]
Auto,
Manual,
}
#[derive(Clone, Debug, Default)]
pub struct StatsWidget {
update_mode: UpdateMode,
on_visible_data: bool,
needs_update: bool,
rows: Vec<(String, Stats)>,
}
impl StatsWidget {
pub fn new() -> Self {
Self {
update_mode: UpdateMode::Auto,
on_visible_data: false,
needs_update: true,
rows: Vec::new(),
}
}
pub fn update_mode(&self) -> UpdateMode {
self.update_mode
}
pub fn set_update_mode(&mut self, mode: UpdateMode) {
self.update_mode = mode;
}
pub fn on_visible_data(&self) -> bool {
self.on_visible_data
}
pub fn set_on_visible_data(&mut self, value: bool) {
self.on_visible_data = value;
self.needs_update = true;
}
pub fn request_update(&mut self) {
self.needs_update = true;
}
pub fn rows(&self) -> &[(String, Stats)] {
&self.rows
}
pub fn recompute(
&mut self,
inputs: &[(&str, StatsInput<'_>)],
viewport: Option<((f64, f64), (f64, f64))>,
) {
let should = self.update_mode == UpdateMode::Auto || self.needs_update;
if !should {
return;
}
let scope = match (self.on_visible_data, viewport) {
(true, Some((x_range, y_range))) => StatScope::OnLimits { x_range, y_range },
_ => StatScope::All,
};
self.rows = inputs
.iter()
.map(|(label, input)| ((*label).to_owned(), input.compute(scope)))
.collect();
self.needs_update = false;
}
pub fn ui(
&mut self,
ui: &mut egui::Ui,
inputs: &[(&str, StatsInput<'_>)],
viewport: Option<((f64, f64), (f64, f64))>,
) {
ui.horizontal(|ui| {
let mut auto = self.update_mode == UpdateMode::Auto;
if ui.checkbox(&mut auto, "Auto update").changed() {
self.update_mode = if auto {
UpdateMode::Auto
} else {
UpdateMode::Manual
};
}
let mut on_visible = self.on_visible_data;
if ui.checkbox(&mut on_visible, "Visible data only").changed() {
self.set_on_visible_data(on_visible);
}
if self.update_mode == UpdateMode::Manual && ui.button("Update").clicked() {
self.request_update();
}
});
self.recompute(inputs, viewport);
egui::ScrollArea::both()
.auto_shrink([false, true])
.show(ui, |ui| {
egui::Grid::new("stats_widget_grid")
.striped(true)
.num_columns(STAT_COLUMNS.len() + 1)
.show(ui, |ui| {
ui.strong("item");
for col in STAT_COLUMNS {
ui.strong(col);
}
ui.end_row();
for (label, stats) in &self.rows {
ui.label(label);
for cell in row_cells(stats) {
ui.label(cell);
}
ui.end_row();
}
});
});
}
}
const STAT_COLUMNS: [&str; 7] = [
"min",
"coords min",
"max",
"coords max",
"COM",
"mean",
"std",
];
fn row_cells(stats: &Stats) -> [String; 7] {
[
format_stat(stats.min),
format_coord(stats.coord_min),
format_stat(stats.max),
format_coord(stats.coord_max),
format_coord(stats.com),
format_stat(stats.mean),
format_stat(stats.std),
]
}
pub fn format_coord(coord: ComCoord) -> String {
match (coord.x, coord.y) {
(None, _) => "--".to_owned(),
(Some(x), None) => format!("({},)", python_repr_float(x)),
(Some(x), Some(y)) => {
format!("({}, {})", python_repr_float(x), python_repr_float(y))
}
}
}
fn python_repr_float(v: f64) -> String {
if v.is_nan() {
return "nan".to_owned();
}
if v.is_infinite() {
return if v < 0.0 { "-inf" } else { "inf" }.to_owned();
}
if v == 0.0 {
return if v.is_sign_negative() { "-0.0" } else { "0.0" }.to_owned();
}
let sign = if v < 0.0 { "-" } else { "" };
let fixed = format!("{}", v.abs());
let (int_part, frac_part) = match fixed.split_once('.') {
Some((i, f)) => (i, f),
None => (fixed.as_str(), ""),
};
let mut decpt = int_part.len() as i32;
let joined = format!("{int_part}{frac_part}");
let after_leading = joined.trim_start_matches('0');
decpt -= (joined.len() - after_leading.len()) as i32;
let digits = after_leading.trim_end_matches('0');
if decpt <= -4 || decpt > 16 {
let exp = decpt - 1;
let mantissa = if digits.len() == 1 {
digits.to_owned()
} else {
format!("{}.{}", &digits[0..1], &digits[1..])
};
format!(
"{sign}{mantissa}e{}{:02}",
if exp < 0 { "-" } else { "+" },
exp.abs()
)
} else if decpt <= 0 {
format!("{sign}0.{}{digits}", "0".repeat((-decpt) as usize))
} else if decpt as usize >= digits.len() {
format!(
"{sign}{digits}{}.0",
"0".repeat(decpt as usize - digits.len())
)
} else {
let (a, b) = digits.split_at(decpt as usize);
format!("{sign}{a}.{b}")
}
}
pub fn format_stat(value: Option<f64>) -> String {
match value {
None => "--".to_owned(),
Some(v) if v.is_nan() => "nan".to_owned(),
Some(v) => format!("{v:.3}"),
}
}
pub fn format_significant(value: f64, digits: usize) -> String {
if !value.is_finite() {
return "--".to_owned();
}
if value == 0.0 {
return "0".to_owned();
}
let digits = digits.clamp(1, 17);
let sci = format!("{:.*e}", digits - 1, value);
let exp: i32 = sci
.split_once('e')
.and_then(|(_, e)| e.parse().ok())
.unwrap_or(0);
if exp < -4 || exp >= digits as i32 {
trim_exponential(&sci)
} else {
let frac = (digits as i32 - 1 - exp).max(0) as usize;
let s = format!("{value:.frac$}");
trim_fraction(&s)
}
}
pub fn format_g_python(value: f64, digits: usize) -> String {
if value.is_nan() {
return "nan".to_owned();
}
if value.is_infinite() {
return if value < 0.0 { "-inf" } else { "inf" }.to_owned();
}
format_significant(value, digits)
}
fn trim_fraction(s: &str) -> String {
if !s.contains('.') {
return s.to_owned();
}
let trimmed = s.trim_end_matches('0');
let trimmed = trimmed.trim_end_matches('.');
trimmed.to_owned()
}
fn trim_exponential(s: &str) -> String {
let Some((mantissa, exp)) = s.split_once('e') else {
return s.to_owned();
};
let mantissa = if mantissa.contains('.') {
let t = mantissa.trim_end_matches('0');
t.trim_end_matches('.').to_owned()
} else {
mantissa.to_owned()
};
let (sign, digits) = match exp.strip_prefix('-') {
Some(rest) => ('-', rest),
None => ('+', exp.strip_prefix('+').unwrap_or(exp)),
};
format!("{mantissa}e{sign}{digits:0>2}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_stat_none_is_dashes() {
assert_eq!(format_stat(None), "--");
}
#[test]
fn format_stat_non_finite_prints_python_spellings() {
assert_eq!(format_stat(Some(f64::NAN)), "nan");
assert_eq!(format_stat(Some(f64::INFINITY)), "inf");
assert_eq!(format_stat(Some(f64::NEG_INFINITY)), "-inf");
}
#[test]
fn format_coord_non_finite_components_visible() {
let c = ComCoord {
x: Some(f64::NAN),
y: Some(f64::NEG_INFINITY),
};
assert_eq!(format_coord(c), "(nan, -inf)");
}
#[test]
fn format_stat_three_decimals() {
assert_eq!(format_stat(Some(1.0)), "1.000");
assert_eq!(format_stat(Some(4.56789)), "4.568");
assert_eq!(format_stat(Some(-2.5)), "-2.500");
}
#[test]
fn format_significant_zero() {
assert_eq!(format_significant(0.0, 7), "0");
}
#[test]
fn format_significant_non_finite() {
assert_eq!(format_significant(f64::NAN, 7), "--");
}
#[test]
fn format_significant_trims_trailing_zeros() {
assert_eq!(format_significant(1.5, 7), "1.5");
assert_eq!(format_significant(100.0, 7), "100");
}
#[test]
fn format_significant_rounds_to_digits() {
assert_eq!(format_significant(4.56789, 3), "4.57");
assert_eq!(format_significant(12345.0, 3), "1.23e+04");
}
#[test]
fn format_significant_small_uses_exponential() {
assert_eq!(format_significant(0.00001234, 4), "1.234e-05");
}
#[test]
fn format_g_python_matches_python_5g() {
assert_eq!(format_g_python(1.5, 5), "1.5");
assert_eq!(format_g_python(0.0000123456, 5), "1.2346e-05");
assert_eq!(format_g_python(123456.0, 5), "1.2346e+05");
assert_eq!(format_g_python(f64::INFINITY, 5), "inf");
assert_eq!(format_g_python(f64::NEG_INFINITY, 5), "-inf");
assert_eq!(format_g_python(f64::NAN, 5), "nan");
assert_eq!(format_significant(f64::INFINITY, 5), "--");
}
#[test]
fn format_significant_negative() {
assert_eq!(format_significant(-42.5, 4), "-42.5");
}
#[test]
fn format_significant_digits_clamped() {
assert_eq!(format_significant(9.0, 0), "9");
}
#[test]
fn format_significant_decides_notation_after_rounding() {
assert_eq!(format_significant(9999999.9, 7), "1e+07");
assert_eq!(format_significant(9.9999999e-05, 7), "0.0001");
assert_eq!(format_significant(1234567.0, 7), "1234567");
assert_eq!(format_significant(0.0001234567, 7), "0.0001234567");
}
#[test]
fn format_coord_curve_x_only() {
let c = ComCoord {
x: Some(2.5),
y: None,
};
assert_eq!(format_coord(c), "(2.5,)");
}
#[test]
fn format_coord_image_xy() {
let c = ComCoord {
x: Some(1.0),
y: Some(3.0),
};
assert_eq!(format_coord(c), "(1.0, 3.0)");
}
#[test]
fn format_coord_none_is_dashes() {
assert_eq!(format_coord(ComCoord::NONE), "--");
}
#[test]
fn format_coord_full_precision_not_truncated() {
let c = ComCoord {
x: Some(0.12345678901),
y: None,
};
assert_eq!(format_coord(c), "(0.12345678901,)");
}
#[test]
fn python_repr_float_matches_cpython_repr() {
assert_eq!(python_repr_float(5.0), "5.0");
assert_eq!(python_repr_float(100.0), "100.0");
assert_eq!(python_repr_float(-3.0), "-3.0");
assert_eq!(python_repr_float(0.0), "0.0");
assert_eq!(python_repr_float(-0.0), "-0.0");
assert_eq!(python_repr_float(2.5), "2.5");
assert_eq!(python_repr_float(0.5), "0.5");
assert_eq!(python_repr_float(0.12345678901), "0.12345678901");
assert_eq!(python_repr_float(0.0001), "0.0001");
assert_eq!(python_repr_float(0.00001), "1e-05");
assert_eq!(python_repr_float(1e20), "1e+20");
assert_eq!(python_repr_float(1.5e20), "1.5e+20");
assert_eq!(python_repr_float(1e15), "1000000000000000.0");
assert_eq!(python_repr_float(f64::NAN), "nan");
assert_eq!(python_repr_float(f64::INFINITY), "inf");
assert_eq!(python_repr_float(f64::NEG_INFINITY), "-inf");
}
#[test]
fn stat_columns_match_silx_default_stats_exactly() {
assert_eq!(
STAT_COLUMNS,
[
"min",
"coords min",
"max",
"coords max",
"COM",
"mean",
"std"
]
);
}
#[test]
fn row_cells_render_std_column() {
let ys = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
let xs: Vec<f64> = (0..ys.len()).map(|i| i as f64).collect();
let stats = Stats::for_curve(&xs, &ys, StatScope::All);
let cells = row_cells(&stats);
assert_eq!(cells.len(), STAT_COLUMNS.len());
assert_eq!(cells[6], "2.000");
}
#[test]
fn recompute_auto_always_runs() {
let mut w = StatsWidget::new();
w.set_update_mode(UpdateMode::Auto);
let xs = [0.0, 1.0, 2.0];
let ys = [1.0, 2.0, 3.0];
let inputs: Vec<(&str, StatsInput<'_>)> =
vec![("c", StatsInput::Curve { xs: &xs, ys: &ys })];
w.recompute(&inputs, None);
assert_eq!(w.rows().len(), 1);
assert_eq!(w.rows()[0].0, "c");
assert_eq!(w.rows()[0].1.included_count, 3);
}
#[test]
fn recompute_manual_waits_for_request() {
let mut w = StatsWidget::new();
w.set_update_mode(UpdateMode::Manual);
let xs = [0.0, 1.0];
let ys = [1.0, 2.0];
let inputs: Vec<(&str, StatsInput<'_>)> =
vec![("c", StatsInput::Curve { xs: &xs, ys: &ys })];
w.recompute(&inputs, None);
assert_eq!(w.rows().len(), 1);
let xs2 = [0.0, 1.0, 2.0, 3.0];
let ys2 = [1.0, 1.0, 1.0, 1.0];
let inputs2: Vec<(&str, StatsInput<'_>)> =
vec![("c", StatsInput::Curve { xs: &xs2, ys: &ys2 })];
w.recompute(&inputs2, None);
assert_eq!(w.rows()[0].1.included_count, 2, "should not have refreshed");
w.request_update();
w.recompute(&inputs2, None);
assert_eq!(w.rows()[0].1.included_count, 4);
}
#[test]
fn recompute_on_visible_data_clips() {
let mut w = StatsWidget::new();
w.set_on_visible_data(true);
let xs = [0.0, 1.0, 2.0, 3.0, 4.0];
let ys = [10.0, 20.0, 30.0, 40.0, 50.0];
let inputs: Vec<(&str, StatsInput<'_>)> =
vec![("c", StatsInput::Curve { xs: &xs, ys: &ys })];
w.recompute(&inputs, Some(((1.0, 3.0), (-1e9, 1e9))));
assert_eq!(w.rows()[0].1.included_count, 3);
w.request_update();
w.recompute(&inputs, None);
assert_eq!(w.rows()[0].1.included_count, 5);
}
#[test]
fn recompute_image_input() {
let mut w = StatsWidget::new();
let data = [1.0, 2.0, 3.0, 9.0];
let inputs: Vec<(&str, StatsInput<'_>)> = vec![(
"img",
StatsInput::Image {
data: &data,
width: 2,
height: 2,
origin: (0.0, 0.0),
scale: (1.0, 1.0),
},
)];
w.recompute(&inputs, None);
let s = &w.rows()[0].1;
assert_eq!(s.max, Some(9.0));
assert_eq!(s.coord_max.x, Some(1.0));
assert_eq!(s.coord_max.y, Some(1.0));
}
}