1#![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#[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 #[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 #[must_use]
40 pub const fn color(mut self, color: Color) -> Self {
41 self.color = Some(color);
42 self
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum FunnelStyle {
49 #[default]
51 Bars,
52 Trapezoid,
54}
55
56#[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 #[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 #[must_use]
93 pub const fn style(mut self, style: FunnelStyle) -> Self {
94 self.style = style;
95 self
96 }
97
98 #[must_use]
100 pub const fn show_values(mut self, show: bool) -> Self {
101 self.show_values = show;
102 self
103 }
104
105 #[must_use]
107 pub fn block(mut self, block: Block<'a>) -> Self {
108 self.block = Some(block);
109 self
110 }
111
112 #[must_use]
114 pub const fn base_style(mut self, style: Style) -> Self {
115 self.base = style;
116 self
117 }
118
119 #[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
143fn 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; 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 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 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}