use crate::aes::Aesthetic;
use crate::data::{DataFrame, Value};
use crate::scale::ScaleSet;
use super::Stat;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum FunnelShape {
Proportional,
Silhouette { neck_width: f64, neck_height: f64 },
Pyramid,
}
#[derive(Clone, Debug)]
pub struct StatFunnel {
pub shape: FunnelShape,
pub gap: f64,
}
impl Default for StatFunnel {
fn default() -> Self {
StatFunnel {
shape: FunnelShape::Proportional,
gap: 0.01,
}
}
}
impl StatFunnel {
fn half_width(&self, y: f64) -> f64 {
match self.shape {
FunnelShape::Silhouette {
neck_width,
neck_height,
} => {
let nw = neck_width.clamp(0.0, 1.0);
let nh = neck_height.clamp(0.0, 0.999);
if y <= nh {
nw / 2.0
} else {
(nw + (1.0 - nw) * (y - nh) / (1.0 - nh)) / 2.0
}
}
FunnelShape::Pyramid => (1.0 - y) / 2.0,
FunnelShape::Proportional => 0.5,
}
}
}
impl Stat for StatFunnel {
fn compute_group(&self, data: &DataFrame, _scales: &ScaleSet) -> DataFrame {
let (Some(xc), Some(yc)) = (data.column("x"), data.column("y")) else {
return DataFrame::new();
};
let mut stages: indexmap::IndexMap<String, (Value, f64)> = indexmap::IndexMap::new();
for (x, y) in xc.iter().zip(yc) {
let Some(v) = y.as_f64().filter(|v| v.is_finite() && *v >= 0.0) else {
continue;
};
stages
.entry(x.key_str().into_owned())
.or_insert_with(|| (x.clone(), 0.0))
.1 += v;
}
let stages: Vec<(Value, f64)> = stages.into_values().collect();
let n = stages.len();
let total: f64 = stages.iter().map(|s| s.1).sum();
let max = stages.iter().map(|s| s.1).fold(0.0, f64::max);
if n == 0 || total <= 0.0 {
return DataFrame::new();
}
let first = stages[0].1;
let gap = self.gap.clamp(0.0, 0.5 / n as f64);
let mut cols: [Vec<f64>; 3] = Default::default(); let (mut stage, mut value, mut pct_first, mut pct_total) = (vec![], vec![], vec![], vec![]);
let mut top = 1.0;
for (i, (key, v)) in stages.iter().enumerate() {
let h = match self.shape {
FunnelShape::Proportional => 1.0 / n as f64,
_ => v / total,
};
let (y1, y0) = (top, top - h);
top = y0;
let (y1g, y0g) = (y1 - gap / 2.0, (y0 + gap / 2.0).min(y1 - gap / 2.0));
let poly: Vec<(f64, f64)> = match self.shape {
FunnelShape::Proportional => {
let w_top = if max > 0.0 { v / max / 2.0 } else { 0.0 };
let w_bot = stages
.get(i + 1)
.map(|s| if max > 0.0 { s.1 / max / 2.0 } else { 0.0 })
.unwrap_or(w_top);
vec![(-w_top, y1g), (w_top, y1g), (w_bot, y0g), (-w_bot, y0g)]
}
_ => {
let mut ys = vec![y1g];
if let FunnelShape::Silhouette { neck_height, .. } = self.shape {
if neck_height < y1g && neck_height > y0g {
ys.push(neck_height);
}
}
ys.push(y0g);
let mut p: Vec<(f64, f64)> =
ys.iter().map(|&y| (self.half_width(y), y)).collect();
p.extend(ys.iter().rev().map(|&y| (-self.half_width(y), y)));
p
}
};
for &(px, py) in &poly {
cols[0].push(px);
cols[1].push(py);
cols[2].push((y1g + y0g) / 2.0);
stage.push(key.clone());
value.push(Value::Float(*v));
pct_first.push(if first > 0.0 {
Value::Float(v / first)
} else {
Value::Na
});
pct_total.push(Value::Float(v / total));
}
}
let f = |v: &Vec<f64>| v.iter().map(|&x| Value::Float(x)).collect::<Vec<_>>();
let mut out = DataFrame::new();
out.add_column("fx".into(), f(&cols[0]));
out.add_column("fy".into(), f(&cols[1]));
out.add_column("x".into(), stage.clone());
out.add_column("y".into(), value.clone());
out.add_column("stage".into(), stage);
out.add_column("value".into(), value);
out.add_column("pct_first".into(), pct_first);
out.add_column("pct_total".into(), pct_total);
out.add_column("label_y".into(), f(&cols[2]));
out
}
fn required_aes(&self) -> Vec<Aesthetic> {
vec![Aesthetic::X, Aesthetic::Y]
}
fn panelwise(&self) -> bool {
true
}
fn name(&self) -> &str {
"funnel"
}
}
#[cfg(test)]
mod tests {
use super::*;
fn data() -> DataFrame {
let mut d = DataFrame::new();
d.add_column(
"x".into(),
["Visit", "Cart", "Buy", "Cart"]
.iter()
.map(|s| Value::Str(s.to_string()))
.collect(),
);
d.add_column(
"y".into(),
[1000.0, 300.0, 120.0, 100.0]
.iter()
.map(|&v| Value::Float(v))
.collect(),
);
d
}
fn col(d: &DataFrame, c: &str) -> Vec<f64> {
d.column(c)
.unwrap()
.iter()
.map(|v| v.as_f64().unwrap())
.collect()
}
#[test]
fn proportional_widths_and_shares() {
let out = StatFunnel {
gap: 0.0,
..Default::default()
}
.compute_group(&data(), &ScaleSet::new());
assert_eq!(out.nrows(), 3 * 4);
let x = col(&out, "fx");
assert_eq!(
&x[..4],
&[-0.5, 0.5, 0.2, -0.2],
"Visit tapers to Cart (400)"
);
assert_eq!(
&x[8..12],
&[-0.06, 0.06, 0.06, -0.06],
"last stage is a bar"
);
let pf = col(&out, "pct_first");
assert_eq!((pf[0], pf[4], pf[8]), (1.0, 0.4, 0.12));
}
#[test]
fn silhouette_heights_are_proportional_and_pass_the_neck() {
let stat = StatFunnel {
shape: FunnelShape::Silhouette {
neck_width: 0.3,
neck_height: 0.25,
},
gap: 0.0,
};
let out = stat.compute_group(&data(), &ScaleSet::new());
let y = col(&out, "fy");
assert!((y[0] - 1.0).abs() < 1e-12);
assert!((y[1] - (1.0 - 1000.0 / 1520.0)).abs() < 1e-12);
let n = out.nrows();
let x = col(&out, "fx");
assert!((x[n - 1] + 0.15).abs() < 1e-12);
assert!((stat.half_width(1.0) - 0.5).abs() < 1e-12);
}
}