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(
324        '\u{250C}',
325        '\u{2500}',
326        '\u{2510}',
327        &top_text,
328        width_cells,
329    ));
330    out.push('\n');
331    for line in canvas.render().lines() {
332        out.push('\u{2502}');
333        out.push_str(line);
334        out.push('\u{2502}');
335        out.push('\n');
336    }
337    out.push_str(&frame_line(
338        '\u{2514}',
339        '\u{2500}',
340        '\u{2518}',
341        &bottom_text,
342        width_cells,
343    ));
344    out
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    // ---- Canvas primitives -------------------------------------------------------------
352
353    #[test]
354    fn braille_blank_cell_is_u2800() {
355        let c = BrailleCanvas::new(1, 1);
356        assert_eq!(c.render(), "\u{2800}");
357    }
358
359    #[test]
360    fn braille_single_dot_top_left() {
361        let mut c = BrailleCanvas::new(1, 1);
362        c.set(0, 0); // dot 1 -> bit 0x01
363        assert_eq!(c.render(), "\u{2801}");
364    }
365
366    #[test]
367    fn braille_single_dot_bottom_right() {
368        let mut c = BrailleCanvas::new(1, 1);
369        c.set(1, 3); // dot 8 -> bit 0x80
370        assert_eq!(c.render(), "\u{2880}");
371    }
372
373    #[test]
374    fn braille_full_cell_is_all_eight_dots() {
375        let mut c = BrailleCanvas::new(1, 1);
376        for y in 0..4 {
377            for x in 0..2 {
378                c.set(x, y);
379            }
380        }
381        // 0xFF -> U+28FF, the last codepoint in the braille block.
382        assert_eq!(c.render(), "\u{28FF}");
383    }
384
385    #[test]
386    fn braille_two_cell_row_addresses_columns_independently() {
387        let mut c = BrailleCanvas::new(2, 1);
388        c.set(0, 0); // left cell, dot 1
389        c.set(2, 0); // right cell (x=2 is dot-column 0 of cell 1), dot 1
390        assert_eq!(c.render(), "\u{2801}\u{2801}");
391    }
392
393    #[test]
394    fn braille_out_of_range_set_is_ignored_not_panicking() {
395        let mut c = BrailleCanvas::new(1, 1);
396        c.set(99, 99);
397        c.set(2, 0);
398        c.set(0, 4);
399        assert_eq!(c.render(), "\u{2800}");
400    }
401
402    #[test]
403    fn braille_multirow_multicol_layout() {
404        let mut c = BrailleCanvas::new(2, 2);
405        c.set(0, 0); // cell (0,0), dot 1
406        c.set(3, 7); // cell (1,1), dot 8 (x=3 -> col 1 of cell 1; y=7 -> row 3 of cell 1)
407        let rendered = c.render();
408        let lines: Vec<&str> = rendered.lines().collect();
409        assert_eq!(lines.len(), 2);
410        assert_eq!(lines[0], "\u{2801}\u{2800}");
411        assert_eq!(lines[1], "\u{2800}\u{2880}");
412    }
413
414    #[test]
415    fn ascii_blank_cell_is_space() {
416        let c = AsciiCanvas::new(1, 1);
417        assert_eq!(c.render(), " ");
418    }
419
420    #[test]
421    fn ascii_any_dot_in_cell_renders_star() {
422        let mut c = AsciiCanvas::new(1, 1);
423        c.set(1, 2); // a single dot anywhere in the cell is enough
424        assert_eq!(c.render(), "*");
425    }
426
427    #[test]
428    fn ascii_two_cell_row() {
429        let mut c = AsciiCanvas::new(3, 1);
430        c.set(0, 0);
431        c.set(5, 3); // cell 2 (x=5 -> col 5/2=2)
432        assert_eq!(c.render(), "* *");
433    }
434
435    #[test]
436    fn ascii_out_of_range_set_is_ignored() {
437        let mut c = AsciiCanvas::new(1, 1);
438        c.set(100, 100);
439        assert_eq!(c.render(), " ");
440    }
441
442    // ---- render_chart: framing, axis text, autoscale -----------------------------------
443
444    #[test]
445    fn render_chart_frame_dimensions_and_borders() {
446        let pts = [(0.0, 0.0), (1.0, 1.0)];
447        let chart = render_chart(&[("s", &pts)], 6, 2, CanvasStyle::Ascii);
448        let lines: Vec<&str> = chart.lines().collect();
449        // top border + height_cells canvas rows + bottom border
450        assert_eq!(lines.len(), 2 + 2);
451        for line in &lines {
452            assert_eq!(line.chars().count(), 6 + 2);
453        }
454        assert!(lines[0].starts_with('\u{250C}') && lines[0].ends_with('\u{2510}'));
455        assert!(lines[3].starts_with('\u{2514}') && lines[3].ends_with('\u{2518}'));
456        for row in &lines[1..3] {
457            assert!(row.starts_with('\u{2502}') && row.ends_with('\u{2502}'));
458        }
459    }
460
461    #[test]
462    fn render_chart_exact_text_two_point_diagonal_ascii() {
463        // Two points at the extreme corners of a canvas wide enough that the axis-range
464        // border text isn't truncated (22 and 17 characters respectively — see the
465        // hand-counted fill lengths below). width_cells=24 -> 48 dot columns,
466        // height_cells=1 -> 4 dot rows.
467        let pts: [(f64, f64); 2] = [(0.0, 0.0), (10.0, 100.0)];
468        let chart = render_chart(&[("s", &pts)], 24, 1, CanvasStyle::Ascii);
469        // (0.0, 0.0) is the min on both axes -> dot column 0, dot row 3 (bottom).
470        // (10.0, 100.0) is the max on both axes -> dot column 47 (last), dot row 0 (top).
471        // Both dots fall in cell_y=0 (the only row), at cell_x=0 and cell_x=23 (the first
472        // and last of the 24 cells) respectively, so the row is '*' + 22 spaces + '*'.
473        let expected = format!(
474            "\u{250C} s \u{2014} y:[0.00, 100.00] {}\u{2510}\n\
475             \u{2502}*{}*\u{2502}\n\
476             \u{2514} x:[0.00, 10.00] {}\u{2518}",
477            "\u{2500}".repeat(2),
478            " ".repeat(22),
479            "\u{2500}".repeat(7)
480        );
481        assert_eq!(chart, expected);
482    }
483
484    #[test]
485    fn render_chart_exact_text_single_point_braille() {
486        // One point, one cell, width_cells=1 (so the border text is truncated down to a
487        // single character — exercised deliberately here; long_label truncation is covered
488        // separately below). The point itself lands dead-center of the 2x4 dot cell: a
489        // single point always makes both axes degenerate (min==max), which
490        // render_chart expands to +/-0.5 around the value, so the point is always
491        // EXACTLY at the midpoint of both the dot-column and dot-row ranges.
492        // dx = (5.0 - 4.5) / 1.0 * (2-1) = 0.5 -> f64::round rounds half away from zero -> 1
493        // dy = (5.5 - 5.0) / 1.0 * (4-1) = 1.5 -> rounds to 2
494        // set(1, 2) -> cell (0,0), dot bit BRAILLE_BITS[2][1] = 0x20 -> U+2820.
495        let pts: [(f64, f64); 1] = [(5.0, 5.0)];
496        let chart = render_chart(&[("p", &pts)], 1, 1, CanvasStyle::Braille);
497        let expected = "\u{250C} \u{2510}\n\
498                         \u{2502}\u{2820}\u{2502}\n\
499                         \u{2514} \u{2518}";
500        assert_eq!(chart, expected);
501    }
502
503    #[test]
504    fn render_chart_multiple_series_labels_joined_in_border() {
505        let a = [(0.0, 0.0)];
506        let b = [(1.0, 1.0)];
507        let chart = render_chart(&[("alpha", &a), ("beta", &b)], 20, 2, CanvasStyle::Ascii);
508        let top = chart.lines().next().unwrap();
509        assert!(top.contains("alpha, beta"));
510    }
511
512    #[test]
513    fn render_chart_empty_series_still_frames_with_placeholder_range() {
514        // width_cells=24 is wide enough that the 23-character axis-range border text
515        // below isn't truncated (truncation itself is covered separately by
516        // render_chart_long_label_is_truncated_not_panicking).
517        let empty: [(f64, f64); 0] = [];
518        let chart = render_chart(&[("none", &empty)], 24, 1, CanvasStyle::Ascii);
519        let lines: Vec<&str> = chart.lines().collect();
520        assert_eq!(lines.len(), 3);
521        assert!(lines[0].contains("y:[0.00, 1.00]"));
522        assert!(lines[2].contains("x:[0.00, 1.00]"));
523        // No dots set anywhere.
524        assert_eq!(lines[1], format!("\u{2502}{}\u{2502}", " ".repeat(24)));
525    }
526
527    #[test]
528    fn render_chart_ignores_non_finite_points() {
529        // width_cells=24 for the same untruncated-border-text reason as above.
530        let pts: [(f64, f64); 3] = [(0.0, 0.0), (f64::NAN, 1.0), (f64::INFINITY, 2.0)];
531        let chart = render_chart(&[("s", &pts)], 24, 1, CanvasStyle::Ascii);
532        // Only the finite (0.0, 0.0) point survives, so both axes collapse to the
533        // degenerate +/-0.5 expansion around 0.0.
534        assert!(chart.contains("y:[-0.50, 0.50]"));
535        assert!(chart.contains("x:[-0.50, 0.50]"));
536    }
537
538    #[test]
539    fn render_chart_long_label_is_truncated_not_panicking() {
540        let pts = [(0.0, 0.0)];
541        let label = "x".repeat(100);
542        let chart = render_chart(&[(label.as_str(), &pts)], 5, 1, CanvasStyle::Ascii);
543        let top = chart.lines().next().unwrap();
544        assert_eq!(top.chars().count(), 5 + 2);
545    }
546}