Skip to main content

tui_piechart/
funnel.rs

1//! [`Funnel`]: stages narrowing from top to bottom, as bars or a smooth trapezoid.
2
3#![allow(
4    clippy::cast_possible_truncation,
5    clippy::cast_precision_loss,
6    clippy::cast_sign_loss,
7    clippy::many_single_char_names,
8    clippy::too_many_lines,
9    clippy::cast_possible_wrap
10)]
11
12use ratatui::buffer::Buffer;
13use ratatui::layout::Rect;
14use ratatui::style::{Color, Modifier, Style};
15use ratatui::widgets::{Block, Widget};
16
17use crate::util::{block_inner, contrast, palette, put, sane, text_width, truncate};
18
19/// One stage of a [`Funnel`].
20#[derive(Debug, Clone, PartialEq)]
21pub struct FunnelStage<'a> {
22    label: &'a str,
23    value: f64,
24    color: Option<Color>,
25}
26
27impl<'a> FunnelStage<'a> {
28    /// A stage with `value` items.
29    #[must_use]
30    pub const fn new(label: &'a str, value: f64) -> Self {
31        Self {
32            label,
33            value,
34            color: None,
35        }
36    }
37
38    /// Fixes the stage color (default: the palette, by position).
39    #[must_use]
40    pub const fn color(mut self, color: Color) -> Self {
41        self.color = Some(color);
42        self
43    }
44}
45
46/// How a [`Funnel`] draws its stages.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum FunnelStyle {
49    /// Centered bars, one per stage (default).
50    #[default]
51    Bars,
52    /// A continuous trapezoid: each stage tapers towards the next one.
53    Trapezoid,
54}
55
56/// A conversion funnel.
57///
58/// # Examples
59///
60/// ```
61/// use tui_piechart::{Funnel, FunnelStage};
62///
63/// let funnel = Funnel::new(vec![
64///     FunnelStage::new("Visits", 10_000.0),
65///     FunnelStage::new("Sign-ups", 2_400.0),
66///     FunnelStage::new("Paid", 380.0),
67/// ]);
68/// ```
69#[derive(Debug, Clone)]
70pub struct Funnel<'a> {
71    stages: Vec<FunnelStage<'a>>,
72    style: FunnelStyle,
73    show_values: bool,
74    block: Option<Block<'a>>,
75    base: Style,
76}
77
78impl<'a> Funnel<'a> {
79    /// Creates a funnel from stages, widest first.
80    #[must_use]
81    pub fn new(stages: Vec<FunnelStage<'a>>) -> Self {
82        Self {
83            stages,
84            style: FunnelStyle::default(),
85            show_values: true,
86            block: None,
87            base: Style::default(),
88        }
89    }
90
91    /// Chooses bars or a trapezoid.
92    #[must_use]
93    pub const fn style(mut self, style: FunnelStyle) -> Self {
94        self.style = style;
95        self
96    }
97
98    /// Shows or hides the value / conversion column on the right.
99    #[must_use]
100    pub const fn show_values(mut self, show: bool) -> Self {
101        self.show_values = show;
102        self
103    }
104
105    /// Wraps the funnel in a block.
106    #[must_use]
107    pub fn block(mut self, block: Block<'a>) -> Self {
108        self.block = Some(block);
109        self
110    }
111
112    /// Base style of the whole area.
113    #[must_use]
114    pub const fn base_style(mut self, style: Style) -> Self {
115        self.base = style;
116        self
117    }
118
119    /// Share of each stage relative to the previous one (the first is `100`).
120    #[must_use]
121    pub fn conversions(&self) -> Vec<f64> {
122        let mut prev = None;
123        self.stages
124            .iter()
125            .map(|s| {
126                let v = sane(s.value);
127                let c = match prev {
128                    None => 100.0,
129                    Some(p) if p > 0.0 => v / p * 100.0,
130                    Some(_) => 0.0,
131                };
132                prev = Some(v);
133                c
134            })
135            .collect()
136    }
137
138    fn color_of(&self, i: usize) -> Color {
139        self.stages[i].color.unwrap_or_else(|| palette(i))
140    }
141}
142
143/// Draws a horizontal span `[left, right)` in half-cell units (`2 * cell`),
144/// using half blocks at ragged edges.
145fn span_row(buf: &mut Buffer, area: Rect, y: u16, left: f64, right: f64, color: Color) {
146    let (l, r) = (left.round() as i64, right.round() as i64);
147    if r <= l {
148        return;
149    }
150    let style = Style::default().fg(color);
151    for cell in (l / 2)..=((r - 1) / 2) {
152        let (a, b) = (cell * 2, cell * 2 + 2);
153        let (lo, hi) = (l.max(a), r.min(b));
154        let ch = match (lo == a, hi == b) {
155            (true, true) => '█',
156            (true, false) => '▌',
157            (false, true) => '▐',
158            (false, false) => continue,
159        };
160        if cell >= 0 && cell < i64::from(area.width) {
161            put(buf, area.x + cell as u16, y, ch, style);
162        }
163    }
164}
165
166impl Widget for Funnel<'_> {
167    fn render(self, area: Rect, buf: &mut Buffer) {
168        buf.set_style(area, self.base);
169        let inner = block_inner(self.block.as_ref(), area, buf);
170        let n = self.stages.len();
171        if inner.is_empty() || n == 0 {
172            return;
173        }
174        let max = self
175            .stages
176            .iter()
177            .map(|s| sane(s.value))
178            .fold(0.0, f64::max);
179        if max <= 0.0 {
180            return;
181        }
182        let conv = self.conversions();
183        let value_w = if self.show_values {
184            self.stages
185                .iter()
186                .zip(&conv)
187                .map(|(s, c)| text_width(&format!("{} ({c:.0}%)", fmt_value(sane(s.value)))))
188                .max()
189                .unwrap_or(0)
190        } else {
191            0
192        };
193        let value_w = if usize::from(inner.width) > value_w + 12 {
194            value_w
195        } else {
196            0
197        };
198        let bar_area = Rect::new(
199            inner.x,
200            inner.y,
201            inner.width - value_w as u16 - u16::from(value_w > 0),
202            inner.height,
203        );
204        let rows = usize::from(inner.height);
205        let per = (rows / n).max(1);
206        let half_w = f64::from(bar_area.width) * 2.0; // half-cell units
207        for (i, stage) in self.stages.iter().enumerate() {
208            let top = i * per;
209            if top >= rows {
210                break;
211            }
212            let color = self.color_of(i);
213            let v = sane(stage.value);
214            let next = self.stages.get(i + 1).map_or(v, |s| sane(s.value));
215            let h = per.min(rows - top);
216            for r in 0..h {
217                let w_units = match self.style {
218                    FunnelStyle::Bars => v / max * half_w,
219                    FunnelStyle::Trapezoid => {
220                        let t = (r as f64 + 0.5) / per as f64;
221                        (v + (next - v) * t) / max * half_w
222                    }
223                };
224                let left = (half_w - w_units) / 2.0;
225                span_row(
226                    buf,
227                    bar_area,
228                    inner.y + (top + r) as u16,
229                    left,
230                    left + w_units,
231                    color,
232                );
233            }
234            // Label on the middle row, inside the bar when it fits.
235            let mid = inner.y + (top + h / 2) as u16;
236            let bar_cells = (v / max * f64::from(bar_area.width)) as usize;
237            let label = truncate(stage.label, usize::from(bar_area.width));
238            let lw = text_width(&label);
239            let fits = lw <= bar_cells;
240            // Inside the bar when it fits, otherwise at the left edge.
241            let x = if fits {
242                bar_area.x + ((usize::from(bar_area.width) - lw) / 2) as u16
243            } else {
244                bar_area.x
245            };
246            let style = if fits {
247                Style::default()
248                    .fg(contrast(color))
249                    .bg(color)
250                    .add_modifier(Modifier::BOLD)
251            } else {
252                Style::default().fg(color)
253            };
254            buf.set_string(x, mid, &label, style);
255            if value_w > 0 {
256                let text = format!("{} ({:.0}%)", fmt_value(v), conv[i]);
257                buf.set_string(
258                    bar_area.right() + 1,
259                    mid,
260                    truncate(&text, value_w),
261                    Style::default().fg(color),
262                );
263            }
264        }
265    }
266}
267
268fn fmt_value(v: f64) -> String {
269    if v >= 1_000_000.0 {
270        format!("{:.1}M", v / 1_000_000.0)
271    } else if v >= 10_000.0 {
272        format!("{:.0}k", v / 1_000.0)
273    } else if (v - v.round()).abs() < 1e-9 {
274        format!("{v:.0}")
275    } else {
276        format!("{v:.1}")
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    #![allow(clippy::assert_is_empty, clippy::float_cmp)]
283    use super::*;
284
285    fn stages() -> Vec<FunnelStage<'static>> {
286        vec![
287            FunnelStage::new("Visits", 10_000.0),
288            FunnelStage::new("Sign-ups", 2_400.0),
289            FunnelStage::new("Paid", 380.0).color(Color::Red),
290        ]
291    }
292
293    fn draw(f: Funnel<'_>, w: u16, h: u16) -> Buffer {
294        let a = Rect::new(0, 0, w, h);
295        let mut b = Buffer::empty(a);
296        f.render(a, &mut b);
297        b
298    }
299
300    fn text(b: &Buffer) -> String {
301        b.content()
302            .iter()
303            .map(ratatui::buffer::Cell::symbol)
304            .collect()
305    }
306
307    #[test]
308    fn conversions_are_relative_to_previous() {
309        let c = Funnel::new(stages()).conversions();
310        assert_eq!(c[0], 100.0);
311        assert!((c[1] - 24.0).abs() < 1e-9);
312        assert!((c[2] - 15.833_333).abs() < 1e-4);
313        assert!(
314            Funnel::new(vec![FunnelStage::new("a", 0.0), FunnelStage::new("b", 5.0)])
315                .conversions()
316                .iter()
317                .all(|c| c.is_finite())
318        );
319    }
320
321    #[test]
322    fn renders_labels_and_values() {
323        let t = text(&draw(Funnel::new(stages()), 60, 9));
324        assert!(t.contains("Visits") && t.contains("Sign-ups") && t.contains("24%"));
325        assert!(t.contains("10k") || t.contains("10000"));
326    }
327
328    #[test]
329    fn bars_narrow_downwards() {
330        let b = draw(Funnel::new(stages()).show_values(false), 60, 9);
331        let filled = |y: u16| (0..60).filter(|x| b[(*x, y)].symbol() == "█").count();
332        assert!(filled(0) > filled(4) && filled(4) > filled(8));
333    }
334
335    #[test]
336    fn trapezoid_tapers_within_a_stage() {
337        let b = draw(
338            Funnel::new(stages())
339                .style(FunnelStyle::Trapezoid)
340                .show_values(false),
341            60,
342            12,
343        );
344        let filled = |y: u16| (0..60).filter(|x| b[(*x, y)].symbol() != " ").count();
345        assert!(filled(0) >= filled(3));
346        assert_ne!(b, draw(Funnel::new(stages()).show_values(false), 60, 12));
347    }
348
349    #[test]
350    fn stage_color_applies() {
351        let b = draw(Funnel::new(stages()).show_values(false), 60, 9);
352        assert!(b
353            .content()
354            .iter()
355            .any(|c| c.symbol() == "█" && c.fg == Color::Red));
356    }
357
358    #[test]
359    fn robust() {
360        for style in [FunnelStyle::Bars, FunnelStyle::Trapezoid] {
361            for (w, h) in [(0, 0), (1, 1), (5, 2), (20, 1), (60, 30)] {
362                for st in [
363                    vec![],
364                    stages(),
365                    vec![FunnelStage::new("n", f64::NAN)],
366                    vec![FunnelStage::new("z", 0.0); 3],
367                    (0..50)
368                        .map(|i| FunnelStage::new("s", f64::from(i)))
369                        .collect(),
370                ] {
371                    let _ = draw(Funnel::new(st).style(style).block(Block::bordered()), w, h);
372                }
373            }
374        }
375    }
376
377    #[test]
378    fn value_formatting() {
379        assert_eq!(fmt_value(5.0), "5");
380        assert_eq!(fmt_value(5.5), "5.5");
381        assert_eq!(fmt_value(12_000.0), "12k");
382        assert_eq!(fmt_value(2_500_000.0), "2.5M");
383    }
384}