1const BRAILLE_BITS: [[u8; 2]; 4] = [[0x01, 0x08], [0x02, 0x10], [0x04, 0x20], [0x40, 0x80]];
38
39const BRAILLE_BASE: u32 = 0x2800;
42
43trait DotCanvas {
48 fn width_dots(&self) -> usize;
49 fn height_dots(&self) -> usize;
50 fn set(&mut self, x: usize, y: usize);
53 fn render(&self) -> String;
56}
57
58#[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 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 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 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#[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#[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
215fn 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
236pub 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 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 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 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 #[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); 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); 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 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); c.set(2, 0); 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); c.set(3, 7); 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); 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); 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 #[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 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 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 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 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 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 assert_eq!(lines[1], format!("\u{2502}{}\u{2502}", " ".repeat(24)));
519 }
520
521 #[test]
522 fn render_chart_ignores_non_finite_points() {
523 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 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}