const BRAILLE_BITS: [[u8; 2]; 4] = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];
const BRAILLE_BASE: u32 = 0x2800;
trait DotCanvas {
fn width_dots(&self) -> usize;
fn height_dots(&self) -> usize;
fn set(&mut self, x: usize, y: usize);
fn render(&self) -> String;
}
#[derive(Debug, Clone)]
pub struct BrailleCanvas {
width_cells: usize,
height_cells: usize,
cells: Vec<u8>,
}
impl BrailleCanvas {
pub fn new(width_cells: usize, height_cells: usize) -> Self {
Self {
width_cells,
height_cells,
cells: vec![0u8; width_cells * height_cells],
}
}
pub fn width_dots(&self) -> usize {
self.width_cells * 2
}
pub fn height_dots(&self) -> usize {
self.height_cells * 4
}
pub fn set(&mut self, x: usize, y: usize) {
if x >= self.width_dots() || y >= self.height_dots() {
return;
}
let cell_x = x / 2;
let cell_y = y / 4;
let bit = BRAILLE_BITS[y % 4][x % 2];
self.cells[cell_y * self.width_cells + cell_x] |= bit;
}
pub fn render(&self) -> String {
let mut out = String::with_capacity((self.width_cells + 1) * self.height_cells);
for row in 0..self.height_cells {
if row > 0 {
out.push('\n');
}
for col in 0..self.width_cells {
let mask = self.cells[row * self.width_cells + col];
let ch = char::from_u32(BRAILLE_BASE + mask as u32).unwrap_or('?');
out.push(ch);
}
}
out
}
}
impl DotCanvas for BrailleCanvas {
fn width_dots(&self) -> usize {
BrailleCanvas::width_dots(self)
}
fn height_dots(&self) -> usize {
BrailleCanvas::height_dots(self)
}
fn set(&mut self, x: usize, y: usize) {
BrailleCanvas::set(self, x, y)
}
fn render(&self) -> String {
BrailleCanvas::render(self)
}
}
#[derive(Debug, Clone)]
pub struct AsciiCanvas {
width_cells: usize,
height_cells: usize,
cells: Vec<bool>,
}
impl AsciiCanvas {
pub fn new(width_cells: usize, height_cells: usize) -> Self {
Self {
width_cells,
height_cells,
cells: vec![false; width_cells * height_cells],
}
}
pub fn width_dots(&self) -> usize {
self.width_cells * 2
}
pub fn height_dots(&self) -> usize {
self.height_cells * 4
}
pub fn set(&mut self, x: usize, y: usize) {
if x >= self.width_dots() || y >= self.height_dots() {
return;
}
let cell_x = x / 2;
let cell_y = y / 4;
self.cells[cell_y * self.width_cells + cell_x] = true;
}
pub fn render(&self) -> String {
let mut out = String::with_capacity((self.width_cells + 1) * self.height_cells);
for row in 0..self.height_cells {
if row > 0 {
out.push('\n');
}
for col in 0..self.width_cells {
out.push(if self.cells[row * self.width_cells + col] {
'*'
} else {
' '
});
}
}
out
}
}
impl DotCanvas for AsciiCanvas {
fn width_dots(&self) -> usize {
AsciiCanvas::width_dots(self)
}
fn height_dots(&self) -> usize {
AsciiCanvas::height_dots(self)
}
fn set(&mut self, x: usize, y: usize) {
AsciiCanvas::set(self, x, y)
}
fn render(&self) -> String {
AsciiCanvas::render(self)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CanvasStyle {
Braille,
Ascii,
}
fn make_canvas(style: CanvasStyle, width_cells: usize, height_cells: usize) -> Box<dyn DotCanvas> {
match style {
CanvasStyle::Braille => Box::new(BrailleCanvas::new(width_cells, height_cells)),
CanvasStyle::Ascii => Box::new(AsciiCanvas::new(width_cells, height_cells)),
}
}
fn frame_line(left: char, fill: char, right: char, text: &str, width: usize) -> String {
let mut s = String::with_capacity(width + 2);
s.push(left);
let mut used = 0usize;
for c in text.chars() {
if used >= width {
break;
}
s.push(c);
used += 1;
}
for _ in used..width {
s.push(fill);
}
s.push(right);
s
}
pub fn render_chart(
series: &[(&str, &[(f64, f64)])],
width_cells: usize,
height_cells: usize,
style: CanvasStyle,
) -> String {
let finite_points = || {
series
.iter()
.flat_map(|(_, pts)| pts.iter())
.filter(|(x, y)| x.is_finite() && y.is_finite())
};
let (mut xmin, mut xmax) = (f64::INFINITY, f64::NEG_INFINITY);
let (mut ymin, mut ymax) = (f64::INFINITY, f64::NEG_INFINITY);
for &(x, y) in finite_points() {
xmin = xmin.min(x);
xmax = xmax.max(x);
ymin = ymin.min(y);
ymax = ymax.max(y);
}
if !xmin.is_finite() || !xmax.is_finite() {
xmin = 0.0;
xmax = 1.0;
}
if !ymin.is_finite() || !ymax.is_finite() {
ymin = 0.0;
ymax = 1.0;
}
if xmax <= xmin {
xmin -= 0.5;
xmax += 0.5;
}
if ymax <= ymin {
ymin -= 0.5;
ymax += 0.5;
}
let xspan = xmax - xmin;
let yspan = ymax - ymin;
let mut canvas = make_canvas(style, width_cells, height_cells);
let width_dots = canvas.width_dots();
let height_dots = canvas.height_dots();
if width_dots > 0 && height_dots > 0 {
let max_dx = (width_dots - 1) as f64;
let max_dy = (height_dots - 1) as f64;
for &(x, y) in finite_points() {
let dx = ((x - xmin) / xspan * max_dx).round().clamp(0.0, max_dx) as usize;
let dy = ((ymax - y) / yspan * max_dy).round().clamp(0.0, max_dy) as usize;
canvas.set(dx, dy);
}
}
let labels = series
.iter()
.map(|(label, _)| *label)
.collect::<Vec<_>>()
.join(", ");
let top_text = format!(" {} \u{2014} y:[{:.2}, {:.2}] ", labels, ymin, ymax);
let bottom_text = format!(" x:[{:.2}, {:.2}] ", xmin, xmax);
let mut out = String::new();
out.push_str(&frame_line('\u{250C}', '\u{2500}', '\u{2510}', &top_text, width_cells));
out.push('\n');
for line in canvas.render().lines() {
out.push('\u{2502}');
out.push_str(line);
out.push('\u{2502}');
out.push('\n');
}
out.push_str(&frame_line(
'\u{2514}',
'\u{2500}',
'\u{2518}',
&bottom_text,
width_cells,
));
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn braille_blank_cell_is_u2800() {
let c = BrailleCanvas::new(1, 1);
assert_eq!(c.render(), "\u{2800}");
}
#[test]
fn braille_single_dot_top_left() {
let mut c = BrailleCanvas::new(1, 1);
c.set(0, 0); assert_eq!(c.render(), "\u{2801}");
}
#[test]
fn braille_single_dot_bottom_right() {
let mut c = BrailleCanvas::new(1, 1);
c.set(1, 3); assert_eq!(c.render(), "\u{2880}");
}
#[test]
fn braille_full_cell_is_all_eight_dots() {
let mut c = BrailleCanvas::new(1, 1);
for y in 0..4 {
for x in 0..2 {
c.set(x, y);
}
}
assert_eq!(c.render(), "\u{28FF}");
}
#[test]
fn braille_two_cell_row_addresses_columns_independently() {
let mut c = BrailleCanvas::new(2, 1);
c.set(0, 0); c.set(2, 0); assert_eq!(c.render(), "\u{2801}\u{2801}");
}
#[test]
fn braille_out_of_range_set_is_ignored_not_panicking() {
let mut c = BrailleCanvas::new(1, 1);
c.set(99, 99);
c.set(2, 0);
c.set(0, 4);
assert_eq!(c.render(), "\u{2800}");
}
#[test]
fn braille_multirow_multicol_layout() {
let mut c = BrailleCanvas::new(2, 2);
c.set(0, 0); c.set(3, 7); let rendered = c.render();
let lines: Vec<&str> = rendered.lines().collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0], "\u{2801}\u{2800}");
assert_eq!(lines[1], "\u{2800}\u{2880}");
}
#[test]
fn ascii_blank_cell_is_space() {
let c = AsciiCanvas::new(1, 1);
assert_eq!(c.render(), " ");
}
#[test]
fn ascii_any_dot_in_cell_renders_star() {
let mut c = AsciiCanvas::new(1, 1);
c.set(1, 2); assert_eq!(c.render(), "*");
}
#[test]
fn ascii_two_cell_row() {
let mut c = AsciiCanvas::new(3, 1);
c.set(0, 0);
c.set(5, 3); assert_eq!(c.render(), "* *");
}
#[test]
fn ascii_out_of_range_set_is_ignored() {
let mut c = AsciiCanvas::new(1, 1);
c.set(100, 100);
assert_eq!(c.render(), " ");
}
#[test]
fn render_chart_frame_dimensions_and_borders() {
let pts = [(0.0, 0.0), (1.0, 1.0)];
let chart = render_chart(&[("s", &pts)], 6, 2, CanvasStyle::Ascii);
let lines: Vec<&str> = chart.lines().collect();
assert_eq!(lines.len(), 2 + 2);
for line in &lines {
assert_eq!(line.chars().count(), 6 + 2);
}
assert!(lines[0].starts_with('\u{250C}') && lines[0].ends_with('\u{2510}'));
assert!(lines[3].starts_with('\u{2514}') && lines[3].ends_with('\u{2518}'));
for row in &lines[1..3] {
assert!(row.starts_with('\u{2502}') && row.ends_with('\u{2502}'));
}
}
#[test]
fn render_chart_exact_text_two_point_diagonal_ascii() {
let pts: [(f64, f64); 2] = [(0.0, 0.0), (10.0, 100.0)];
let chart = render_chart(&[("s", &pts)], 24, 1, CanvasStyle::Ascii);
let expected = format!(
"\u{250C} s \u{2014} y:[0.00, 100.00] {}\u{2510}\n\
\u{2502}*{}*\u{2502}\n\
\u{2514} x:[0.00, 10.00] {}\u{2518}",
"\u{2500}".repeat(2),
" ".repeat(22),
"\u{2500}".repeat(7)
);
assert_eq!(chart, expected);
}
#[test]
fn render_chart_exact_text_single_point_braille() {
let pts: [(f64, f64); 1] = [(5.0, 5.0)];
let chart = render_chart(&[("p", &pts)], 1, 1, CanvasStyle::Braille);
let expected = "\u{250C} \u{2510}\n\
\u{2502}\u{2820}\u{2502}\n\
\u{2514} \u{2518}";
assert_eq!(chart, expected);
}
#[test]
fn render_chart_multiple_series_labels_joined_in_border() {
let a = [(0.0, 0.0)];
let b = [(1.0, 1.0)];
let chart = render_chart(&[("alpha", &a), ("beta", &b)], 20, 2, CanvasStyle::Ascii);
let top = chart.lines().next().unwrap();
assert!(top.contains("alpha, beta"));
}
#[test]
fn render_chart_empty_series_still_frames_with_placeholder_range() {
let empty: [(f64, f64); 0] = [];
let chart = render_chart(&[("none", &empty)], 24, 1, CanvasStyle::Ascii);
let lines: Vec<&str> = chart.lines().collect();
assert_eq!(lines.len(), 3);
assert!(lines[0].contains("y:[0.00, 1.00]"));
assert!(lines[2].contains("x:[0.00, 1.00]"));
assert_eq!(lines[1], format!("\u{2502}{}\u{2502}", " ".repeat(24)));
}
#[test]
fn render_chart_ignores_non_finite_points() {
let pts: [(f64, f64); 3] = [(0.0, 0.0), (f64::NAN, 1.0), (f64::INFINITY, 2.0)];
let chart = render_chart(&[("s", &pts)], 24, 1, CanvasStyle::Ascii);
assert!(chart.contains("y:[-0.50, 0.50]"));
assert!(chart.contains("x:[-0.50, 0.50]"));
}
#[test]
fn render_chart_long_label_is_truncated_not_panicking() {
let pts = [(0.0, 0.0)];
let label = "x".repeat(100);
let chart = render_chart(&[(label.as_str(), &pts)], 5, 1, CanvasStyle::Ascii);
let top = chart.lines().next().unwrap();
assert_eq!(top.chars().count(), 5 + 2);
}
}