Skip to main content

nuviz_cli/tui/
chart.rs

1/// Braille-character based chart rendering.
2///
3/// Each terminal cell maps to a 2x4 dot grid using Unicode braille characters
4/// (U+2800–U+28FF). The bit pattern for each dot position is:
5///
6/// ```text
7///   Col 0  Col 1
8///   0x01   0x08    row 0
9///   0x02   0x10    row 1
10///   0x04   0x20    row 2
11///   0x40   0x80    row 3
12/// ```
13///
14/// Dot bit positions for braille encoding: [col][row]
15const BRAILLE_DOTS: [[u8; 4]; 2] = [
16    [0x01, 0x02, 0x04, 0x40], // left column
17    [0x08, 0x10, 0x20, 0x80], // right column
18];
19
20/// A canvas for drawing with braille characters.
21pub struct BrailleCanvas {
22    /// Width in terminal cells
23    width: usize,
24    /// Height in terminal cells
25    height: usize,
26    /// Pixel buffer: each cell has an 8-bit braille pattern
27    cells: Vec<Vec<u8>>,
28}
29
30impl BrailleCanvas {
31    pub fn new(width: usize, height: usize) -> Self {
32        Self {
33            width,
34            height,
35            cells: vec![vec![0u8; width]; height],
36        }
37    }
38
39    /// Pixel dimensions (each cell is 2 wide x 4 tall in dots)
40    pub fn pixel_width(&self) -> usize {
41        self.width * 2
42    }
43
44    pub fn pixel_height(&self) -> usize {
45        self.height * 4
46    }
47
48    /// Set a single pixel (in dot coordinates).
49    pub fn set_pixel(&mut self, x: usize, y: usize) {
50        let cell_x = x / 2;
51        let cell_y = y / 4;
52        let dot_x = x % 2;
53        let dot_y = y % 4;
54
55        if cell_x < self.width && cell_y < self.height {
56            self.cells[cell_y][cell_x] |= BRAILLE_DOTS[dot_x][dot_y];
57        }
58    }
59
60    /// Draw a line using Bresenham's algorithm.
61    pub fn draw_line(&mut self, x0: usize, y0: usize, x1: usize, y1: usize) {
62        let (mut x0, mut y0) = (x0 as isize, y0 as isize);
63        let (x1, y1) = (x1 as isize, y1 as isize);
64
65        let dx = (x1 - x0).abs();
66        let dy = -(y1 - y0).abs();
67        let sx: isize = if x0 < x1 { 1 } else { -1 };
68        let sy: isize = if y0 < y1 { 1 } else { -1 };
69        let mut err = dx + dy;
70
71        loop {
72            if x0 >= 0 && y0 >= 0 {
73                self.set_pixel(x0 as usize, y0 as usize);
74            }
75
76            if x0 == x1 && y0 == y1 {
77                break;
78            }
79
80            let e2 = 2 * err;
81            if e2 >= dy {
82                err += dy;
83                x0 += sx;
84            }
85            if e2 <= dx {
86                err += dx;
87                y0 += sy;
88            }
89        }
90    }
91
92    /// Render the canvas to a vector of strings (one per row).
93    pub fn render(&self) -> Vec<String> {
94        self.cells
95            .iter()
96            .map(|row| {
97                row.iter()
98                    .map(|&bits| char::from_u32(0x2800 + bits as u32).unwrap_or(' '))
99                    .collect()
100            })
101            .collect()
102    }
103}
104
105/// Plot a data series as a braille line chart.
106///
107/// Returns rendered lines with Y-axis labels prepended.
108pub fn plot_series(data: &[f64], width: usize, height: usize, label: &str) -> Vec<String> {
109    if data.is_empty() || width == 0 || height == 0 {
110        return vec![format!("{label}: (no data)")];
111    }
112
113    // Reserve space for Y-axis labels
114    let label_width = 8;
115    let chart_width = width.saturating_sub(label_width);
116    if chart_width == 0 {
117        return vec![format!("{label}: (too narrow)")];
118    }
119
120    let mut canvas = BrailleCanvas::new(chart_width, height);
121    let pw = canvas.pixel_width();
122    let ph = canvas.pixel_height();
123
124    // Find data range
125    let min_val = data.iter().copied().fold(f64::INFINITY, f64::min);
126    let max_val = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
127    let range = if (max_val - min_val).abs() < f64::EPSILON {
128        1.0
129    } else {
130        max_val - min_val
131    };
132
133    // Map data points to pixel coordinates
134    let points: Vec<(usize, usize)> = data
135        .iter()
136        .enumerate()
137        .map(|(i, &v)| {
138            let x = if data.len() > 1 {
139                i * (pw - 1) / (data.len() - 1)
140            } else {
141                pw / 2
142            };
143            // Y is inverted (0 at top)
144            let y = ((max_val - v) / range * (ph - 1) as f64) as usize;
145            (x, y.min(ph - 1))
146        })
147        .collect();
148
149    // Draw lines between consecutive points
150    for window in points.windows(2) {
151        canvas.draw_line(window[0].0, window[0].1, window[1].0, window[1].1);
152    }
153
154    // If single point, just set the pixel
155    if points.len() == 1 {
156        canvas.set_pixel(points[0].0, points[0].1);
157    }
158
159    // Render with Y-axis labels
160    let rendered = canvas.render();
161    let mut result = Vec::with_capacity(height + 1);
162
163    // Title
164    result.push(format!("─ {label} "));
165
166    for (i, line) in rendered.iter().enumerate() {
167        let y_val = if i == 0 {
168            max_val
169        } else if i == height - 1 {
170            min_val
171        } else {
172            max_val - (i as f64 / (height - 1) as f64) * range
173        };
174
175        let y_label = format_number(y_val);
176        result.push(format!("{y_label:>label_width$}┤{line}"));
177    }
178
179    result
180}
181
182/// Format a number compactly for axis labels.
183pub fn format_number(v: f64) -> String {
184    let abs = v.abs();
185    if abs == 0.0 {
186        "0".into()
187    } else if abs >= 1_000_000.0 {
188        format!("{:.1}M", v / 1_000_000.0)
189    } else if abs >= 1_000.0 {
190        format!("{:.1}k", v / 1_000.0)
191    } else if abs >= 1.0 {
192        format!("{v:.2}")
193    } else if abs >= 0.001 {
194        format!("{v:.4}")
195    } else {
196        format!("{v:.2e}")
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn test_braille_canvas_new() {
206        let canvas = BrailleCanvas::new(10, 5);
207        assert_eq!(canvas.pixel_width(), 20);
208        assert_eq!(canvas.pixel_height(), 20);
209    }
210
211    #[test]
212    fn test_set_pixel_renders_braille() {
213        let mut canvas = BrailleCanvas::new(1, 1);
214        canvas.set_pixel(0, 0); // top-left dot
215        let rendered = canvas.render();
216        assert_eq!(rendered.len(), 1);
217        // U+2800 + 0x01 = U+2801 = ⠁
218        assert_eq!(rendered[0], "\u{2801}");
219    }
220
221    #[test]
222    fn test_set_multiple_pixels() {
223        let mut canvas = BrailleCanvas::new(1, 1);
224        canvas.set_pixel(0, 0); // bit 0x01
225        canvas.set_pixel(1, 0); // bit 0x08
226        let rendered = canvas.render();
227        // 0x01 | 0x08 = 0x09 => U+2809 = ⠉
228        assert_eq!(rendered[0], "\u{2809}");
229    }
230
231    #[test]
232    fn test_empty_canvas_renders_blank_braille() {
233        let canvas = BrailleCanvas::new(3, 2);
234        let rendered = canvas.render();
235        assert_eq!(rendered.len(), 2);
236        // Empty braille = U+2800 = ⠀
237        for line in &rendered {
238            assert_eq!(line.chars().count(), 3);
239            assert!(line.chars().all(|c| c == '\u{2800}'));
240        }
241    }
242
243    #[test]
244    fn test_draw_line_horizontal() {
245        let mut canvas = BrailleCanvas::new(5, 1);
246        canvas.draw_line(0, 0, 9, 0);
247        let rendered = canvas.render();
248        // All cells in the top row should have dots
249        for c in rendered[0].chars() {
250            assert_ne!(c, '\u{2800}', "expected dots in horizontal line");
251        }
252    }
253
254    #[test]
255    fn test_draw_line_vertical() {
256        let mut canvas = BrailleCanvas::new(1, 3);
257        canvas.draw_line(0, 0, 0, 11);
258        let rendered = canvas.render();
259        // All rows should have dots in the left column
260        for line in &rendered {
261            let c = line.chars().next().unwrap();
262            assert_ne!(c, '\u{2800}', "expected dots in vertical line");
263        }
264    }
265
266    #[test]
267    fn test_out_of_bounds_pixel_ignored() {
268        let mut canvas = BrailleCanvas::new(2, 2);
269        canvas.set_pixel(100, 100); // should not panic
270        let rendered = canvas.render();
271        assert!(rendered.iter().all(|l| l.chars().all(|c| c == '\u{2800}')));
272    }
273
274    #[test]
275    fn test_plot_series_empty() {
276        let result = plot_series(&[], 40, 10, "test");
277        assert_eq!(result.len(), 1);
278        assert!(result[0].contains("no data"));
279    }
280
281    #[test]
282    fn test_plot_series_single_point() {
283        let result = plot_series(&[42.0], 40, 5, "value");
284        assert!(result.len() > 1);
285    }
286
287    #[test]
288    fn test_plot_series_monotonic() {
289        let data: Vec<f64> = (0..50).map(|i| i as f64).collect();
290        let result = plot_series(&data, 40, 8, "linear");
291        assert!(result.len() > 1);
292    }
293
294    #[test]
295    fn test_plot_series_constant() {
296        let data = vec![5.0; 20];
297        let result = plot_series(&data, 40, 5, "constant");
298        assert!(result.len() > 1);
299    }
300
301    #[test]
302    fn test_format_number() {
303        assert_eq!(format_number(0.0), "0");
304        assert_eq!(format_number(1500000.0), "1.5M");
305        assert_eq!(format_number(2500.0), "2.5k");
306        assert_eq!(format_number(3.14), "3.14");
307        assert_eq!(format_number(0.0523), "0.0523");
308    }
309}