1use ratatui::Frame;
14use ratatui::layout::{Constraint, Direction, Layout, Rect};
15use ratatui::style::{Color, Style};
16use ratatui::symbols;
17use ratatui::text::Line;
18use ratatui::widgets::{Axis, Bar, BarChart, BarGroup, Block, Borders, Chart, Dataset, Paragraph};
19
20use crate::spec::{ChartKind, ChartSpec, SeriesPoint};
21
22const PALETTE: &[Color] = &[
25 Color::Cyan,
26 Color::Yellow,
27 Color::Green,
28 Color::Red,
29 Color::Magenta,
30 Color::Blue,
31 Color::LightRed,
32];
33
34fn color_for(index: usize) -> Color {
35 PALETTE[index % PALETTE.len()]
36}
37
38struct RenderedSeries {
41 label: String,
42 color: Color,
43 points: Vec<(f64, f64)>,
44}
45
46pub struct RatatuiChart {
50 title: String,
51 kind: ChartKind,
52 x_label: String,
53 y_label: String,
54 x_labels: Vec<String>,
55 notes: Vec<String>,
56 series: Vec<RenderedSeries>,
57 x_bounds: [f64; 2],
58 y_bounds: [f64; 2],
59}
60
61impl RatatuiChart {
62 pub fn draw(&self, frame: &mut Frame<'_>, area: Rect) {
66 let note_lines = self.notes.len().min(3) as u16;
67 let chunks = Layout::default()
68 .direction(Direction::Vertical)
69 .constraints([Constraint::Min(3), Constraint::Length(note_lines)])
70 .split(area);
71
72 match self.kind {
73 ChartKind::Line => self.draw_line(frame, chunks[0]),
74 ChartKind::Bar | ChartKind::StackedBar => self.draw_bars(frame, chunks[0]),
75 }
76
77 if note_lines > 0 {
78 let text: Vec<Line<'_>> = self
79 .notes
80 .iter()
81 .take(3)
82 .map(|s| Line::from(s.clone()))
83 .collect();
84 frame.render_widget(
85 Paragraph::new(text).style(Style::default().fg(Color::DarkGray)),
86 chunks[1],
87 );
88 }
89 }
90
91 fn draw_line(&self, frame: &mut Frame<'_>, area: Rect) {
92 let datasets: Vec<Dataset<'_>> = self
93 .series
94 .iter()
95 .map(|s| {
96 Dataset::default()
97 .name(s.label.clone())
98 .marker(symbols::Marker::Braille)
99 .style(Style::default().fg(s.color))
100 .data(&s.points)
101 })
102 .collect();
103
104 let x_labels_refs: Vec<Line<'_>> = self
105 .x_labels
106 .iter()
107 .map(|l| Line::from(l.clone()))
108 .collect();
109
110 let chart = Chart::new(datasets)
111 .block(
112 Block::default()
113 .borders(Borders::ALL)
114 .title(self.title.clone()),
115 )
116 .x_axis(
117 Axis::default()
118 .title(self.x_label.clone())
119 .style(Style::default().fg(Color::Gray))
120 .bounds(self.x_bounds)
121 .labels(x_labels_refs),
122 )
123 .y_axis(
124 Axis::default()
125 .title(self.y_label.clone())
126 .style(Style::default().fg(Color::Gray))
127 .bounds(self.y_bounds)
128 .labels([
129 format!("{:.0}", self.y_bounds[0]),
130 format!("{:.0}", f64::midpoint(self.y_bounds[0], self.y_bounds[1])),
131 format!("{:.0}", self.y_bounds[1]),
132 ]),
133 );
134
135 frame.render_widget(chart, area);
136 }
137
138 fn draw_bars(&self, frame: &mut Frame<'_>, area: Rect) {
139 let groups: Vec<BarGroup<'_>> = (0..self.x_labels.len())
142 .map(|x_idx| {
143 let label = self.x_labels[x_idx].clone();
144 let bars: Vec<Bar<'_>> = self
145 .series
146 .iter()
147 .map(|s| {
148 let value = s
149 .points
150 .iter()
151 .find(|(x, _)| (*x as usize) == x_idx)
152 .map_or(0.0_f64, |(_, y)| *y);
153 let u = if value < 0.0 {
156 0
157 } else {
158 value.round().max(0.0) as u64
159 };
160 Bar::default()
161 .value(u)
162 .label(Line::from(s.label.clone()))
163 .style(Style::default().fg(s.color))
164 })
165 .collect();
166 BarGroup::default().label(Line::from(label)).bars(&bars)
167 })
168 .collect();
169
170 let mut bar_chart = BarChart::default()
171 .block(
172 Block::default()
173 .borders(Borders::ALL)
174 .title(self.title.clone()),
175 )
176 .bar_width(3)
177 .bar_gap(1)
178 .group_gap(2);
179 for group in groups {
180 bar_chart = bar_chart.data(group);
181 }
182 frame.render_widget(bar_chart, area);
183 }
184
185 #[must_use]
186 pub fn title(&self) -> &str {
187 &self.title
188 }
189
190 #[must_use]
191 pub fn notes(&self) -> &[String] {
192 &self.notes
193 }
194}
195
196#[must_use]
197pub fn render_ratatui(spec: &ChartSpec) -> RatatuiChart {
198 let mut x_labels: Vec<String> = Vec::new();
200 for series in &spec.series {
201 for point in &series.points {
202 if !x_labels.iter().any(|l| l == &point.x) {
203 x_labels.push(point.x.clone());
204 }
205 }
206 }
207
208 let series: Vec<RenderedSeries> = spec
210 .series
211 .iter()
212 .enumerate()
213 .map(|(s_idx, s)| {
214 let points: Vec<(f64, f64)> = x_labels
215 .iter()
216 .enumerate()
217 .map(|(x_idx, label)| {
218 let y = s
219 .points
220 .iter()
221 .find(|p| &p.x == label)
222 .map_or(0.0, SeriesPoint::y_f64);
223 (x_idx as f64, y)
224 })
225 .collect();
226 RenderedSeries {
227 label: s.label.clone(),
228 color: color_for(s_idx),
229 points,
230 }
231 })
232 .collect();
233
234 let x_bounds = if x_labels.is_empty() {
235 [0.0, 1.0]
236 } else {
237 [0.0, (x_labels.len() - 1).max(1) as f64]
238 };
239
240 let (y_min, y_max) = y_bounds(spec);
241 let y_bounds = [y_min, y_max];
242
243 RatatuiChart {
244 title: spec.title.clone(),
245 kind: spec.kind,
246 x_label: spec.x_label.clone(),
247 y_label: spec.y_label.clone(),
248 x_labels,
249 notes: spec.notes.clone(),
250 series,
251 x_bounds,
252 y_bounds,
253 }
254}
255
256fn y_bounds(spec: &ChartSpec) -> (f64, f64) {
257 let (mut min, mut max) = (0.0_f64, 0.0_f64);
258 for series in &spec.series {
259 for point in &series.points {
260 let y = point.y_f64();
261 if y < min {
262 min = y;
263 }
264 if y > max {
265 max = y;
266 }
267 }
268 }
269 let span = (max - min).abs().max(1.0);
270 let pad = span * 0.05;
271 (min - pad, max + pad)
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::spec::{ChartKind, Series, SeriesPoint};
278
279 fn sample(kind: ChartKind) -> ChartSpec {
280 ChartSpec {
281 title: "Test".to_string(),
282 kind,
283 x_label: "Period".to_string(),
284 y_label: "Amount".to_string(),
285 series: vec![
286 Series {
287 label: "Income".to_string(),
288 commodity_symbol: "USD".to_string(),
289 points: vec![
290 SeriesPoint {
291 x: "Jan".to_string(),
292 y_num: 100,
293 y_denom: 1,
294 },
295 SeriesPoint {
296 x: "Feb".to_string(),
297 y_num: 200,
298 y_denom: 1,
299 },
300 ],
301 },
302 Series {
303 label: "Expense".to_string(),
304 commodity_symbol: "USD".to_string(),
305 points: vec![
306 SeriesPoint {
307 x: "Jan".to_string(),
308 y_num: 80,
309 y_denom: 1,
310 },
311 SeriesPoint {
312 x: "Feb".to_string(),
313 y_num: 150,
314 y_denom: 1,
315 },
316 ],
317 },
318 ],
319 notes: vec!["Showing USD only.".to_string()],
320 }
321 }
322
323 #[test]
324 fn render_ratatui_builds_for_each_kind() {
325 for kind in [ChartKind::Bar, ChartKind::StackedBar, ChartKind::Line] {
326 let chart = render_ratatui(&sample(kind));
327 assert_eq!(chart.title(), "Test");
328 assert_eq!(chart.notes(), &["Showing USD only.".to_string()]);
329 }
330 }
331
332 #[test]
333 fn render_ratatui_collects_all_x_labels_in_order() {
334 let chart = render_ratatui(&sample(ChartKind::Line));
335 assert_eq!(chart.x_labels, vec!["Jan".to_string(), "Feb".to_string()]);
336 }
337
338 #[test]
339 fn render_ratatui_builds_series_with_zero_fill() {
340 let spec = ChartSpec {
342 title: "T".to_string(),
343 kind: ChartKind::Line,
344 x_label: "X".to_string(),
345 y_label: "Y".to_string(),
346 series: vec![
347 Series {
348 label: "A".to_string(),
349 commodity_symbol: String::new(),
350 points: vec![
351 SeriesPoint {
352 x: "x1".to_string(),
353 y_num: 1,
354 y_denom: 1,
355 },
356 SeriesPoint {
357 x: "x2".to_string(),
358 y_num: 2,
359 y_denom: 1,
360 },
361 ],
362 },
363 Series {
364 label: "B".to_string(),
365 commodity_symbol: String::new(),
366 points: vec![SeriesPoint {
367 x: "x2".to_string(),
368 y_num: 5,
369 y_denom: 1,
370 }],
371 },
372 ],
373 notes: vec![],
374 };
375 let chart = render_ratatui(&spec);
376 assert_eq!(chart.series.len(), 2);
377 let b_points = &chart.series[1].points;
378 assert_eq!(b_points.len(), 2);
379 assert_eq!(b_points[0], (0.0, 0.0));
381 assert_eq!(b_points[1], (1.0, 5.0));
382 }
383
384 #[test]
385 fn render_ratatui_empty_spec_is_safe() {
386 let spec = ChartSpec {
387 title: "Empty".to_string(),
388 kind: ChartKind::Bar,
389 x_label: String::new(),
390 y_label: String::new(),
391 series: vec![],
392 notes: vec![],
393 };
394 let chart = render_ratatui(&spec);
395 assert_eq!(chart.title(), "Empty");
396 assert!(chart.x_labels.is_empty());
397 assert_eq!(chart.x_bounds, [0.0, 1.0]);
398 }
399}