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(
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 #[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); 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); 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 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); c.set(2, 0); 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); c.set(3, 7); 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); 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); 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 #[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 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 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 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 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 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 assert_eq!(lines[1], format!("\u{2502}{}\u{2502}", " ".repeat(24)));
525 }
526
527 #[test]
528 fn render_chart_ignores_non_finite_points() {
529 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 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}