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
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                // Line: connect consecutive points
241                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    // Title
251    if let Some(ref title) = spec.title {
252        let _ = writeln!(out, "  {}", title);
253    }
254
255    // Render with y-axis labels
256    let braille_lines: Vec<&str> = canvas.render().lines().map(|l| l).collect();
257    // We need to own the rendered string first
258    let rendered = canvas.render();
259    let braille_lines: Vec<&str> = rendered.lines().collect();
260
261    for (i, line) in braille_lines.iter().enumerate() {
262        // Y-axis label at top, middle, bottom
263        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
278// ========== Bar chart rendering ==========
279
280/// Block characters from 1/8 to 8/8 height.
281const BLOCK_CHARS: [char; 8] = [
282    '\u{2581}', // ▁
283    '\u{2582}', // ▂
284    '\u{2583}', // ▃
285    '\u{2584}', // ▄
286    '\u{2585}', // ▅
287    '\u{2586}', // ▆
288    '\u{2587}', // ▇
289    '\u{2588}', // █
290];
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); // title + x labels + spacing
304    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; // bars start from 0
310
311    let mut out = String::new();
312
313    // Title
314    if let Some(ref title) = spec.title {
315        let _ = writeln!(out, "  {}", title);
316    }
317
318    // Determine bar width: each value gets some columns
319    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    // Render rows from top to bottom
325    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]); // full block
337                } else if val > threshold_bot {
338                    // Partial block
339                    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    // X-axis labels from categories
351    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
366// ========== Candlestick rendering ==========
367
368fn 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    // Find price range
395    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); // label space
414    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    // Render rows
422    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        // Y-axis label
427        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                // Check if this row intersects the candle
446                if row_price_top >= l && row_price_bot <= h {
447                    if row_price_bot <= body_top && row_price_top >= body_bot {
448                        // Body
449                        if c >= o {
450                            out.push('█'); // bullish
451                        } else {
452                            out.push('▒'); // bearish
453                        }
454                    } else if is_center {
455                        out.push('│'); // wick
456                    } 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        // Should contain braille characters
500        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        // Should contain braille characters
537        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        // Should contain block characters
562        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}