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.iter().copied().fold(f64::INFINITY, f64::min);
215 let x_max = x_values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
216 let x_range = if (x_max - x_min).abs() < f64::EPSILON {
217 1.0
218 } else {
219 x_max - x_min
220 };
221 let px = ((x - x_min) / x_range * (dot_w - 1) as f64) as usize;
222 let py = ((y_max - y) / (y_max - y_min) * (dot_h - 1) as f64) as usize;
223 (px.min(dot_w - 1), py.min(dot_h - 1))
224 })
225 .collect();
226
227 match spec.chart_type {
228 ChartType::Scatter => {
229 for &(px, py) in &points {
230 canvas.set(px, py);
231 }
232 }
233 _ => {
234 for pair in points.windows(2) {
236 canvas.line(pair[0].0, pair[0].1, pair[1].0, pair[1].1);
237 }
238 }
239 }
240 }
241
242 let mut out = String::new();
243
244 if let Some(ref title) = spec.title {
246 let _ = writeln!(out, " {}", title);
247 }
248
249 let rendered = canvas.render();
252 let braille_lines: Vec<&str> = rendered.lines().collect();
253
254 for (i, line) in braille_lines.iter().enumerate() {
255 let label = if i == 0 {
257 format!("{:>7.1}", y_max)
258 } else if i == braille_lines.len() / 2 {
259 format!("{:>7.1}", (y_min + y_max) / 2.0)
260 } else if i == braille_lines.len() - 1 {
261 format!("{:>7.1}", y_min)
262 } else {
263 " ".to_string()
264 };
265 let _ = writeln!(out, "{} {}", label, line);
266 }
267
268 out
269}
270
271const BLOCK_CHARS: [char; 8] = [
275 '\u{2581}', '\u{2582}', '\u{2583}', '\u{2584}', '\u{2585}', '\u{2586}', '\u{2587}', '\u{2588}', ];
284
285fn render_bar_chart(spec: &ChartSpec, width: usize, height: usize) -> String {
286 let y_channels = spec.channels_by_name("y");
287 if y_channels.is_empty() {
288 return chart_placeholder(spec);
289 }
290
291 let values = &y_channels[0].values;
292 if values.is_empty() {
293 return chart_placeholder(spec);
294 }
295
296 let chart_height = height.saturating_sub(3); if chart_height < 2 {
298 return chart_placeholder(spec);
299 }
300
301 let y_max = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
302 let y_min = 0.0_f64; let mut out = String::new();
305
306 if let Some(ref title) = spec.title {
308 let _ = writeln!(out, " {}", title);
309 }
310
311 let bar_count = values.len();
313 let available = width.saturating_sub(2);
314 let bar_width = (available / bar_count).max(1).min(4);
315 let gap = if bar_width > 1 { 1 } else { 0 };
316
317 for row in 0..chart_height {
319 let threshold_top = y_max - (y_max - y_min) * row as f64 / chart_height as f64;
320 let threshold_bot = y_max - (y_max - y_min) * (row + 1) as f64 / chart_height as f64;
321
322 let _ = write!(out, " ");
323 for (i, &val) in values.iter().enumerate() {
324 if i > 0 && gap > 0 {
325 out.push(' ');
326 }
327 for _ in 0..bar_width {
328 if val >= threshold_top {
329 out.push(BLOCK_CHARS[7]); } else if val > threshold_bot {
331 let frac = (val - threshold_bot) / (threshold_top - threshold_bot);
333 let idx = (frac * 7.0) as usize;
334 out.push(BLOCK_CHARS[idx.min(7)]);
335 } else {
336 out.push(' ');
337 }
338 }
339 }
340 let _ = writeln!(out);
341 }
342
343 if let Some(ref cats) = spec.x_categories {
345 let _ = write!(out, " ");
346 for (i, cat) in cats.iter().enumerate() {
347 if i > 0 && gap > 0 {
348 out.push(' ');
349 }
350 let label: String = cat.chars().take(bar_width).collect();
351 let _ = write!(out, "{:width$}", label, width = bar_width);
352 }
353 let _ = writeln!(out);
354 }
355
356 out
357}
358
359fn render_candlestick_chart(spec: &ChartSpec, width: usize, height: usize) -> String {
362 let open = spec.channel("open");
363 let high = spec.channel("high");
364 let low = spec.channel("low");
365 let close = spec.channel("close");
366
367 let (open, high, low, close) = match (open, high, low, close) {
368 (Some(o), Some(h), Some(l), Some(c)) => (o, h, l, c),
369 _ => return chart_placeholder(spec),
370 };
371
372 let n = open
373 .values
374 .len()
375 .min(high.values.len())
376 .min(low.values.len())
377 .min(close.values.len());
378 if n == 0 {
379 return chart_placeholder(spec);
380 }
381
382 let chart_height = height.saturating_sub(2);
383 if chart_height < 4 {
384 return chart_placeholder(spec);
385 }
386
387 let price_min = low
389 .values
390 .iter()
391 .take(n)
392 .copied()
393 .fold(f64::INFINITY, f64::min);
394 let price_max = high
395 .values
396 .iter()
397 .take(n)
398 .copied()
399 .fold(f64::NEG_INFINITY, f64::max);
400 let price_range = if (price_max - price_min).abs() < f64::EPSILON {
401 1.0
402 } else {
403 price_max - price_min
404 };
405
406 let available_cols = width.saturating_sub(10); let candle_width = (available_cols / n).max(1).min(3);
408
409 let mut out = String::new();
410 if let Some(ref title) = spec.title {
411 let _ = writeln!(out, " {}", title);
412 }
413
414 for row in 0..chart_height {
416 let row_price_top = price_max - price_range * row as f64 / chart_height as f64;
417 let row_price_bot = price_max - price_range * (row + 1) as f64 / chart_height as f64;
418
419 if row == 0 {
421 let _ = write!(out, "{:>8.1} ", price_max);
422 } else if row == chart_height - 1 {
423 let _ = write!(out, "{:>8.1} ", price_min);
424 } else {
425 let _ = write!(out, " ");
426 }
427
428 for i in 0..n {
429 let o = open.values[i];
430 let h = high.values[i];
431 let l = low.values[i];
432 let c = close.values[i];
433 let body_top = o.max(c);
434 let body_bot = o.min(c);
435
436 for col in 0..candle_width {
437 let is_center = col == candle_width / 2;
438 if row_price_top >= l && row_price_bot <= h {
440 if row_price_bot <= body_top && row_price_top >= body_bot {
441 if c >= o {
443 out.push('█'); } else {
445 out.push('▒'); }
447 } else if is_center {
448 out.push('│'); } else {
450 out.push(' ');
451 }
452 } else {
453 out.push(' ');
454 }
455 }
456 }
457 let _ = writeln!(out);
458 }
459
460 out
461}
462
463fn chart_placeholder(spec: &ChartSpec) -> String {
464 let title = spec.title.as_deref().unwrap_or("untitled");
465 let type_name = match spec.chart_type {
466 ChartType::Line => "Line",
467 ChartType::Bar => "Bar",
468 ChartType::Scatter => "Scatter",
469 ChartType::Area => "Area",
470 ChartType::Candlestick => "Candlestick",
471 ChartType::Histogram => "Histogram",
472 ChartType::BoxPlot => "BoxPlot",
473 ChartType::Heatmap => "Heatmap",
474 ChartType::Bubble => "Bubble",
475 };
476 let y_count = spec.channels_by_name("y").len();
477 format!("[{} Chart: {} ({} series)]\n", type_name, title, y_count)
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use shape_value::content::ChartChannel;
484
485 #[test]
486 fn test_braille_canvas_basic() {
487 let mut canvas = BrailleCanvas::new(4, 2);
488 canvas.set(0, 0);
489 canvas.set(1, 0);
490 let output = canvas.render();
491 assert!(!output.is_empty());
492 for ch in output.chars() {
494 if ch != '\n' {
495 assert!(ch as u32 >= BRAILLE_BASE);
496 }
497 }
498 }
499
500 #[test]
501 fn test_braille_line_chart() {
502 let spec = ChartSpec {
503 chart_type: ChartType::Line,
504 channels: vec![
505 ChartChannel {
506 name: "x".into(),
507 label: "X".into(),
508 values: vec![0.0, 1.0, 2.0, 3.0, 4.0],
509 color: None,
510 },
511 ChartChannel {
512 name: "y".into(),
513 label: "Y".into(),
514 values: vec![1.0, 4.0, 2.0, 5.0, 3.0],
515 color: None,
516 },
517 ],
518 x_categories: None,
519 title: Some("Test Line".into()),
520 x_label: None,
521 y_label: None,
522 width: Some(40),
523 height: Some(10),
524 echarts_options: None,
525 interactive: false,
526 };
527 let output = render_chart_text(&spec);
528 assert!(output.contains("Test Line"));
529 assert!(
531 output
532 .chars()
533 .any(|c| c as u32 >= BRAILLE_BASE && c as u32 <= BRAILLE_BASE + 0xFF)
534 );
535 }
536
537 #[test]
538 fn test_bar_chart() {
539 let spec = ChartSpec {
540 chart_type: ChartType::Bar,
541 channels: vec![ChartChannel {
542 name: "y".into(),
543 label: "Sales".into(),
544 values: vec![10.0, 25.0, 15.0, 30.0],
545 color: None,
546 }],
547 x_categories: Some(vec!["Q1".into(), "Q2".into(), "Q3".into(), "Q4".into()]),
548 title: Some("Quarterly Sales".into()),
549 x_label: None,
550 y_label: None,
551 width: Some(30),
552 height: Some(10),
553 echarts_options: None,
554 interactive: false,
555 };
556 let output = render_chart_text(&spec);
557 assert!(output.contains("Quarterly Sales"));
558 assert!(output.chars().any(|c| BLOCK_CHARS.contains(&c)));
560 }
561
562 #[test]
563 fn test_scatter_chart() {
564 let spec = ChartSpec {
565 chart_type: ChartType::Scatter,
566 channels: vec![
567 ChartChannel {
568 name: "x".into(),
569 label: "X".into(),
570 values: vec![1.0, 2.0, 3.0],
571 color: None,
572 },
573 ChartChannel {
574 name: "y".into(),
575 label: "Y".into(),
576 values: vec![2.0, 4.0, 1.0],
577 color: None,
578 },
579 ],
580 x_categories: None,
581 title: Some("Scatter".into()),
582 x_label: None,
583 y_label: None,
584 width: Some(30),
585 height: Some(8),
586 echarts_options: None,
587 interactive: false,
588 };
589 let output = render_chart_text(&spec);
590 assert!(output.contains("Scatter"));
591 }
592
593 #[test]
594 fn test_empty_chart_fallback() {
595 let spec = ChartSpec {
596 chart_type: ChartType::Line,
597 channels: vec![],
598 x_categories: None,
599 title: Some("Empty".into()),
600 x_label: None,
601 y_label: None,
602 width: None,
603 height: None,
604 echarts_options: None,
605 interactive: false,
606 };
607 let output = render_chart_text(&spec);
608 assert!(output.contains("[Line Chart: Empty (0 series)]"));
609 }
610
611 #[test]
612 fn test_candlestick_chart() {
613 let spec = ChartSpec {
614 chart_type: ChartType::Candlestick,
615 channels: vec![
616 ChartChannel {
617 name: "open".into(),
618 label: "Open".into(),
619 values: vec![100.0, 105.0, 102.0],
620 color: None,
621 },
622 ChartChannel {
623 name: "high".into(),
624 label: "High".into(),
625 values: vec![110.0, 112.0, 108.0],
626 color: None,
627 },
628 ChartChannel {
629 name: "low".into(),
630 label: "Low".into(),
631 values: vec![95.0, 100.0, 98.0],
632 color: None,
633 },
634 ChartChannel {
635 name: "close".into(),
636 label: "Close".into(),
637 values: vec![105.0, 102.0, 106.0],
638 color: None,
639 },
640 ],
641 x_categories: None,
642 title: Some("OHLC".into()),
643 x_label: None,
644 y_label: None,
645 width: Some(30),
646 height: Some(12),
647 echarts_options: None,
648 interactive: false,
649 };
650 let output = render_chart_text(&spec);
651 assert!(output.contains("OHLC"));
652 }
653}