1use shape_value::content::{ChartSpec, ChartType};
10use std::fmt::Write;
11
12const DEFAULT_WIDTH: usize = 60;
14const DEFAULT_HEIGHT: usize = 20;
15
16pub fn render_chart_text(spec: &ChartSpec) -> String {
18 let width = spec.width.unwrap_or(DEFAULT_WIDTH);
19 let height = spec.height.unwrap_or(DEFAULT_HEIGHT);
20
21 match spec.chart_type {
22 ChartType::Line | ChartType::Scatter | ChartType::Area => {
23 render_braille_chart(spec, width, height)
24 }
25 ChartType::Bar | ChartType::Histogram => render_bar_chart(spec, width, height),
26 ChartType::Candlestick => render_candlestick_chart(spec, width, height),
27 _ => render_braille_chart(spec, width, height),
28 }
29}
30
31const BRAILLE_BASE: u32 = 0x2800;
40
41struct BrailleCanvas {
43 char_width: usize,
45 char_height: usize,
47 dots: Vec<Vec<bool>>,
49}
50
51impl BrailleCanvas {
52 fn new(char_width: usize, char_height: usize) -> Self {
53 let dot_rows = char_height * 4;
54 let dot_cols = char_width * 2;
55 Self {
56 char_width,
57 char_height,
58 dots: vec![vec![false; dot_cols]; dot_rows],
59 }
60 }
61
62 fn dot_width(&self) -> usize {
63 self.char_width * 2
64 }
65
66 fn dot_height(&self) -> usize {
67 self.char_height * 4
68 }
69
70 fn set(&mut self, x: usize, y: usize) {
72 if x < self.dot_width() && y < self.dot_height() {
73 self.dots[y][x] = true;
74 }
75 }
76
77 fn line(&mut self, x0: usize, y0: usize, x1: usize, y1: usize) {
79 let (mut x0, mut y0) = (x0 as isize, y0 as isize);
80 let (x1, y1) = (x1 as isize, y1 as isize);
81 let dx = (x1 - x0).abs();
82 let dy = -(y1 - y0).abs();
83 let sx = if x0 < x1 { 1 } else { -1 };
84 let sy = if y0 < y1 { 1 } else { -1 };
85 let mut err = dx + dy;
86
87 loop {
88 if x0 >= 0 && y0 >= 0 {
89 self.set(x0 as usize, y0 as usize);
90 }
91 if x0 == x1 && y0 == y1 {
92 break;
93 }
94 let e2 = 2 * err;
95 if e2 >= dy {
96 err += dy;
97 x0 += sx;
98 }
99 if e2 <= dx {
100 err += dx;
101 y0 += sy;
102 }
103 }
104 }
105
106 fn render(&self) -> String {
108 let mut out = String::new();
109 for cy in 0..self.char_height {
110 for cx in 0..self.char_width {
111 let mut code: u32 = 0;
112 let dx = cx * 2;
113 let dy = cy * 4;
114 if self.dot_at(dx, dy) {
116 code |= 1 << 0;
117 }
118 if self.dot_at(dx, dy + 1) {
119 code |= 1 << 1;
120 }
121 if self.dot_at(dx, dy + 2) {
122 code |= 1 << 2;
123 }
124 if self.dot_at(dx + 1, dy) {
125 code |= 1 << 3;
126 }
127 if self.dot_at(dx + 1, dy + 1) {
128 code |= 1 << 4;
129 }
130 if self.dot_at(dx + 1, dy + 2) {
131 code |= 1 << 5;
132 }
133 if self.dot_at(dx, dy + 3) {
134 code |= 1 << 6;
135 }
136 if self.dot_at(dx + 1, dy + 3) {
137 code |= 1 << 7;
138 }
139 if let Some(ch) = char::from_u32(BRAILLE_BASE + code) {
140 out.push(ch);
141 }
142 }
143 out.push('\n');
144 }
145 out
146 }
147
148 fn dot_at(&self, x: usize, y: usize) -> bool {
149 if x < self.dot_width() && y < self.dot_height() {
150 self.dots[y][x]
151 } else {
152 false
153 }
154 }
155}
156
157fn render_braille_chart(spec: &ChartSpec, width: usize, height: usize) -> String {
158 let x_chan = spec.channel("x");
159 let y_channels = spec.channels_by_name("y");
160 if y_channels.is_empty() {
161 return chart_placeholder(spec);
162 }
163
164 let label_width = 8;
166 let chart_char_width = width.saturating_sub(label_width + 1);
167 let chart_char_height = height.saturating_sub(2); if chart_char_width < 4 || chart_char_height < 2 {
170 return chart_placeholder(spec);
171 }
172
173 let mut canvas = BrailleCanvas::new(chart_char_width, chart_char_height);
174
175 let (y_min, y_max) = {
177 let mut min = f64::INFINITY;
178 let mut max = f64::NEG_INFINITY;
179 for ch in &y_channels {
180 for &v in &ch.values {
181 if v.is_finite() {
182 min = min.min(v);
183 max = max.max(v);
184 }
185 }
186 }
187 if min == max {
188 (min - 1.0, max + 1.0)
189 } else {
190 (min, max)
191 }
192 };
193
194 let dot_w = canvas.dot_width();
195 let dot_h = canvas.dot_height();
196
197 for ch in &y_channels {
198 let n = ch.values.len();
199 if n == 0 {
200 continue;
201 }
202
203 let x_values: Vec<f64> = if let Some(xc) = &x_chan {
204 xc.values.clone()
205 } else {
206 (0..n).map(|i| i as f64).collect()
207 };
208
209 let points: Vec<(usize, usize)> = x_values
210 .iter()
211 .zip(ch.values.iter())
212 .filter(|(_, y)| y.is_finite())
213 .map(|(x, y)| {
214 let x_min = x_values
215 .iter()
216 .copied()
217 .fold(f64::INFINITY, f64::min);
218 let x_max = x_values
219 .iter()
220 .copied()
221 .fold(f64::NEG_INFINITY, f64::max);
222 let x_range = if (x_max - x_min).abs() < f64::EPSILON {
223 1.0
224 } else {
225 x_max - x_min
226 };
227 let px = ((x - x_min) / x_range * (dot_w - 1) as f64) as usize;
228 let py = ((y_max - y) / (y_max - y_min) * (dot_h - 1) as f64) as usize;
229 (px.min(dot_w - 1), py.min(dot_h - 1))
230 })
231 .collect();
232
233 match spec.chart_type {
234 ChartType::Scatter => {
235 for &(px, py) in &points {
236 canvas.set(px, py);
237 }
238 }
239 _ => {
240 for pair in points.windows(2) {
242 canvas.line(pair[0].0, pair[0].1, pair[1].0, pair[1].1);
243 }
244 }
245 }
246 }
247
248 let mut out = String::new();
249
250 if let Some(ref title) = spec.title {
252 let _ = writeln!(out, " {}", title);
253 }
254
255 let braille_lines: Vec<&str> = canvas.render().lines().map(|l| l).collect();
257 let rendered = canvas.render();
259 let braille_lines: Vec<&str> = rendered.lines().collect();
260
261 for (i, line) in braille_lines.iter().enumerate() {
262 let label = if i == 0 {
264 format!("{:>7.1}", y_max)
265 } else if i == braille_lines.len() / 2 {
266 format!("{:>7.1}", (y_min + y_max) / 2.0)
267 } else if i == braille_lines.len() - 1 {
268 format!("{:>7.1}", y_min)
269 } else {
270 " ".to_string()
271 };
272 let _ = writeln!(out, "{} {}", label, line);
273 }
274
275 out
276}
277
278const BLOCK_CHARS: [char; 8] = [
282 '\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', '\u{2588}', ];
291
292fn render_bar_chart(spec: &ChartSpec, width: usize, height: usize) -> String {
293 let y_channels = spec.channels_by_name("y");
294 if y_channels.is_empty() {
295 return chart_placeholder(spec);
296 }
297
298 let values = &y_channels[0].values;
299 if values.is_empty() {
300 return chart_placeholder(spec);
301 }
302
303 let chart_height = height.saturating_sub(3); if chart_height < 2 {
305 return chart_placeholder(spec);
306 }
307
308 let y_max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
309 let y_min = 0.0_f64; let mut out = String::new();
312
313 if let Some(ref title) = spec.title {
315 let _ = writeln!(out, " {}", title);
316 }
317
318 let bar_count = values.len();
320 let available = width.saturating_sub(2);
321 let bar_width = (available / bar_count).max(1).min(4);
322 let gap = if bar_width > 1 { 1 } else { 0 };
323
324 for row in 0..chart_height {
326 let threshold_top = y_max - (y_max - y_min) * row as f64 / chart_height as f64;
327 let threshold_bot = y_max - (y_max - y_min) * (row + 1) as f64 / chart_height as f64;
328
329 let _ = write!(out, " ");
330 for (i, &val) in values.iter().enumerate() {
331 if i > 0 && gap > 0 {
332 out.push(' ');
333 }
334 for _ in 0..bar_width {
335 if val >= threshold_top {
336 out.push(BLOCK_CHARS[7]); } else if val > threshold_bot {
338 let frac = (val - threshold_bot) / (threshold_top - threshold_bot);
340 let idx = (frac * 7.0) as usize;
341 out.push(BLOCK_CHARS[idx.min(7)]);
342 } else {
343 out.push(' ');
344 }
345 }
346 }
347 let _ = writeln!(out);
348 }
349
350 if let Some(ref cats) = spec.x_categories {
352 let _ = write!(out, " ");
353 for (i, cat) in cats.iter().enumerate() {
354 if i > 0 && gap > 0 {
355 out.push(' ');
356 }
357 let label: String = cat.chars().take(bar_width).collect();
358 let _ = write!(out, "{:width$}", label, width = bar_width);
359 }
360 let _ = writeln!(out);
361 }
362
363 out
364}
365
366fn render_candlestick_chart(spec: &ChartSpec, width: usize, height: usize) -> String {
369 let open = spec.channel("open");
370 let high = spec.channel("high");
371 let low = spec.channel("low");
372 let close = spec.channel("close");
373
374 let (open, high, low, close) = match (open, high, low, close) {
375 (Some(o), Some(h), Some(l), Some(c)) => (o, h, l, c),
376 _ => return chart_placeholder(spec),
377 };
378
379 let n = open
380 .values
381 .len()
382 .min(high.values.len())
383 .min(low.values.len())
384 .min(close.values.len());
385 if n == 0 {
386 return chart_placeholder(spec);
387 }
388
389 let chart_height = height.saturating_sub(2);
390 if chart_height < 4 {
391 return chart_placeholder(spec);
392 }
393
394 let price_min = low
396 .values
397 .iter()
398 .take(n)
399 .copied()
400 .fold(f64::INFINITY, f64::min);
401 let price_max = high
402 .values
403 .iter()
404 .take(n)
405 .copied()
406 .fold(f64::NEG_INFINITY, f64::max);
407 let price_range = if (price_max - price_min).abs() < f64::EPSILON {
408 1.0
409 } else {
410 price_max - price_min
411 };
412
413 let available_cols = width.saturating_sub(10); let candle_width = (available_cols / n).max(1).min(3);
415
416 let mut out = String::new();
417 if let Some(ref title) = spec.title {
418 let _ = writeln!(out, " {}", title);
419 }
420
421 for row in 0..chart_height {
423 let row_price_top = price_max - price_range * row as f64 / chart_height as f64;
424 let row_price_bot = price_max - price_range * (row + 1) as f64 / chart_height as f64;
425
426 if row == 0 {
428 let _ = write!(out, "{:>8.1} ", price_max);
429 } else if row == chart_height - 1 {
430 let _ = write!(out, "{:>8.1} ", price_min);
431 } else {
432 let _ = write!(out, " ");
433 }
434
435 for i in 0..n {
436 let o = open.values[i];
437 let h = high.values[i];
438 let l = low.values[i];
439 let c = close.values[i];
440 let body_top = o.max(c);
441 let body_bot = o.min(c);
442
443 for col in 0..candle_width {
444 let is_center = col == candle_width / 2;
445 if row_price_top >= l && row_price_bot <= h {
447 if row_price_bot <= body_top && row_price_top >= body_bot {
448 if c >= o {
450 out.push('█'); } else {
452 out.push('▒'); }
454 } else if is_center {
455 out.push('│'); } else {
457 out.push(' ');
458 }
459 } else {
460 out.push(' ');
461 }
462 }
463 }
464 let _ = writeln!(out);
465 }
466
467 out
468}
469
470fn chart_placeholder(spec: &ChartSpec) -> String {
471 let title = spec.title.as_deref().unwrap_or("untitled");
472 let type_name = match spec.chart_type {
473 ChartType::Line => "Line",
474 ChartType::Bar => "Bar",
475 ChartType::Scatter => "Scatter",
476 ChartType::Area => "Area",
477 ChartType::Candlestick => "Candlestick",
478 ChartType::Histogram => "Histogram",
479 ChartType::BoxPlot => "BoxPlot",
480 ChartType::Heatmap => "Heatmap",
481 ChartType::Bubble => "Bubble",
482 };
483 let y_count = spec.channels_by_name("y").len();
484 format!("[{} Chart: {} ({} series)]\n", type_name, title, y_count)
485}
486
487#[cfg(test)]
488mod tests {
489 use super::*;
490 use shape_value::content::ChartChannel;
491
492 #[test]
493 fn test_braille_canvas_basic() {
494 let mut canvas = BrailleCanvas::new(4, 2);
495 canvas.set(0, 0);
496 canvas.set(1, 0);
497 let output = canvas.render();
498 assert!(!output.is_empty());
499 for ch in output.chars() {
501 if ch != '\n' {
502 assert!(ch as u32 >= BRAILLE_BASE);
503 }
504 }
505 }
506
507 #[test]
508 fn test_braille_line_chart() {
509 let spec = ChartSpec {
510 chart_type: ChartType::Line,
511 channels: vec![
512 ChartChannel {
513 name: "x".into(),
514 label: "X".into(),
515 values: vec![0.0, 1.0, 2.0, 3.0, 4.0],
516 color: None,
517 },
518 ChartChannel {
519 name: "y".into(),
520 label: "Y".into(),
521 values: vec![1.0, 4.0, 2.0, 5.0, 3.0],
522 color: None,
523 },
524 ],
525 x_categories: None,
526 title: Some("Test Line".into()),
527 x_label: None,
528 y_label: None,
529 width: Some(40),
530 height: Some(10),
531 echarts_options: None,
532 interactive: false,
533 };
534 let output = render_chart_text(&spec);
535 assert!(output.contains("Test Line"));
536 assert!(output.chars().any(|c| c as u32 >= BRAILLE_BASE && c as u32 <= BRAILLE_BASE + 0xFF));
538 }
539
540 #[test]
541 fn test_bar_chart() {
542 let spec = ChartSpec {
543 chart_type: ChartType::Bar,
544 channels: vec![ChartChannel {
545 name: "y".into(),
546 label: "Sales".into(),
547 values: vec![10.0, 25.0, 15.0, 30.0],
548 color: None,
549 }],
550 x_categories: Some(vec!["Q1".into(), "Q2".into(), "Q3".into(), "Q4".into()]),
551 title: Some("Quarterly Sales".into()),
552 x_label: None,
553 y_label: None,
554 width: Some(30),
555 height: Some(10),
556 echarts_options: None,
557 interactive: false,
558 };
559 let output = render_chart_text(&spec);
560 assert!(output.contains("Quarterly Sales"));
561 assert!(output.chars().any(|c| BLOCK_CHARS.contains(&c)));
563 }
564
565 #[test]
566 fn test_scatter_chart() {
567 let spec = ChartSpec {
568 chart_type: ChartType::Scatter,
569 channels: vec![
570 ChartChannel {
571 name: "x".into(),
572 label: "X".into(),
573 values: vec![1.0, 2.0, 3.0],
574 color: None,
575 },
576 ChartChannel {
577 name: "y".into(),
578 label: "Y".into(),
579 values: vec![2.0, 4.0, 1.0],
580 color: None,
581 },
582 ],
583 x_categories: None,
584 title: Some("Scatter".into()),
585 x_label: None,
586 y_label: None,
587 width: Some(30),
588 height: Some(8),
589 echarts_options: None,
590 interactive: false,
591 };
592 let output = render_chart_text(&spec);
593 assert!(output.contains("Scatter"));
594 }
595
596 #[test]
597 fn test_empty_chart_fallback() {
598 let spec = ChartSpec {
599 chart_type: ChartType::Line,
600 channels: vec![],
601 x_categories: None,
602 title: Some("Empty".into()),
603 x_label: None,
604 y_label: None,
605 width: None,
606 height: None,
607 echarts_options: None,
608 interactive: false,
609 };
610 let output = render_chart_text(&spec);
611 assert!(output.contains("[Line Chart: Empty (0 series)]"));
612 }
613
614 #[test]
615 fn test_candlestick_chart() {
616 let spec = ChartSpec {
617 chart_type: ChartType::Candlestick,
618 channels: vec![
619 ChartChannel {
620 name: "open".into(),
621 label: "Open".into(),
622 values: vec![100.0, 105.0, 102.0],
623 color: None,
624 },
625 ChartChannel {
626 name: "high".into(),
627 label: "High".into(),
628 values: vec![110.0, 112.0, 108.0],
629 color: None,
630 },
631 ChartChannel {
632 name: "low".into(),
633 label: "Low".into(),
634 values: vec![95.0, 100.0, 98.0],
635 color: None,
636 },
637 ChartChannel {
638 name: "close".into(),
639 label: "Close".into(),
640 values: vec![105.0, 102.0, 106.0],
641 color: None,
642 },
643 ],
644 x_categories: None,
645 title: Some("OHLC".into()),
646 x_label: None,
647 y_label: None,
648 width: Some(30),
649 height: Some(12),
650 echarts_options: None,
651 interactive: false,
652 };
653 let output = render_chart_text(&spec);
654 assert!(output.contains("OHLC"));
655 }
656}