use crate::aes::Aesthetic;
use crate::data::{DataFrame, Value};
use crate::scale::ScaleSet;
use super::Stat;
pub const WATERFALL_INCREASE: &str = "Increase";
pub const WATERFALL_DECREASE: &str = "Decrease";
pub const WATERFALL_TOTAL: &str = "Total";
#[derive(Clone, Debug, Default)]
pub struct StatWaterfall {
pub totals: Vec<String>,
pub final_total: Option<String>,
}
impl Stat for StatWaterfall {
fn compute_group(&self, data: &DataFrame, _scales: &ScaleSet) -> DataFrame {
let (Some(x_col), Some(y_col)) = (data.column("x"), data.column("y")) else {
return DataFrame::new();
};
let n = data.nrows();
let mut cum = 0.0;
let mut out: Vec<(Value, [f64; 5], &'static str)> = Vec::with_capacity(n + 1);
let mut kept = Vec::with_capacity(n);
for i in 0..n {
let is_total = self.totals.iter().any(|t| *t == x_col[i].key_str());
if is_total {
out.push((x_col[i].clone(), [0.0, cum, 0.0, cum, cum], WATERFALL_TOTAL));
} else {
let Some(d) = y_col[i].as_f64().filter(|d| d.is_finite()) else {
continue;
};
let start = cum;
cum += d;
let dir = if d >= 0.0 {
WATERFALL_INCREASE
} else {
WATERFALL_DECREASE
};
out.push((
x_col[i].clone(),
[start, cum, start.min(cum), start.max(cum), d],
dir,
));
}
kept.push(Some(i));
}
if let Some(label) = &self.final_total {
out.push((
Value::Str(label.clone()),
[0.0, cum, 0.0, cum, cum],
WATERFALL_TOTAL,
));
kept.push(None);
}
let mut result = DataFrame::new();
let num = |k: usize| out.iter().map(|r| Value::Float(r.1[k])).collect::<Vec<_>>();
result.add_column("x".into(), out.iter().map(|r| r.0.clone()).collect());
result.add_column("y".into(), num(1));
result.add_column("ymin".into(), num(2));
result.add_column("ymax".into(), num(3));
result.add_column("start".into(), num(0));
result.add_column("delta".into(), num(4));
result.add_column(
"direction".into(),
out.iter().map(|r| Value::Str(r.2.to_string())).collect(),
);
for name in data.column_names() {
if result.has_column(name) {
continue;
}
let col = data.column(name).unwrap_or_default();
let vals = kept
.iter()
.map(|k| k.map(|i| col[i].clone()).unwrap_or(Value::Na))
.collect();
result.add_column(name.to_string(), vals);
}
result
}
fn required_aes(&self) -> Vec<Aesthetic> {
vec![Aesthetic::X, Aesthetic::Y]
}
fn panelwise(&self) -> bool {
true
}
fn name(&self) -> &str {
"waterfall"
}
}
#[cfg(test)]
mod tests {
use super::*;
fn df(xs: &[&str], ys: &[f64]) -> DataFrame {
let mut d = DataFrame::new();
d.add_column(
"x".into(),
xs.iter().map(|s| Value::Str(s.to_string())).collect(),
);
d.add_column("y".into(), ys.iter().map(|v| Value::Float(*v)).collect());
d
}
fn col(d: &DataFrame, n: &str) -> Vec<f64> {
d.column(n)
.unwrap()
.iter()
.map(|v| v.as_f64().unwrap())
.collect()
}
#[test]
fn steps_float_from_the_running_total() {
let stat = StatWaterfall {
totals: vec!["Sub".into()],
final_total: Some("End".into()),
};
let out = stat.compute_group(
&df(&["A", "B", "Sub", "C"], &[10.0, -4.0, 0.0, 3.0]),
&ScaleSet::new(),
);
assert_eq!(out.nrows(), 5);
assert_eq!(col(&out, "ymin"), vec![0.0, 6.0, 0.0, 6.0, 0.0]);
assert_eq!(col(&out, "ymax"), vec![10.0, 10.0, 6.0, 9.0, 9.0]);
assert_eq!(col(&out, "y"), vec![10.0, 6.0, 6.0, 9.0, 9.0]);
assert_eq!(col(&out, "delta"), vec![10.0, -4.0, 6.0, 3.0, 9.0]);
let dirs: Vec<String> = out
.column("direction")
.unwrap()
.iter()
.map(|v| v.key_str().into_owned())
.collect();
assert_eq!(dirs, ["Increase", "Decrease", "Total", "Increase", "Total"]);
assert_eq!(out.column("x").unwrap()[4], Value::Str("End".into()));
}
}