Skip to main content

plotting/
text.rs

1//! Plain-text chart renderer. Draws `ChartSpec` as a block of text
2//! suitable for the CLI's log region, terminal stdout, or any
3//! context where SVG/canvas/ratatui widgets aren't available.
4//!
5//! Bar and stacked-bar specs render as horizontal bars sized to the
6//! biggest magnitude. Line specs render as a per-series sparkline.
7//! The output is purely monospace text — no colour — so it survives
8//! pasting into logs and scrolling.
9
10use crate::spec::{ChartKind, ChartSpec, SeriesPoint};
11
12const DEFAULT_WIDTH: usize = 48;
13
14/// Render `spec` to a multi-line string. `width` controls how wide
15/// each bar gets (min 10, max ~200 is sensible).
16#[must_use]
17pub fn render_text(spec: &ChartSpec, width: usize) -> String {
18    let width = width.clamp(10, 200);
19    let mut out = String::new();
20    out.push_str(&spec.title);
21    out.push('\n');
22    if !spec.y_label.is_empty() {
23        out.push_str(&format!("  ({})\n", spec.y_label));
24    }
25
26    match spec.kind {
27        ChartKind::Bar | ChartKind::StackedBar => draw_bars(spec, width, &mut out),
28        ChartKind::Line => draw_lines(spec, width, &mut out),
29    }
30
31    if !spec.notes.is_empty() {
32        out.push('\n');
33        for note in &spec.notes {
34            out.push_str("  · ");
35            out.push_str(note);
36            out.push('\n');
37        }
38    }
39
40    out
41}
42
43/// Default-width convenience wrapper: 48 columns, readable in the
44/// typical terminal log pane.
45#[must_use]
46pub fn render_text_default(spec: &ChartSpec) -> String {
47    render_text(spec, DEFAULT_WIDTH)
48}
49
50fn draw_bars(spec: &ChartSpec, width: usize, out: &mut String) {
51    // Flatten every (series label, point) into (label, value) rows so
52    // each bar gets an independent entry. Stacked vs grouped collapse
53    // to the same visual in text — one bar per (series, x).
54    let mut rows: Vec<(String, f64)> = Vec::new();
55    for series in &spec.series {
56        let prefix = if spec.series.len() > 1 {
57            format!("{}:", series.label)
58        } else {
59            String::new()
60        };
61        for point in &series.points {
62            let label = if prefix.is_empty() {
63                point.x.clone()
64            } else if point.x.is_empty() {
65                series.label.clone()
66            } else {
67                format!("{prefix}{}", point.x)
68            };
69            rows.push((label, point.y_f64()));
70        }
71    }
72
73    if rows.is_empty() {
74        out.push_str("  (no data)\n");
75        return;
76    }
77
78    let label_width = rows
79        .iter()
80        .map(|(l, _)| l.chars().count())
81        .max()
82        .unwrap_or(1)
83        .min(width / 2);
84    let max_abs = rows
85        .iter()
86        .map(|(_, v)| v.abs())
87        .fold(0.0_f64, f64::max)
88        .max(1.0);
89    let bar_width = width.saturating_sub(label_width + 3).max(4);
90
91    for (label, value) in rows {
92        let filled = ((value.abs() / max_abs) * bar_width as f64).round() as usize;
93        let bar: String = "█".repeat(filled.min(bar_width));
94        let label_trunc: String = truncate_left(&label, label_width);
95        let sign = if value < 0.0 { "-" } else { " " };
96        out.push_str(&format!(
97            "  {label_trunc:>label_width$} {sign}{bar} {value:.2}\n",
98        ));
99    }
100}
101
102fn draw_lines(spec: &ChartSpec, width: usize, out: &mut String) {
103    // For each series, draw a one-line sparkline using Unicode block
104    // characters. Align them by a common min/max so multiple series
105    // on the same chart read as comparable.
106    if spec.series.is_empty() || spec.series.iter().all(|s| s.points.is_empty()) {
107        out.push_str("  (no data)\n");
108        return;
109    }
110
111    let (min, max) = spec
112        .series
113        .iter()
114        .flat_map(|s| s.points.iter().map(SeriesPoint::y_f64))
115        .fold((f64::INFINITY, f64::NEG_INFINITY), |(mn, mx), y| {
116            (mn.min(y), mx.max(y))
117        });
118    let span = (max - min).abs().max(1.0);
119    let label_width = spec
120        .series
121        .iter()
122        .map(|s| s.label.chars().count())
123        .max()
124        .unwrap_or(1)
125        .min(width / 2);
126    let line_width = width.saturating_sub(label_width + 3).max(4);
127    const BLOCKS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
128
129    for series in &spec.series {
130        let label = truncate_left(&series.label, label_width);
131        let sparkline: String = (0..series.points.len().min(line_width))
132            .map(|idx| {
133                let y = series.points[idx].y_f64();
134                let pos = ((y - min) / span * (BLOCKS.len() - 1) as f64)
135                    .round()
136                    .clamp(0.0, (BLOCKS.len() - 1) as f64);
137                BLOCKS[pos as usize]
138            })
139            .collect();
140        out.push_str(&format!("  {label:>label_width$} {sparkline}\n"));
141    }
142}
143
144fn truncate_left(s: &str, max: usize) -> String {
145    let chars: Vec<char> = s.chars().collect();
146    if chars.len() <= max {
147        s.to_string()
148    } else {
149        // Keep the trailing characters — account names often share a
150        // common prefix (e.g. "Expenses:Food") and the tail is the
151        // informative bit.
152        chars[chars.len() - max..].iter().collect()
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use crate::spec::{ChartKind, Series, SeriesPoint};
160
161    fn sample_bar() -> ChartSpec {
162        ChartSpec {
163            title: "Balance".to_string(),
164            kind: ChartKind::Bar,
165            x_label: "Account".to_string(),
166            y_label: "USD".to_string(),
167            series: vec![
168                Series {
169                    label: "Assets".to_string(),
170                    commodity_symbol: "USD".to_string(),
171                    points: vec![SeriesPoint {
172                        x: "Assets".to_string(),
173                        y_num: 10_000,
174                        y_denom: 1,
175                    }],
176                },
177                Series {
178                    label: "Income".to_string(),
179                    commodity_symbol: "USD".to_string(),
180                    points: vec![SeriesPoint {
181                        x: "Income".to_string(),
182                        y_num: -3_000,
183                        y_denom: 1,
184                    }],
185                },
186            ],
187            notes: vec!["Top 2 accounts.".to_string()],
188        }
189    }
190
191    #[test]
192    fn render_text_contains_title_and_labels() {
193        let out = render_text(&sample_bar(), 40);
194        assert!(out.contains("Balance"));
195        assert!(out.contains("Assets"));
196        assert!(out.contains("Income"));
197        assert!(out.contains("Top 2 accounts."));
198    }
199
200    #[test]
201    fn render_text_empty_spec_is_safe() {
202        let spec = ChartSpec {
203            title: "Empty".to_string(),
204            kind: ChartKind::Bar,
205            x_label: String::new(),
206            y_label: String::new(),
207            series: vec![],
208            notes: vec![],
209        };
210        let out = render_text(&spec, 20);
211        assert!(out.contains("no data"));
212    }
213
214    #[test]
215    fn render_text_line_uses_sparkline_blocks() {
216        let spec = ChartSpec {
217            title: "Line".to_string(),
218            kind: ChartKind::Line,
219            x_label: "Month".to_string(),
220            y_label: "USD".to_string(),
221            series: vec![Series {
222                label: "Income".to_string(),
223                commodity_symbol: "USD".to_string(),
224                points: vec![
225                    SeriesPoint {
226                        x: "Jan".to_string(),
227                        y_num: 100,
228                        y_denom: 1,
229                    },
230                    SeriesPoint {
231                        x: "Feb".to_string(),
232                        y_num: 200,
233                        y_denom: 1,
234                    },
235                    SeriesPoint {
236                        x: "Mar".to_string(),
237                        y_num: 50,
238                        y_denom: 1,
239                    },
240                ],
241            }],
242            notes: vec![],
243        };
244        let out = render_text(&spec, 30);
245        // At least one sparkline block char appears in the output.
246        assert!(out.chars().any(|c| "▁▂▃▄▅▆▇█".contains(c)));
247    }
248
249    #[test]
250    fn truncate_left_keeps_tail() {
251        assert_eq!(truncate_left("Expenses:Food", 5), ":Food");
252        assert_eq!(truncate_left("Food", 10), "Food");
253    }
254}