Skip to main content

shape_runtime/renderers/
terminal_chart.rs

1//! Terminal chart rendering using Braille and block characters.
2//!
3//! Renders ChartSpec data as text-based charts for terminals that
4//! don't support Kitty image protocol. Uses:
5//! - Braille patterns (U+2800..U+28FF) for line/scatter/area charts
6//! - Block characters (U+2581..U+2588) for bar/histogram charts
7//! - ASCII for candlestick wicks
8
9use shape_value::content::{ChartSpec, ChartType};
10use std::fmt::Write;
11
12/// Default chart dimensions in character cells.
13const DEFAULT_WIDTH: usize = 60;
14const DEFAULT_HEIGHT: usize = 20;
15
16/// Render a ChartSpec as a text-based terminal chart.
17pub 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
31// ========== Braille rendering ==========
32
33/// Braille character base (U+2800). Each braille cell is a 2x4 dot grid.
34/// Dot numbering (bit positions):
35///   0  3
36///   1  4
37///   2  5
38///   6  7
39const BRAILLE_BASE: u32 = 0x2800;
40
41/// A 2D grid of braille dots. Each character cell covers 2 columns x 4 rows of dots.
42struct BrailleCanvas {
43    /// Width in character cells
44    char_width: usize,
45    /// Height in character cells
46    char_height: usize,
47    /// Dot grid: char_height*4 rows of char_width*2 columns
48    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    /// Set a dot at (x, y) in dot coordinates. Origin is top-left.
71    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    /// Draw a line between two dot coordinates using Bresenham's algorithm.
78    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    /// Render the canvas to a string of braille characters.
107    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                // Map dots to braille bit positions
115                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    // Use y-axis label area: 8 chars for labels + 1 for axis
165    let label_width = 8;
166    let chart_char_width = width.saturating_sub(label_width + 1);
167    let chart_char_height = height.saturating_sub(2); // 1 for title, 1 for x-axis
168
169    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    // Compute y range across all y channels
176    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                // Line: connect consecutive points
235                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    // Title
245    if let Some(ref title) = spec.title {
246        let _ = writeln!(out, "  {}", title);
247    }
248
249    // Render with y-axis labels
250    // We need to own the rendered string first
251    let rendered = canvas.render();
252    let braille_lines: Vec<&str> = rendered.lines().collect();
253
254    for (i, line) in braille_lines.iter().enumerate() {
255        // Y-axis label at top, middle, bottom
256        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
271// ========== Bar chart rendering ==========
272
273/// Block characters from 1/8 to 8/8 height.
274const BLOCK_CHARS: [char; 8] = [
275    '\u{2581}', // ▁
276    '\u{2582}', // ▂
277    '\u{2583}', // ▃
278    '\u{2584}', // ▄
279    '\u{2585}', // ▅
280    '\u{2586}', // ▆
281    '\u{2587}', // ▇
282    '\u{2588}', // █
283];
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); // title + x labels + spacing
297    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; // bars start from 0
303
304    let mut out = String::new();
305
306    // Title
307    if let Some(ref title) = spec.title {
308        let _ = writeln!(out, "  {}", title);
309    }
310
311    // Determine bar width: each value gets some columns
312    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    // Render rows from top to bottom
318    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]); // full block
330                } else if val > threshold_bot {
331                    // Partial block
332                    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    // X-axis labels from categories
344    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
359// ========== Candlestick rendering ==========
360
361fn 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    // Find price range
388    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); // label space
407    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    // Render rows
415    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        // Y-axis label
420        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                // Check if this row intersects the candle
439                if row_price_top >= l && row_price_bot <= h {
440                    if row_price_bot <= body_top && row_price_top >= body_bot {
441                        // Body
442                        if c >= o {
443                            out.push('█'); // bullish
444                        } else {
445                            out.push('▒'); // bearish
446                        }
447                    } else if is_center {
448                        out.push('│'); // wick
449                    } 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        // Should contain braille characters
493        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        // Should contain braille characters
530        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        // Should contain block characters
559        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}