Skip to main content

ballistics_engine/
terminal_plot.rs

1//! Zero-dependency terminal chart rendering for `trajectory --plot` (MBA-1320).
2//!
3//! Presentation code shared by the native CLI and the WASM terminal (MBA-1337 p3
4//! moved it from a `main.rs`-local `mod` into the lib so `wasm.rs` can render the
5//! same charts). It stays dependency-free; bindings consumers can simply ignore it.
6//!
7//! Two canvas backends share one dot-addressed API (`new`, `width_dots`/`height_dots`,
8//! `set`, `render`):
9//!
10//! - [`BrailleCanvas`] packs a 2 (wide) x 4 (tall) grid of dots into a single Unicode
11//!   Braille Patterns character (`U+2800`..=`U+28FF`), giving roughly 2x the horizontal and
12//!   4x the vertical resolution of one terminal character cell.
13//! - [`AsciiCanvas`] uses the *same* 2x4 dot-per-cell addressing so callers (in particular
14//!   [`render_chart`]) don't need to special-case it, but collapses each cell to a single
15//!   `'*'` (any dot set) or `' '` (no dots set) — for terminals/fonts without braille glyph
16//!   coverage.
17//!
18//! Deliberately monochrome: no ANSI color/SGR codes appear anywhere in this module. That
19//! sidesteps `NO_COLOR` (<https://no-color.org/>) entirely — there is nothing to suppress —
20//! and keeps output byte-identical whether the terminal honors color, redirects to a file,
21//! or is a dumb pipe. Multiple series plotted on one canvas are therefore visually
22//! indistinguishable where their dots overlap; [`render_chart`] lists every series label in
23//! the frame instead of color-coding them. Callers that need series to stay visually
24//! separable should render one series per chart (which is what `trajectory --plot` does:
25//! one chart for drop, one for lateral drift).
26
27/// Bit weight of each of the 8 sub-dot positions within one braille cell, indexed
28/// `[row][col]` with row `0..4` (top-to-bottom) and col `0..2` (left-to-right). This is the
29/// standard Unicode braille dot numbering:
30///
31/// ```text
32/// 1 4      0x01 0x08
33/// 2 5  ->  0x02 0x10
34/// 3 6      0x04 0x20
35/// 7 8      0x40 0x80
36/// ```
37const BRAILLE_BITS: [[u8; 2]; 4] = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];
38
39/// First codepoint of the Unicode Braille Patterns block; a cell's mask (0..=255) added to
40/// this base gives the glyph for that cell.
41const BRAILLE_BASE: u32 = 0x2800;
42
43/// A monochrome canvas addressed in DOT coordinates, shared by [`BrailleCanvas`] and
44/// [`AsciiCanvas`]: `(0, 0)` is the top-left dot, `x` grows right, `y` grows down. Each
45/// implementor packs a 2 (wide) x 4 (tall) block of dots into one printable character
46/// cell — `width_dots() == width_cells * 2`, `height_dots() == height_cells * 4`.
47trait DotCanvas {
48    fn width_dots(&self) -> usize;
49    fn height_dots(&self) -> usize;
50    /// Turn on the dot at `(x, y)`. Coordinates outside the canvas are silently dropped
51    /// (callers are expected to clamp before calling; this just keeps the API panic-free).
52    fn set(&mut self, x: usize, y: usize);
53    /// Render to a string of `height_cells` lines, each `width_cells` characters wide, no
54    /// trailing newline.
55    fn render(&self) -> String;
56}
57
58/// Braille-dot terminal canvas: each character cell packs a 2x4 grid of dots into one
59/// Unicode Braille Patterns codepoint.
60#[derive(Debug, Clone)]
61pub struct BrailleCanvas {
62    width_cells: usize,
63    height_cells: usize,
64    cells: Vec<u8>,
65}
66
67impl BrailleCanvas {
68    pub fn new(width_cells: usize, height_cells: usize) -> Self {
69        Self {
70            width_cells,
71            height_cells,
72            cells: vec![0u8; width_cells * height_cells],
73        }
74    }
75
76    pub fn width_dots(&self) -> usize {
77        self.width_cells * 2
78    }
79
80    pub fn height_dots(&self) -> usize {
81        self.height_cells * 4
82    }
83
84    /// Turn on the dot at dot-coordinate `(x, y)`; `(0, 0)` is top-left. Coordinates
85    /// outside the canvas are silently dropped.
86    pub fn set(&mut self, x: usize, y: usize) {
87        if x >= self.width_dots() || y >= self.height_dots() {
88            return;
89        }
90        let cell_x = x / 2;
91        let cell_y = y / 4;
92        let bit = BRAILLE_BITS[y % 4][x % 2];
93        self.cells[cell_y * self.width_cells + cell_x] |= bit;
94    }
95
96    /// Render to a string of `height_cells` lines, each `width_cells` characters wide (no
97    /// trailing newline). A cell with no dots set renders as `U+2800` (BRAILLE PATTERN
98    /// BLANK), not an ASCII space — it is a real glyph, not empty output.
99    pub fn render(&self) -> String {
100        let mut out = String::with_capacity((self.width_cells + 1) * self.height_cells);
101        for row in 0..self.height_cells {
102            if row > 0 {
103                out.push('\n');
104            }
105            for col in 0..self.width_cells {
106                let mask = self.cells[row * self.width_cells + col];
107                // Every value 0..=255 maps to a valid codepoint in the braille block, so
108                // this can never actually fall back to '?'.
109                let ch = char::from_u32(BRAILLE_BASE + mask as u32).unwrap_or('?');
110                out.push(ch);
111            }
112        }
113        out
114    }
115}
116
117impl DotCanvas for BrailleCanvas {
118    fn width_dots(&self) -> usize {
119        BrailleCanvas::width_dots(self)
120    }
121    fn height_dots(&self) -> usize {
122        BrailleCanvas::height_dots(self)
123    }
124    fn set(&mut self, x: usize, y: usize) {
125        BrailleCanvas::set(self, x, y)
126    }
127    fn render(&self) -> String {
128        BrailleCanvas::render(self)
129    }
130}
131
132/// ASCII fallback canvas: the same dot-addressed API as [`BrailleCanvas`] (a 2x4 dot grid
133/// per character cell), but rendered with a single `'*'` whenever ANY dot in the cell is
134/// set, `' '` otherwise — for terminals/fonts without braille glyph coverage.
135#[derive(Debug, Clone)]
136pub struct AsciiCanvas {
137    width_cells: usize,
138    height_cells: usize,
139    cells: Vec<bool>,
140}
141
142impl AsciiCanvas {
143    pub fn new(width_cells: usize, height_cells: usize) -> Self {
144        Self {
145            width_cells,
146            height_cells,
147            cells: vec![false; width_cells * height_cells],
148        }
149    }
150
151    pub fn width_dots(&self) -> usize {
152        self.width_cells * 2
153    }
154
155    pub fn height_dots(&self) -> usize {
156        self.height_cells * 4
157    }
158
159    pub fn set(&mut self, x: usize, y: usize) {
160        if x >= self.width_dots() || y >= self.height_dots() {
161            return;
162        }
163        let cell_x = x / 2;
164        let cell_y = y / 4;
165        self.cells[cell_y * self.width_cells + cell_x] = true;
166    }
167
168    pub fn render(&self) -> String {
169        let mut out = String::with_capacity((self.width_cells + 1) * self.height_cells);
170        for row in 0..self.height_cells {
171            if row > 0 {
172                out.push('\n');
173            }
174            for col in 0..self.width_cells {
175                out.push(if self.cells[row * self.width_cells + col] {
176                    '*'
177                } else {
178                    ' '
179                });
180            }
181        }
182        out
183    }
184}
185
186impl DotCanvas for AsciiCanvas {
187    fn width_dots(&self) -> usize {
188        AsciiCanvas::width_dots(self)
189    }
190    fn height_dots(&self) -> usize {
191        AsciiCanvas::height_dots(self)
192    }
193    fn set(&mut self, x: usize, y: usize) {
194        AsciiCanvas::set(self, x, y)
195    }
196    fn render(&self) -> String {
197        AsciiCanvas::render(self)
198    }
199}
200
201/// Which canvas backend [`render_chart`] should use.
202#[derive(Debug, Clone, Copy, PartialEq, Eq)]
203pub enum CanvasStyle {
204    Braille,
205    Ascii,
206}
207
208fn make_canvas(style: CanvasStyle, width_cells: usize, height_cells: usize) -> Box<dyn DotCanvas> {
209    match style {
210        CanvasStyle::Braille => Box::new(BrailleCanvas::new(width_cells, height_cells)),
211        CanvasStyle::Ascii => Box::new(AsciiCanvas::new(width_cells, height_cells)),
212    }
213}
214
215/// Build one border line exactly `width` characters between `left` and `right`: as much of
216/// `text` as fits, then `fill` out to `width`. `text` longer than `width` is truncated (no
217/// ellipsis — this is a fixed-width terminal frame, not prose).
218fn frame_line(left: char, fill: char, right: char, text: &str, width: usize) -> String {
219    let mut s = String::with_capacity(width + 2);
220    s.push(left);
221    let mut used = 0usize;
222    for c in text.chars() {
223        if used >= width {
224            break;
225        }
226        s.push(c);
227        used += 1;
228    }
229    for _ in used..width {
230        s.push(fill);
231    }
232    s.push(right);
233    s
234}
235
236/// Render one or more named `(label, points)` series into a single framed chart.
237///
238/// - Axis ranges are auto-scaled from the data: min/max `x` and min/max `y` across every
239///   series. The `y` range and every series label are printed on the top border; the `x`
240///   range is printed on the bottom border — "framed chart string with x/y axis ranges
241///   printed on the border", per the ticket.
242/// - Points are plotted as dots only; there is no line interpolation between consecutive
243///   samples. Dense series (trajectory integration output, which this is built for, easily
244///   has hundreds to thousands of points over a 72-cell-wide canvas) render as a
245///   continuous-looking curve purely from sample density; sparse series will look like
246///   literally scattered dots. Adding Bresenham-style line segments was considered and
247///   dropped: it's not needed for the trajectory use case and would be extra surface to get
248///   right for no visible benefit there.
249/// - `width_cells`/`height_cells` size only the PLOT AREA. The returned string is
250///   `width_cells + 2` characters wide (a left/right frame column) and `height_cells + 2`
251///   lines tall (top/bottom frame rows), with no trailing newline.
252/// - A degenerate axis (every point shares the same `x`, or the same `y`) is expanded by
253///   ±0.5 around that single value so the scale never divides by zero.
254/// - Non-finite (`NaN`/`inf`) coordinates are skipped entirely — they neither affect the
255///   autoscale range nor get plotted — rather than corrupting the whole chart's scale.
256pub fn render_chart(
257    series: &[(&str, &[(f64, f64)])],
258    width_cells: usize,
259    height_cells: usize,
260    style: CanvasStyle,
261) -> String {
262    let finite_points = || {
263        series
264            .iter()
265            .flat_map(|(_, pts)| pts.iter())
266            .filter(|(x, y)| x.is_finite() && y.is_finite())
267    };
268
269    let (mut xmin, mut xmax) = (f64::INFINITY, f64::NEG_INFINITY);
270    let (mut ymin, mut ymax) = (f64::INFINITY, f64::NEG_INFINITY);
271    for &(x, y) in finite_points() {
272        xmin = xmin.min(x);
273        xmax = xmax.max(x);
274        ymin = ymin.min(y);
275        ymax = ymax.max(y);
276    }
277    // No finite data at all: fall back to a fixed 0..1 range so the frame/axis text still
278    // renders sensibly instead of showing +inf/-inf.
279    if !xmin.is_finite() || !xmax.is_finite() {
280        xmin = 0.0;
281        xmax = 1.0;
282    }
283    if !ymin.is_finite() || !ymax.is_finite() {
284        ymin = 0.0;
285        ymax = 1.0;
286    }
287    // Degenerate (single-valued) axis: expand around the value so the scale below never
288    // divides by zero.
289    if xmax <= xmin {
290        xmin -= 0.5;
291        xmax += 0.5;
292    }
293    if ymax <= ymin {
294        ymin -= 0.5;
295        ymax += 0.5;
296    }
297    let xspan = xmax - xmin;
298    let yspan = ymax - ymin;
299
300    let mut canvas = make_canvas(style, width_cells, height_cells);
301    let width_dots = canvas.width_dots();
302    let height_dots = canvas.height_dots();
303    if width_dots > 0 && height_dots > 0 {
304        let max_dx = (width_dots - 1) as f64;
305        let max_dy = (height_dots - 1) as f64;
306        for &(x, y) in finite_points() {
307            let dx = ((x - xmin) / xspan * max_dx).round().clamp(0.0, max_dx) as usize;
308            // Row 0 is the TOP of the canvas, so larger y (up) must map to a SMALLER row.
309            let dy = ((ymax - y) / yspan * max_dy).round().clamp(0.0, max_dy) as usize;
310            canvas.set(dx, dy);
311        }
312    }
313
314    let labels = series
315        .iter()
316        .map(|(label, _)| *label)
317        .collect::<Vec<_>>()
318        .join(", ");
319    let top_text = format!(" {} \u{2014} y:[{:.2}, {:.2}] ", labels, ymin, ymax);
320    let bottom_text = format!(" x:[{:.2}, {:.2}] ", xmin, xmax);
321
322    let mut out = String::new();
323    out.push_str(&frame_line('\u{250C}', '\u{2500}', '\u{2510}', &top_text, width_cells));
324    out.push('\n');
325    for line in canvas.render().lines() {
326        out.push('\u{2502}');
327        out.push_str(line);
328        out.push('\u{2502}');
329        out.push('\n');
330    }
331    out.push_str(&frame_line(
332        '\u{2514}',
333        '\u{2500}',
334        '\u{2518}',
335        &bottom_text,
336        width_cells,
337    ));
338    out
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    // ---- Canvas primitives -------------------------------------------------------------
346
347    #[test]
348    fn braille_blank_cell_is_u2800() {
349        let c = BrailleCanvas::new(1, 1);
350        assert_eq!(c.render(), "\u{2800}");
351    }
352
353    #[test]
354    fn braille_single_dot_top_left() {
355        let mut c = BrailleCanvas::new(1, 1);
356        c.set(0, 0); // dot 1 -> bit 0x01
357        assert_eq!(c.render(), "\u{2801}");
358    }
359
360    #[test]
361    fn braille_single_dot_bottom_right() {
362        let mut c = BrailleCanvas::new(1, 1);
363        c.set(1, 3); // dot 8 -> bit 0x80
364        assert_eq!(c.render(), "\u{2880}");
365    }
366
367    #[test]
368    fn braille_full_cell_is_all_eight_dots() {
369        let mut c = BrailleCanvas::new(1, 1);
370        for y in 0..4 {
371            for x in 0..2 {
372                c.set(x, y);
373            }
374        }
375        // 0xFF -> U+28FF, the last codepoint in the braille block.
376        assert_eq!(c.render(), "\u{28FF}");
377    }
378
379    #[test]
380    fn braille_two_cell_row_addresses_columns_independently() {
381        let mut c = BrailleCanvas::new(2, 1);
382        c.set(0, 0); // left cell, dot 1
383        c.set(2, 0); // right cell (x=2 is dot-column 0 of cell 1), dot 1
384        assert_eq!(c.render(), "\u{2801}\u{2801}");
385    }
386
387    #[test]
388    fn braille_out_of_range_set_is_ignored_not_panicking() {
389        let mut c = BrailleCanvas::new(1, 1);
390        c.set(99, 99);
391        c.set(2, 0);
392        c.set(0, 4);
393        assert_eq!(c.render(), "\u{2800}");
394    }
395
396    #[test]
397    fn braille_multirow_multicol_layout() {
398        let mut c = BrailleCanvas::new(2, 2);
399        c.set(0, 0); // cell (0,0), dot 1
400        c.set(3, 7); // cell (1,1), dot 8 (x=3 -> col 1 of cell 1; y=7 -> row 3 of cell 1)
401        let rendered = c.render();
402        let lines: Vec<&str> = rendered.lines().collect();
403        assert_eq!(lines.len(), 2);
404        assert_eq!(lines[0], "\u{2801}\u{2800}");
405        assert_eq!(lines[1], "\u{2800}\u{2880}");
406    }
407
408    #[test]
409    fn ascii_blank_cell_is_space() {
410        let c = AsciiCanvas::new(1, 1);
411        assert_eq!(c.render(), " ");
412    }
413
414    #[test]
415    fn ascii_any_dot_in_cell_renders_star() {
416        let mut c = AsciiCanvas::new(1, 1);
417        c.set(1, 2); // a single dot anywhere in the cell is enough
418        assert_eq!(c.render(), "*");
419    }
420
421    #[test]
422    fn ascii_two_cell_row() {
423        let mut c = AsciiCanvas::new(3, 1);
424        c.set(0, 0);
425        c.set(5, 3); // cell 2 (x=5 -> col 5/2=2)
426        assert_eq!(c.render(), "* *");
427    }
428
429    #[test]
430    fn ascii_out_of_range_set_is_ignored() {
431        let mut c = AsciiCanvas::new(1, 1);
432        c.set(100, 100);
433        assert_eq!(c.render(), " ");
434    }
435
436    // ---- render_chart: framing, axis text, autoscale -----------------------------------
437
438    #[test]
439    fn render_chart_frame_dimensions_and_borders() {
440        let pts = [(0.0, 0.0), (1.0, 1.0)];
441        let chart = render_chart(&[("s", &pts)], 6, 2, CanvasStyle::Ascii);
442        let lines: Vec<&str> = chart.lines().collect();
443        // top border + height_cells canvas rows + bottom border
444        assert_eq!(lines.len(), 2 + 2);
445        for line in &lines {
446            assert_eq!(line.chars().count(), 6 + 2);
447        }
448        assert!(lines[0].starts_with('\u{250C}') && lines[0].ends_with('\u{2510}'));
449        assert!(lines[3].starts_with('\u{2514}') && lines[3].ends_with('\u{2518}'));
450        for row in &lines[1..3] {
451            assert!(row.starts_with('\u{2502}') && row.ends_with('\u{2502}'));
452        }
453    }
454
455    #[test]
456    fn render_chart_exact_text_two_point_diagonal_ascii() {
457        // Two points at the extreme corners of a canvas wide enough that the axis-range
458        // border text isn't truncated (22 and 17 characters respectively — see the
459        // hand-counted fill lengths below). width_cells=24 -> 48 dot columns,
460        // height_cells=1 -> 4 dot rows.
461        let pts: [(f64, f64); 2] = [(0.0, 0.0), (10.0, 100.0)];
462        let chart = render_chart(&[("s", &pts)], 24, 1, CanvasStyle::Ascii);
463        // (0.0, 0.0) is the min on both axes -> dot column 0, dot row 3 (bottom).
464        // (10.0, 100.0) is the max on both axes -> dot column 47 (last), dot row 0 (top).
465        // Both dots fall in cell_y=0 (the only row), at cell_x=0 and cell_x=23 (the first
466        // and last of the 24 cells) respectively, so the row is '*' + 22 spaces + '*'.
467        let expected = format!(
468            "\u{250C} s \u{2014} y:[0.00, 100.00] {}\u{2510}\n\
469             \u{2502}*{}*\u{2502}\n\
470             \u{2514} x:[0.00, 10.00] {}\u{2518}",
471            "\u{2500}".repeat(2),
472            " ".repeat(22),
473            "\u{2500}".repeat(7)
474        );
475        assert_eq!(chart, expected);
476    }
477
478    #[test]
479    fn render_chart_exact_text_single_point_braille() {
480        // One point, one cell, width_cells=1 (so the border text is truncated down to a
481        // single character — exercised deliberately here; long_label truncation is covered
482        // separately below). The point itself lands dead-center of the 2x4 dot cell: a
483        // single point always makes both axes degenerate (min==max), which
484        // render_chart expands to +/-0.5 around the value, so the point is always
485        // EXACTLY at the midpoint of both the dot-column and dot-row ranges.
486        // dx = (5.0 - 4.5) / 1.0 * (2-1) = 0.5 -> f64::round rounds half away from zero -> 1
487        // dy = (5.5 - 5.0) / 1.0 * (4-1) = 1.5 -> rounds to 2
488        // set(1, 2) -> cell (0,0), dot bit BRAILLE_BITS[2][1] = 0x20 -> U+2820.
489        let pts: [(f64, f64); 1] = [(5.0, 5.0)];
490        let chart = render_chart(&[("p", &pts)], 1, 1, CanvasStyle::Braille);
491        let expected = "\u{250C} \u{2510}\n\
492                         \u{2502}\u{2820}\u{2502}\n\
493                         \u{2514} \u{2518}";
494        assert_eq!(chart, expected);
495    }
496
497    #[test]
498    fn render_chart_multiple_series_labels_joined_in_border() {
499        let a = [(0.0, 0.0)];
500        let b = [(1.0, 1.0)];
501        let chart = render_chart(&[("alpha", &a), ("beta", &b)], 20, 2, CanvasStyle::Ascii);
502        let top = chart.lines().next().unwrap();
503        assert!(top.contains("alpha, beta"));
504    }
505
506    #[test]
507    fn render_chart_empty_series_still_frames_with_placeholder_range() {
508        // width_cells=24 is wide enough that the 23-character axis-range border text
509        // below isn't truncated (truncation itself is covered separately by
510        // render_chart_long_label_is_truncated_not_panicking).
511        let empty: [(f64, f64); 0] = [];
512        let chart = render_chart(&[("none", &empty)], 24, 1, CanvasStyle::Ascii);
513        let lines: Vec<&str> = chart.lines().collect();
514        assert_eq!(lines.len(), 3);
515        assert!(lines[0].contains("y:[0.00, 1.00]"));
516        assert!(lines[2].contains("x:[0.00, 1.00]"));
517        // No dots set anywhere.
518        assert_eq!(lines[1], format!("\u{2502}{}\u{2502}", " ".repeat(24)));
519    }
520
521    #[test]
522    fn render_chart_ignores_non_finite_points() {
523        // width_cells=24 for the same untruncated-border-text reason as above.
524        let pts: [(f64, f64); 3] = [(0.0, 0.0), (f64::NAN, 1.0), (f64::INFINITY, 2.0)];
525        let chart = render_chart(&[("s", &pts)], 24, 1, CanvasStyle::Ascii);
526        // Only the finite (0.0, 0.0) point survives, so both axes collapse to the
527        // degenerate +/-0.5 expansion around 0.0.
528        assert!(chart.contains("y:[-0.50, 0.50]"));
529        assert!(chart.contains("x:[-0.50, 0.50]"));
530    }
531
532    #[test]
533    fn render_chart_long_label_is_truncated_not_panicking() {
534        let pts = [(0.0, 0.0)];
535        let label = "x".repeat(100);
536        let chart = render_chart(&[(label.as_str(), &pts)], 5, 1, CanvasStyle::Ascii);
537        let top = chart.lines().next().unwrap();
538        assert_eq!(top.chars().count(), 5 + 2);
539    }
540}