Skip to main content

slt/context/
widgets_viz.rs

1use super::*;
2
3impl Context {
4    /// Render a horizontal bar chart from `(label, value)` pairs.
5    ///
6    /// Bars are normalized against the largest value and rendered with `█` up to
7    /// `max_width` characters.
8    ///
9    /// # Example
10    ///
11    /// ```ignore
12    /// # slt::run(|ui: &mut slt::Context| {
13    /// let data = [
14    ///     ("Sales", 160.0),
15    ///     ("Revenue", 120.0),
16    ///     ("Users", 220.0),
17    ///     ("Costs", 60.0),
18    /// ];
19    /// ui.bar_chart(&data, 24);
20    ///
21    /// For styled bars with per-bar colors, see [`bar_chart_styled`].
22    /// # });
23    /// ```
24    pub fn bar_chart(&mut self, data: &[(&str, f64)], max_width: u32) -> &mut Self {
25        if data.is_empty() {
26            return self;
27        }
28
29        let max_label_width = data
30            .iter()
31            .map(|(label, _)| UnicodeWidthStr::width(*label))
32            .max()
33            .unwrap_or(0);
34        let max_value = data
35            .iter()
36            .map(|(_, value)| *value)
37            .fold(f64::NEG_INFINITY, f64::max);
38        let denom = if max_value > 0.0 { max_value } else { 1.0 };
39
40        self.interaction_count += 1;
41        self.commands.push(Command::BeginContainer {
42            direction: Direction::Column,
43            gap: 0,
44            align: Align::Start,
45            justify: Justify::Start,
46            border: None,
47            border_sides: BorderSides::all(),
48            border_style: Style::new().fg(self.theme.border),
49            bg_color: None,
50            padding: Padding::default(),
51            margin: Margin::default(),
52            constraints: Constraints::default(),
53            title: None,
54            grow: 0,
55            group_name: None,
56        });
57
58        for (label, value) in data {
59            let label_width = UnicodeWidthStr::width(*label);
60            let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
61            let normalized = (*value / denom).clamp(0.0, 1.0);
62            let bar_len = (normalized * max_width as f64).round() as usize;
63            let bar = "█".repeat(bar_len);
64
65            self.interaction_count += 1;
66            self.commands.push(Command::BeginContainer {
67                direction: Direction::Row,
68                gap: 1,
69                align: Align::Start,
70                justify: Justify::Start,
71                border: None,
72                border_sides: BorderSides::all(),
73                border_style: Style::new().fg(self.theme.border),
74                bg_color: None,
75                padding: Padding::default(),
76                margin: Margin::default(),
77                constraints: Constraints::default(),
78                title: None,
79                grow: 0,
80                group_name: None,
81            });
82            self.styled(
83                format!("{label}{label_padding}"),
84                Style::new().fg(self.theme.text),
85            );
86            self.styled(bar, Style::new().fg(self.theme.primary));
87            self.styled(
88                format_compact_number(*value),
89                Style::new().fg(self.theme.text_dim),
90            );
91            self.commands.push(Command::EndContainer);
92            self.last_text_idx = None;
93        }
94
95        self.commands.push(Command::EndContainer);
96        self.last_text_idx = None;
97
98        self
99    }
100
101    /// Render a styled bar chart with per-bar colors, grouping, and direction control.
102    ///
103    /// # Example
104    /// ```ignore
105    /// # slt::run(|ui: &mut slt::Context| {
106    /// use slt::{Bar, Color};
107    /// let bars = vec![
108    ///     Bar::new("Q1", 32.0).color(Color::Cyan),
109    ///     Bar::new("Q2", 46.0).color(Color::Green),
110    ///     Bar::new("Q3", 28.0).color(Color::Yellow),
111    ///     Bar::new("Q4", 54.0).color(Color::Red),
112    /// ];
113    /// ui.bar_chart_styled(&bars, 30, slt::BarDirection::Horizontal);
114    /// # });
115    /// ```
116    pub fn bar_chart_styled(
117        &mut self,
118        bars: &[Bar],
119        max_width: u32,
120        direction: BarDirection,
121    ) -> &mut Self {
122        if bars.is_empty() {
123            return self;
124        }
125
126        let max_value = bars
127            .iter()
128            .map(|bar| bar.value)
129            .fold(f64::NEG_INFINITY, f64::max);
130        let denom = if max_value > 0.0 { max_value } else { 1.0 };
131
132        match direction {
133            BarDirection::Horizontal => {
134                let max_label_width = bars
135                    .iter()
136                    .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
137                    .max()
138                    .unwrap_or(0);
139
140                self.interaction_count += 1;
141                self.commands.push(Command::BeginContainer {
142                    direction: Direction::Column,
143                    gap: 0,
144                    align: Align::Start,
145                    justify: Justify::Start,
146                    border: None,
147                    border_sides: BorderSides::all(),
148                    border_style: Style::new().fg(self.theme.border),
149                    bg_color: None,
150                    padding: Padding::default(),
151                    margin: Margin::default(),
152                    constraints: Constraints::default(),
153                    title: None,
154                    grow: 0,
155                    group_name: None,
156                });
157
158                for bar in bars {
159                    let label_width = UnicodeWidthStr::width(bar.label.as_str());
160                    let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
161                    let normalized = (bar.value / denom).clamp(0.0, 1.0);
162                    let bar_len = (normalized * max_width as f64).round() as usize;
163                    let bar_text = "█".repeat(bar_len);
164                    let color = bar.color.unwrap_or(self.theme.primary);
165
166                    self.interaction_count += 1;
167                    self.commands.push(Command::BeginContainer {
168                        direction: Direction::Row,
169                        gap: 1,
170                        align: Align::Start,
171                        justify: Justify::Start,
172                        border: None,
173                        border_sides: BorderSides::all(),
174                        border_style: Style::new().fg(self.theme.border),
175                        bg_color: None,
176                        padding: Padding::default(),
177                        margin: Margin::default(),
178                        constraints: Constraints::default(),
179                        title: None,
180                        grow: 0,
181                        group_name: None,
182                    });
183                    self.styled(
184                        format!("{}{label_padding}", bar.label),
185                        Style::new().fg(self.theme.text),
186                    );
187                    self.styled(bar_text, Style::new().fg(color));
188                    self.styled(
189                        format_compact_number(bar.value),
190                        Style::new().fg(self.theme.text_dim),
191                    );
192                    self.commands.push(Command::EndContainer);
193                    self.last_text_idx = None;
194                }
195
196                self.commands.push(Command::EndContainer);
197                self.last_text_idx = None;
198            }
199            BarDirection::Vertical => {
200                const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
201
202                let chart_height = max_width.max(1) as usize;
203                let value_labels: Vec<String> = bars
204                    .iter()
205                    .map(|bar| format_compact_number(bar.value))
206                    .collect();
207                let col_width = bars
208                    .iter()
209                    .zip(value_labels.iter())
210                    .map(|(bar, value)| {
211                        UnicodeWidthStr::width(bar.label.as_str())
212                            .max(UnicodeWidthStr::width(value.as_str()))
213                            .max(1)
214                    })
215                    .max()
216                    .unwrap_or(1);
217
218                let bar_units: Vec<usize> = bars
219                    .iter()
220                    .map(|bar| {
221                        let normalized = (bar.value / denom).clamp(0.0, 1.0);
222                        (normalized * chart_height as f64 * 8.0).round() as usize
223                    })
224                    .collect();
225
226                self.interaction_count += 1;
227                self.commands.push(Command::BeginContainer {
228                    direction: Direction::Column,
229                    gap: 0,
230                    align: Align::Start,
231                    justify: Justify::Start,
232                    border: None,
233                    border_sides: BorderSides::all(),
234                    border_style: Style::new().fg(self.theme.border),
235                    bg_color: None,
236                    padding: Padding::default(),
237                    margin: Margin::default(),
238                    constraints: Constraints::default(),
239                    title: None,
240                    grow: 0,
241                    group_name: None,
242                });
243
244                self.interaction_count += 1;
245                self.commands.push(Command::BeginContainer {
246                    direction: Direction::Row,
247                    gap: 1,
248                    align: Align::Start,
249                    justify: Justify::Start,
250                    border: None,
251                    border_sides: BorderSides::all(),
252                    border_style: Style::new().fg(self.theme.border),
253                    bg_color: None,
254                    padding: Padding::default(),
255                    margin: Margin::default(),
256                    constraints: Constraints::default(),
257                    title: None,
258                    grow: 0,
259                    group_name: None,
260                });
261                for value in &value_labels {
262                    self.styled(
263                        center_text(value, col_width),
264                        Style::new().fg(self.theme.text_dim),
265                    );
266                }
267                self.commands.push(Command::EndContainer);
268                self.last_text_idx = None;
269
270                for row in (0..chart_height).rev() {
271                    self.interaction_count += 1;
272                    self.commands.push(Command::BeginContainer {
273                        direction: Direction::Row,
274                        gap: 1,
275                        align: Align::Start,
276                        justify: Justify::Start,
277                        border: None,
278                        border_sides: BorderSides::all(),
279                        border_style: Style::new().fg(self.theme.border),
280                        bg_color: None,
281                        padding: Padding::default(),
282                        margin: Margin::default(),
283                        constraints: Constraints::default(),
284                        title: None,
285                        grow: 0,
286                        group_name: None,
287                    });
288
289                    let row_base = row * 8;
290                    for (bar, units) in bars.iter().zip(bar_units.iter()) {
291                        let fill = if *units <= row_base {
292                            ' '
293                        } else {
294                            let delta = *units - row_base;
295                            if delta >= 8 {
296                                '█'
297                            } else {
298                                FRACTION_BLOCKS[delta]
299                            }
300                        };
301
302                        self.styled(
303                            center_text(&fill.to_string(), col_width),
304                            Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
305                        );
306                    }
307
308                    self.commands.push(Command::EndContainer);
309                    self.last_text_idx = None;
310                }
311
312                self.interaction_count += 1;
313                self.commands.push(Command::BeginContainer {
314                    direction: Direction::Row,
315                    gap: 1,
316                    align: Align::Start,
317                    justify: Justify::Start,
318                    border: None,
319                    border_sides: BorderSides::all(),
320                    border_style: Style::new().fg(self.theme.border),
321                    bg_color: None,
322                    padding: Padding::default(),
323                    margin: Margin::default(),
324                    constraints: Constraints::default(),
325                    title: None,
326                    grow: 0,
327                    group_name: None,
328                });
329                for bar in bars {
330                    self.styled(
331                        center_text(&bar.label, col_width),
332                        Style::new().fg(self.theme.text),
333                    );
334                }
335                self.commands.push(Command::EndContainer);
336                self.last_text_idx = None;
337
338                self.commands.push(Command::EndContainer);
339                self.last_text_idx = None;
340            }
341        }
342
343        self
344    }
345
346    /// Render a grouped bar chart.
347    ///
348    /// Each group contains multiple bars rendered side by side. Useful for
349    /// comparing categories across groups (e.g., quarterly revenue by product).
350    ///
351    /// # Example
352    /// ```ignore
353    /// # slt::run(|ui: &mut slt::Context| {
354    /// use slt::{Bar, BarGroup, Color};
355    /// let groups = vec![
356    ///     BarGroup::new("2023", vec![Bar::new("Rev", 100.0).color(Color::Cyan), Bar::new("Cost", 60.0).color(Color::Red)]),
357    ///     BarGroup::new("2024", vec![Bar::new("Rev", 140.0).color(Color::Cyan), Bar::new("Cost", 80.0).color(Color::Red)]),
358    /// ];
359    /// ui.bar_chart_grouped(&groups, 40);
360    /// # });
361    /// ```
362    pub fn bar_chart_grouped(&mut self, groups: &[BarGroup], max_width: u32) -> &mut Self {
363        if groups.is_empty() {
364            return self;
365        }
366
367        let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
368        if all_bars.is_empty() {
369            return self;
370        }
371
372        let max_label_width = all_bars
373            .iter()
374            .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
375            .max()
376            .unwrap_or(0);
377        let max_value = all_bars
378            .iter()
379            .map(|bar| bar.value)
380            .fold(f64::NEG_INFINITY, f64::max);
381        let denom = if max_value > 0.0 { max_value } else { 1.0 };
382
383        self.interaction_count += 1;
384        self.commands.push(Command::BeginContainer {
385            direction: Direction::Column,
386            gap: 1,
387            align: Align::Start,
388            justify: Justify::Start,
389            border: None,
390            border_sides: BorderSides::all(),
391            border_style: Style::new().fg(self.theme.border),
392            bg_color: None,
393            padding: Padding::default(),
394            margin: Margin::default(),
395            constraints: Constraints::default(),
396            title: None,
397            grow: 0,
398            group_name: None,
399        });
400
401        for group in groups {
402            self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
403
404            for bar in &group.bars {
405                let label_width = UnicodeWidthStr::width(bar.label.as_str());
406                let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
407                let normalized = (bar.value / denom).clamp(0.0, 1.0);
408                let bar_len = (normalized * max_width as f64).round() as usize;
409                let bar_text = "█".repeat(bar_len);
410
411                self.interaction_count += 1;
412                self.commands.push(Command::BeginContainer {
413                    direction: Direction::Row,
414                    gap: 1,
415                    align: Align::Start,
416                    justify: Justify::Start,
417                    border: None,
418                    border_sides: BorderSides::all(),
419                    border_style: Style::new().fg(self.theme.border),
420                    bg_color: None,
421                    padding: Padding::default(),
422                    margin: Margin::default(),
423                    constraints: Constraints::default(),
424                    title: None,
425                    grow: 0,
426                    group_name: None,
427                });
428                self.styled(
429                    format!("  {}{label_padding}", bar.label),
430                    Style::new().fg(self.theme.text),
431                );
432                self.styled(
433                    bar_text,
434                    Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
435                );
436                self.styled(
437                    format_compact_number(bar.value),
438                    Style::new().fg(self.theme.text_dim),
439                );
440                self.commands.push(Command::EndContainer);
441                self.last_text_idx = None;
442            }
443        }
444
445        self.commands.push(Command::EndContainer);
446        self.last_text_idx = None;
447
448        self
449    }
450
451    /// Render a single-line sparkline from numeric data.
452    ///
453    /// Uses the last `width` points (or fewer if the data is shorter) and maps
454    /// each point to one of `▁▂▃▄▅▆▇█`.
455    ///
456    /// # Example
457    ///
458    /// ```ignore
459    /// # slt::run(|ui: &mut slt::Context| {
460    /// let samples = [12.0, 9.0, 14.0, 18.0, 16.0, 21.0, 20.0, 24.0];
461    /// ui.sparkline(&samples, 16);
462    ///
463    /// For per-point colors and missing values, see [`sparkline_styled`].
464    /// # });
465    /// ```
466    pub fn sparkline(&mut self, data: &[f64], width: u32) -> &mut Self {
467        const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
468
469        let w = width as usize;
470        let window = if data.len() > w {
471            &data[data.len() - w..]
472        } else {
473            data
474        };
475
476        if window.is_empty() {
477            return self;
478        }
479
480        let min = window.iter().copied().fold(f64::INFINITY, f64::min);
481        let max = window.iter().copied().fold(f64::NEG_INFINITY, f64::max);
482        let range = max - min;
483
484        let line: String = window
485            .iter()
486            .map(|&value| {
487                let normalized = if range == 0.0 {
488                    0.5
489                } else {
490                    (value - min) / range
491                };
492                let idx = (normalized * 7.0).round() as usize;
493                BLOCKS[idx.min(7)]
494            })
495            .collect();
496
497        self.styled(line, Style::new().fg(self.theme.primary))
498    }
499
500    /// Render a sparkline with per-point colors.
501    ///
502    /// Each point can have its own color via `(f64, Option<Color>)` tuples.
503    /// Use `f64::NAN` for absent values (rendered as spaces).
504    ///
505    /// # Example
506    /// ```ignore
507    /// # slt::run(|ui: &mut slt::Context| {
508    /// use slt::Color;
509    /// let data: Vec<(f64, Option<Color>)> = vec![
510    ///     (12.0, Some(Color::Green)),
511    ///     (9.0, Some(Color::Red)),
512    ///     (14.0, Some(Color::Green)),
513    ///     (f64::NAN, None),
514    ///     (18.0, Some(Color::Cyan)),
515    /// ];
516    /// ui.sparkline_styled(&data, 16);
517    /// # });
518    /// ```
519    pub fn sparkline_styled(&mut self, data: &[(f64, Option<Color>)], width: u32) -> &mut Self {
520        const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
521
522        let w = width as usize;
523        let window = if data.len() > w {
524            &data[data.len() - w..]
525        } else {
526            data
527        };
528
529        if window.is_empty() {
530            return self;
531        }
532
533        let mut finite_values = window
534            .iter()
535            .map(|(value, _)| *value)
536            .filter(|value| !value.is_nan());
537        let Some(first) = finite_values.next() else {
538            return self.styled(
539                " ".repeat(window.len()),
540                Style::new().fg(self.theme.text_dim),
541            );
542        };
543
544        let mut min = first;
545        let mut max = first;
546        for value in finite_values {
547            min = f64::min(min, value);
548            max = f64::max(max, value);
549        }
550        let range = max - min;
551
552        let mut cells: Vec<(char, Color)> = Vec::with_capacity(window.len());
553        for (value, color) in window {
554            if value.is_nan() {
555                cells.push((' ', self.theme.text_dim));
556                continue;
557            }
558
559            let normalized = if range == 0.0 {
560                0.5
561            } else {
562                ((*value - min) / range).clamp(0.0, 1.0)
563            };
564            let idx = (normalized * 7.0).round() as usize;
565            cells.push((BLOCKS[idx.min(7)], color.unwrap_or(self.theme.primary)));
566        }
567
568        self.interaction_count += 1;
569        self.commands.push(Command::BeginContainer {
570            direction: Direction::Row,
571            gap: 0,
572            align: Align::Start,
573            justify: Justify::Start,
574            border: None,
575            border_sides: BorderSides::all(),
576            border_style: Style::new().fg(self.theme.border),
577            bg_color: None,
578            padding: Padding::default(),
579            margin: Margin::default(),
580            constraints: Constraints::default(),
581            title: None,
582            grow: 0,
583            group_name: None,
584        });
585
586        let mut seg = String::new();
587        let mut seg_color = cells[0].1;
588        for (ch, color) in cells {
589            if color != seg_color {
590                self.styled(seg, Style::new().fg(seg_color));
591                seg = String::new();
592                seg_color = color;
593            }
594            seg.push(ch);
595        }
596        if !seg.is_empty() {
597            self.styled(seg, Style::new().fg(seg_color));
598        }
599
600        self.commands.push(Command::EndContainer);
601        self.last_text_idx = None;
602
603        self
604    }
605
606    /// Render a multi-row line chart using braille characters.
607    ///
608    /// `width` and `height` are terminal cell dimensions. Internally this uses
609    /// braille dot resolution (`width*2` x `height*4`) for smoother plotting.
610    ///
611    /// # Example
612    ///
613    /// ```ignore
614    /// # slt::run(|ui: &mut slt::Context| {
615    /// let data = [1.0, 3.0, 2.0, 5.0, 4.0, 6.0, 3.0, 7.0];
616    /// ui.line_chart(&data, 40, 8);
617    /// # });
618    /// ```
619    pub fn line_chart(&mut self, data: &[f64], width: u32, height: u32) -> &mut Self {
620        if data.is_empty() || width == 0 || height == 0 {
621            return self;
622        }
623
624        let cols = width as usize;
625        let rows = height as usize;
626        let px_w = cols * 2;
627        let px_h = rows * 4;
628
629        let min = data.iter().copied().fold(f64::INFINITY, f64::min);
630        let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
631        let range = if (max - min).abs() < f64::EPSILON {
632            1.0
633        } else {
634            max - min
635        };
636
637        let points: Vec<usize> = (0..px_w)
638            .map(|px| {
639                let data_idx = if px_w <= 1 {
640                    0.0
641                } else {
642                    px as f64 * (data.len() - 1) as f64 / (px_w - 1) as f64
643                };
644                let idx = data_idx.floor() as usize;
645                let frac = data_idx - idx as f64;
646                let value = if idx + 1 < data.len() {
647                    data[idx] * (1.0 - frac) + data[idx + 1] * frac
648                } else {
649                    data[idx.min(data.len() - 1)]
650                };
651
652                let normalized = (value - min) / range;
653                let py = ((1.0 - normalized) * (px_h - 1) as f64).round() as usize;
654                py.min(px_h - 1)
655            })
656            .collect();
657
658        const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
659        const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
660
661        let mut grid = vec![vec![0u32; cols]; rows];
662
663        for i in 0..points.len() {
664            let px = i;
665            let py = points[i];
666            let char_col = px / 2;
667            let char_row = py / 4;
668            let sub_col = px % 2;
669            let sub_row = py % 4;
670
671            if char_col < cols && char_row < rows {
672                grid[char_row][char_col] |= if sub_col == 0 {
673                    LEFT_BITS[sub_row]
674                } else {
675                    RIGHT_BITS[sub_row]
676                };
677            }
678
679            if i + 1 < points.len() {
680                let py_next = points[i + 1];
681                let (y_start, y_end) = if py <= py_next {
682                    (py, py_next)
683                } else {
684                    (py_next, py)
685                };
686                for y in y_start..=y_end {
687                    let cell_row = y / 4;
688                    let sub_y = y % 4;
689                    if char_col < cols && cell_row < rows {
690                        grid[cell_row][char_col] |= if sub_col == 0 {
691                            LEFT_BITS[sub_y]
692                        } else {
693                            RIGHT_BITS[sub_y]
694                        };
695                    }
696                }
697            }
698        }
699
700        let style = Style::new().fg(self.theme.primary);
701        for row in grid {
702            let line: String = row
703                .iter()
704                .map(|&bits| char::from_u32(0x2800 + bits).unwrap_or(' '))
705                .collect();
706            self.styled(line, style);
707        }
708
709        self
710    }
711
712    /// Render a braille drawing canvas.
713    ///
714    /// The closure receives a [`CanvasContext`] for pixel-level drawing. Each
715    /// terminal cell maps to a 2x4 braille dot matrix, giving `width*2` x
716    /// `height*4` pixel resolution.
717    ///
718    /// # Example
719    ///
720    /// ```ignore
721    /// # slt::run(|ui: &mut slt::Context| {
722    /// ui.canvas(40, 10, |cv| {
723    ///     cv.line(0, 0, cv.width() - 1, cv.height() - 1);
724    ///     cv.circle(40, 20, 15);
725    /// });
726    /// # });
727    /// ```
728    pub fn canvas(
729        &mut self,
730        width: u32,
731        height: u32,
732        draw: impl FnOnce(&mut CanvasContext),
733    ) -> &mut Self {
734        if width == 0 || height == 0 {
735            return self;
736        }
737
738        let mut canvas = CanvasContext::new(width as usize, height as usize);
739        draw(&mut canvas);
740
741        for segments in canvas.render() {
742            self.interaction_count += 1;
743            self.commands.push(Command::BeginContainer {
744                direction: Direction::Row,
745                gap: 0,
746                align: Align::Start,
747                justify: Justify::Start,
748                border: None,
749                border_sides: BorderSides::all(),
750                border_style: Style::new(),
751                bg_color: None,
752                padding: Padding::default(),
753                margin: Margin::default(),
754                constraints: Constraints::default(),
755                title: None,
756                grow: 0,
757                group_name: None,
758            });
759            for (text, color) in segments {
760                let c = if color == Color::Reset {
761                    self.theme.primary
762                } else {
763                    color
764                };
765                self.styled(text, Style::new().fg(c));
766            }
767            self.commands.push(Command::EndContainer);
768            self.last_text_idx = None;
769        }
770
771        self
772    }
773
774    /// Render a multi-series chart with axes, legend, and auto-scaling.
775    pub fn chart(
776        &mut self,
777        configure: impl FnOnce(&mut ChartBuilder),
778        width: u32,
779        height: u32,
780    ) -> &mut Self {
781        if width == 0 || height == 0 {
782            return self;
783        }
784
785        let axis_style = Style::new().fg(self.theme.text_dim);
786        let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
787        configure(&mut builder);
788
789        let config = builder.build();
790        let rows = render_chart(&config);
791
792        for row in rows {
793            self.interaction_count += 1;
794            self.commands.push(Command::BeginContainer {
795                direction: Direction::Row,
796                gap: 0,
797                align: Align::Start,
798                justify: Justify::Start,
799                border: None,
800                border_sides: BorderSides::all(),
801                border_style: Style::new().fg(self.theme.border),
802                bg_color: None,
803                padding: Padding::default(),
804                margin: Margin::default(),
805                constraints: Constraints::default(),
806                title: None,
807                grow: 0,
808                group_name: None,
809            });
810            for (text, style) in row.segments {
811                self.styled(text, style);
812            }
813            self.commands.push(Command::EndContainer);
814            self.last_text_idx = None;
815        }
816
817        self
818    }
819
820    /// Renders a scatter plot.
821    ///
822    /// Each point is a (x, y) tuple. Uses braille markers.
823    pub fn scatter(&mut self, data: &[(f64, f64)], width: u32, height: u32) -> &mut Self {
824        self.chart(
825            |c| {
826                c.scatter(data);
827                c.grid(true);
828            },
829            width,
830            height,
831        )
832    }
833
834    /// Render a histogram from raw data with auto-binning.
835    pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> &mut Self {
836        self.histogram_with(data, |_| {}, width, height)
837    }
838
839    /// Render a histogram with configuration options.
840    pub fn histogram_with(
841        &mut self,
842        data: &[f64],
843        configure: impl FnOnce(&mut HistogramBuilder),
844        width: u32,
845        height: u32,
846    ) -> &mut Self {
847        if width == 0 || height == 0 {
848            return self;
849        }
850
851        let mut options = HistogramBuilder::default();
852        configure(&mut options);
853        let axis_style = Style::new().fg(self.theme.text_dim);
854        let config = build_histogram_config(data, &options, width, height, axis_style);
855        let rows = render_chart(&config);
856
857        for row in rows {
858            self.interaction_count += 1;
859            self.commands.push(Command::BeginContainer {
860                direction: Direction::Row,
861                gap: 0,
862                align: Align::Start,
863                justify: Justify::Start,
864                border: None,
865                border_sides: BorderSides::all(),
866                border_style: Style::new().fg(self.theme.border),
867                bg_color: None,
868                padding: Padding::default(),
869                margin: Margin::default(),
870                constraints: Constraints::default(),
871                title: None,
872                grow: 0,
873                group_name: None,
874            });
875            for (text, style) in row.segments {
876                self.styled(text, style);
877            }
878            self.commands.push(Command::EndContainer);
879            self.last_text_idx = None;
880        }
881
882        self
883    }
884}