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) -> Response {
25        if data.is_empty() {
26            return Response::none();
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 = Self::horizontal_bar_text(normalized, max_width);
63
64            self.interaction_count += 1;
65            self.commands.push(Command::BeginContainer {
66                direction: Direction::Row,
67                gap: 1,
68                align: Align::Start,
69                justify: Justify::Start,
70                border: None,
71                border_sides: BorderSides::all(),
72                border_style: Style::new().fg(self.theme.border),
73                bg_color: None,
74                padding: Padding::default(),
75                margin: Margin::default(),
76                constraints: Constraints::default(),
77                title: None,
78                grow: 0,
79                group_name: None,
80            });
81            self.styled(
82                format!("{label}{label_padding}"),
83                Style::new().fg(self.theme.text),
84            );
85            self.styled(bar, Style::new().fg(self.theme.primary));
86            self.styled(
87                format_compact_number(*value),
88                Style::new().fg(self.theme.text_dim),
89            );
90            self.commands.push(Command::EndContainer);
91            self.last_text_idx = None;
92        }
93
94        self.commands.push(Command::EndContainer);
95        self.last_text_idx = None;
96
97        Response::none()
98    }
99
100    /// Render a styled bar chart with per-bar colors, grouping, and direction control.
101    ///
102    /// # Example
103    /// ```ignore
104    /// # slt::run(|ui: &mut slt::Context| {
105    /// use slt::{Bar, Color};
106    /// let bars = vec![
107    ///     Bar::new("Q1", 32.0).color(Color::Cyan),
108    ///     Bar::new("Q2", 46.0).color(Color::Green),
109    ///     Bar::new("Q3", 28.0).color(Color::Yellow),
110    ///     Bar::new("Q4", 54.0).color(Color::Red),
111    /// ];
112    /// ui.bar_chart_styled(&bars, 30, slt::BarDirection::Horizontal);
113    /// # });
114    /// ```
115    pub fn bar_chart_styled(
116        &mut self,
117        bars: &[Bar],
118        max_width: u32,
119        direction: BarDirection,
120    ) -> Response {
121        self.bar_chart_with(
122            bars,
123            |config| {
124                config.direction(direction);
125            },
126            max_width,
127        )
128    }
129
130    pub fn bar_chart_with(
131        &mut self,
132        bars: &[Bar],
133        configure: impl FnOnce(&mut BarChartConfig),
134        max_size: u32,
135    ) -> Response {
136        if bars.is_empty() {
137            return Response::none();
138        }
139
140        let mut config = BarChartConfig::default();
141        configure(&mut config);
142
143        let auto_max = bars
144            .iter()
145            .map(|bar| bar.value)
146            .fold(f64::NEG_INFINITY, f64::max);
147        let max_value = config.max_value.unwrap_or(auto_max);
148        let denom = if max_value > 0.0 { max_value } else { 1.0 };
149
150        match config.direction {
151            BarDirection::Horizontal => {
152                self.render_horizontal_styled_bars(bars, max_size, denom, config.bar_gap)
153            }
154            BarDirection::Vertical => self.render_vertical_styled_bars(
155                bars,
156                max_size,
157                denom,
158                config.bar_width,
159                config.bar_gap,
160            ),
161        }
162
163        Response::none()
164    }
165
166    fn render_horizontal_styled_bars(
167        &mut self,
168        bars: &[Bar],
169        max_width: u32,
170        denom: f64,
171        bar_gap: u16,
172    ) {
173        let max_label_width = bars
174            .iter()
175            .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
176            .max()
177            .unwrap_or(0);
178
179        self.interaction_count += 1;
180        self.commands.push(Command::BeginContainer {
181            direction: Direction::Column,
182            gap: bar_gap as u32,
183            align: Align::Start,
184            justify: Justify::Start,
185            border: None,
186            border_sides: BorderSides::all(),
187            border_style: Style::new().fg(self.theme.border),
188            bg_color: None,
189            padding: Padding::default(),
190            margin: Margin::default(),
191            constraints: Constraints::default(),
192            title: None,
193            grow: 0,
194            group_name: None,
195        });
196
197        for bar in bars {
198            self.render_horizontal_styled_bar_row(bar, max_label_width, max_width, denom);
199        }
200
201        self.commands.push(Command::EndContainer);
202        self.last_text_idx = None;
203    }
204
205    fn render_horizontal_styled_bar_row(
206        &mut self,
207        bar: &Bar,
208        max_label_width: usize,
209        max_width: u32,
210        denom: f64,
211    ) {
212        let label_width = UnicodeWidthStr::width(bar.label.as_str());
213        let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
214        let normalized = (bar.value / denom).clamp(0.0, 1.0);
215        let bar_text = Self::horizontal_bar_text(normalized, max_width);
216        let color = bar.color.unwrap_or(self.theme.primary);
217
218        self.interaction_count += 1;
219        self.commands.push(Command::BeginContainer {
220            direction: Direction::Row,
221            gap: 1,
222            align: Align::Start,
223            justify: Justify::Start,
224            border: None,
225            border_sides: BorderSides::all(),
226            border_style: Style::new().fg(self.theme.border),
227            bg_color: None,
228            padding: Padding::default(),
229            margin: Margin::default(),
230            constraints: Constraints::default(),
231            title: None,
232            grow: 0,
233            group_name: None,
234        });
235        self.styled(
236            format!("{}{label_padding}", bar.label),
237            Style::new().fg(self.theme.text),
238        );
239        self.styled(bar_text, Style::new().fg(color));
240        self.styled(
241            Self::bar_display_value(bar),
242            bar.value_style
243                .unwrap_or(Style::new().fg(self.theme.text_dim)),
244        );
245        self.commands.push(Command::EndContainer);
246        self.last_text_idx = None;
247    }
248
249    fn render_vertical_styled_bars(
250        &mut self,
251        bars: &[Bar],
252        max_height: u32,
253        denom: f64,
254        bar_width: u16,
255        bar_gap: u16,
256    ) {
257        let chart_height = max_height.max(1) as usize;
258        let bar_width = bar_width.max(1) as usize;
259        let value_labels: Vec<String> = bars.iter().map(Self::bar_display_value).collect();
260        let label_width = bars
261            .iter()
262            .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
263            .max()
264            .unwrap_or(1);
265        let value_width = value_labels
266            .iter()
267            .map(|value| UnicodeWidthStr::width(value.as_str()))
268            .max()
269            .unwrap_or(1);
270        let col_width = bar_width.max(label_width.max(value_width).max(1));
271        let bar_units: Vec<usize> = bars
272            .iter()
273            .map(|bar| {
274                ((bar.value / denom).clamp(0.0, 1.0) * chart_height as f64 * 8.0).round() as usize
275            })
276            .collect();
277
278        self.interaction_count += 1;
279        self.commands.push(Command::BeginContainer {
280            direction: Direction::Column,
281            gap: 0,
282            align: Align::Start,
283            justify: Justify::Start,
284            border: None,
285            border_sides: BorderSides::all(),
286            border_style: Style::new().fg(self.theme.border),
287            bg_color: None,
288            padding: Padding::default(),
289            margin: Margin::default(),
290            constraints: Constraints::default(),
291            title: None,
292            grow: 0,
293            group_name: None,
294        });
295
296        self.render_vertical_bar_body(
297            bars,
298            &bar_units,
299            chart_height,
300            col_width,
301            bar_width,
302            bar_gap,
303            &value_labels,
304        );
305        self.render_vertical_bar_labels(bars, col_width, bar_gap);
306
307        self.commands.push(Command::EndContainer);
308        self.last_text_idx = None;
309    }
310
311    #[allow(clippy::too_many_arguments)]
312    fn render_vertical_bar_body(
313        &mut self,
314        bars: &[Bar],
315        bar_units: &[usize],
316        chart_height: usize,
317        col_width: usize,
318        bar_width: usize,
319        bar_gap: u16,
320        value_labels: &[String],
321    ) {
322        const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
323
324        // Pre-compute the topmost filled row for each bar (for value label placement).
325        let top_rows: Vec<usize> = bar_units
326            .iter()
327            .map(|units| {
328                if *units == 0 {
329                    usize::MAX
330                } else {
331                    (*units - 1) / 8
332                }
333            })
334            .collect();
335
336        for row in (0..chart_height).rev() {
337            self.interaction_count += 1;
338            self.commands.push(Command::BeginContainer {
339                direction: Direction::Row,
340                gap: bar_gap as u32,
341                align: Align::Start,
342                justify: Justify::Start,
343                border: None,
344                border_sides: BorderSides::all(),
345                border_style: Style::new().fg(self.theme.border),
346                bg_color: None,
347                padding: Padding::default(),
348                margin: Margin::default(),
349                constraints: Constraints::default(),
350                title: None,
351                grow: 0,
352                group_name: None,
353            });
354
355            let row_base = row * 8;
356            for (i, (bar, units)) in bars.iter().zip(bar_units.iter()).enumerate() {
357                let color = bar.color.unwrap_or(self.theme.primary);
358
359                if *units <= row_base {
360                    // Value label one row above the bar top (plain text, no bg).
361                    if top_rows[i] != usize::MAX && row == top_rows[i] + 1 {
362                        let label = &value_labels[i];
363                        let centered = Self::center_and_truncate_text(label, col_width);
364                        self.styled(
365                            centered,
366                            bar.value_style.unwrap_or(Style::new().fg(color).bold()),
367                        );
368                    } else {
369                        let empty = " ".repeat(col_width);
370                        self.styled(empty, Style::new());
371                    }
372                    continue;
373                }
374
375                if row == top_rows[i] && top_rows[i] + 1 >= chart_height {
376                    let label = &value_labels[i];
377                    let centered = Self::center_and_truncate_text(label, col_width);
378                    self.styled(
379                        centered,
380                        bar.value_style.unwrap_or(Style::new().fg(color).bold()),
381                    );
382                    continue;
383                }
384
385                let delta = *units - row_base;
386                let fill = if delta >= 8 {
387                    '█'
388                } else {
389                    FRACTION_BLOCKS[delta]
390                };
391                let fill_text = fill.to_string().repeat(bar_width);
392                let centered_fill = center_text(&fill_text, col_width);
393                self.styled(centered_fill, Style::new().fg(color));
394            }
395
396            self.commands.push(Command::EndContainer);
397            self.last_text_idx = None;
398        }
399    }
400
401    fn render_vertical_bar_labels(&mut self, bars: &[Bar], col_width: usize, bar_gap: u16) {
402        self.interaction_count += 1;
403        self.commands.push(Command::BeginContainer {
404            direction: Direction::Row,
405            gap: bar_gap as u32,
406            align: Align::Start,
407            justify: Justify::Start,
408            border: None,
409            border_sides: BorderSides::all(),
410            border_style: Style::new().fg(self.theme.border),
411            bg_color: None,
412            padding: Padding::default(),
413            margin: Margin::default(),
414            constraints: Constraints::default(),
415            title: None,
416            grow: 0,
417            group_name: None,
418        });
419        for bar in bars {
420            self.styled(
421                Self::center_and_truncate_text(&bar.label, col_width),
422                Style::new().fg(self.theme.text),
423            );
424        }
425        self.commands.push(Command::EndContainer);
426        self.last_text_idx = None;
427    }
428
429    /// Render a grouped bar chart.
430    ///
431    /// Each group contains multiple bars rendered side by side. Useful for
432    /// comparing categories across groups (e.g., quarterly revenue by product).
433    ///
434    /// # Example
435    /// ```ignore
436    /// # slt::run(|ui: &mut slt::Context| {
437    /// use slt::{Bar, BarGroup, Color};
438    /// let groups = vec![
439    ///     BarGroup::new("2023", vec![Bar::new("Rev", 100.0).color(Color::Cyan), Bar::new("Cost", 60.0).color(Color::Red)]),
440    ///     BarGroup::new("2024", vec![Bar::new("Rev", 140.0).color(Color::Cyan), Bar::new("Cost", 80.0).color(Color::Red)]),
441    /// ];
442    /// ui.bar_chart_grouped(&groups, 40);
443    /// # });
444    /// ```
445    pub fn bar_chart_grouped(&mut self, groups: &[BarGroup], max_width: u32) -> Response {
446        self.bar_chart_grouped_with(groups, |_| {}, max_width)
447    }
448
449    pub fn bar_chart_grouped_with(
450        &mut self,
451        groups: &[BarGroup],
452        configure: impl FnOnce(&mut BarChartConfig),
453        max_size: u32,
454    ) -> Response {
455        if groups.is_empty() {
456            return Response::none();
457        }
458
459        let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
460        if all_bars.is_empty() {
461            return Response::none();
462        }
463
464        let mut config = BarChartConfig::default();
465        configure(&mut config);
466
467        let auto_max = all_bars
468            .iter()
469            .map(|bar| bar.value)
470            .fold(f64::NEG_INFINITY, f64::max);
471        let max_value = config.max_value.unwrap_or(auto_max);
472        let denom = if max_value > 0.0 { max_value } else { 1.0 };
473
474        match config.direction {
475            BarDirection::Horizontal => {
476                self.render_grouped_horizontal_bars(groups, max_size, denom, &config)
477            }
478            BarDirection::Vertical => {
479                self.render_grouped_vertical_bars(groups, max_size, denom, &config)
480            }
481        }
482
483        Response::none()
484    }
485
486    fn render_grouped_horizontal_bars(
487        &mut self,
488        groups: &[BarGroup],
489        max_width: u32,
490        denom: f64,
491        config: &BarChartConfig,
492    ) {
493        let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
494        let max_label_width = all_bars
495            .iter()
496            .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
497            .max()
498            .unwrap_or(0);
499
500        self.interaction_count += 1;
501        self.commands.push(Command::BeginContainer {
502            direction: Direction::Column,
503            gap: config.group_gap as u32,
504            align: Align::Start,
505            justify: Justify::Start,
506            border: None,
507            border_sides: BorderSides::all(),
508            border_style: Style::new().fg(self.theme.border),
509            bg_color: None,
510            padding: Padding::default(),
511            margin: Margin::default(),
512            constraints: Constraints::default(),
513            title: None,
514            grow: 0,
515            group_name: None,
516        });
517
518        for group in groups {
519            self.interaction_count += 1;
520            self.commands.push(Command::BeginContainer {
521                direction: Direction::Column,
522                gap: config.bar_gap as u32,
523                align: Align::Start,
524                justify: Justify::Start,
525                border: None,
526                border_sides: BorderSides::all(),
527                border_style: Style::new().fg(self.theme.border),
528                bg_color: None,
529                padding: Padding::default(),
530                margin: Margin::default(),
531                constraints: Constraints::default(),
532                title: None,
533                grow: 0,
534                group_name: None,
535            });
536
537            self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
538
539            for bar in &group.bars {
540                let label_width = UnicodeWidthStr::width(bar.label.as_str());
541                let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
542                let normalized = (bar.value / denom).clamp(0.0, 1.0);
543                let bar_text = Self::horizontal_bar_text(normalized, max_width);
544
545                self.interaction_count += 1;
546                self.commands.push(Command::BeginContainer {
547                    direction: Direction::Row,
548                    gap: 1,
549                    align: Align::Start,
550                    justify: Justify::Start,
551                    border: None,
552                    border_sides: BorderSides::all(),
553                    border_style: Style::new().fg(self.theme.border),
554                    bg_color: None,
555                    padding: Padding::default(),
556                    margin: Margin::default(),
557                    constraints: Constraints::default(),
558                    title: None,
559                    grow: 0,
560                    group_name: None,
561                });
562                self.styled(
563                    format!("  {}{label_padding}", bar.label),
564                    Style::new().fg(self.theme.text),
565                );
566                self.styled(
567                    bar_text,
568                    Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
569                );
570                self.styled(
571                    Self::bar_display_value(bar),
572                    bar.value_style
573                        .unwrap_or(Style::new().fg(self.theme.text_dim)),
574                );
575                self.commands.push(Command::EndContainer);
576                self.last_text_idx = None;
577            }
578
579            self.commands.push(Command::EndContainer);
580            self.last_text_idx = None;
581        }
582
583        self.commands.push(Command::EndContainer);
584        self.last_text_idx = None;
585    }
586
587    fn render_grouped_vertical_bars(
588        &mut self,
589        groups: &[BarGroup],
590        max_height: u32,
591        denom: f64,
592        config: &BarChartConfig,
593    ) {
594        self.interaction_count += 1;
595        self.commands.push(Command::BeginContainer {
596            direction: Direction::Column,
597            gap: config.group_gap as u32,
598            align: Align::Start,
599            justify: Justify::Start,
600            border: None,
601            border_sides: BorderSides::all(),
602            border_style: Style::new().fg(self.theme.border),
603            bg_color: None,
604            padding: Padding::default(),
605            margin: Margin::default(),
606            constraints: Constraints::default(),
607            title: None,
608            grow: 0,
609            group_name: None,
610        });
611
612        for group in groups {
613            self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
614            if !group.bars.is_empty() {
615                self.render_vertical_styled_bars(
616                    &group.bars,
617                    max_height,
618                    denom,
619                    config.bar_width,
620                    config.bar_gap,
621                );
622            }
623        }
624
625        self.commands.push(Command::EndContainer);
626        self.last_text_idx = None;
627    }
628
629    fn horizontal_bar_text(normalized: f64, max_width: u32) -> String {
630        let filled = (normalized.clamp(0.0, 1.0) * max_width as f64).round() as usize;
631        "█".repeat(filled)
632    }
633
634    fn bar_display_value(bar: &Bar) -> String {
635        bar.text_value
636            .clone()
637            .unwrap_or_else(|| format_compact_number(bar.value))
638    }
639
640    fn center_and_truncate_text(text: &str, width: usize) -> String {
641        if width == 0 {
642            return String::new();
643        }
644
645        let mut out = String::new();
646        let mut used = 0usize;
647        for ch in text.chars() {
648            let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
649            if used + cw > width {
650                break;
651            }
652            out.push(ch);
653            used += cw;
654        }
655        center_text(&out, width)
656    }
657
658    /// Render a single-line sparkline from numeric data.
659    ///
660    /// Uses the last `width` points (or fewer if the data is shorter) and maps
661    /// each point to one of `▁▂▃▄▅▆▇█`.
662    ///
663    /// # Example
664    ///
665    /// ```ignore
666    /// # slt::run(|ui: &mut slt::Context| {
667    /// let samples = [12.0, 9.0, 14.0, 18.0, 16.0, 21.0, 20.0, 24.0];
668    /// ui.sparkline(&samples, 16);
669    ///
670    /// For per-point colors and missing values, see [`sparkline_styled`].
671    /// # });
672    /// ```
673    pub fn sparkline(&mut self, data: &[f64], width: u32) -> Response {
674        const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
675
676        let w = width as usize;
677        if data.is_empty() || w == 0 {
678            return Response::none();
679        }
680
681        let points: Vec<f64> = if data.len() >= w {
682            data[data.len() - w..].to_vec()
683        } else if data.len() == 1 {
684            vec![data[0]; w]
685        } else {
686            (0..w)
687                .map(|i| {
688                    let t = i as f64 * (data.len() - 1) as f64 / (w - 1) as f64;
689                    let idx = t.floor() as usize;
690                    let frac = t - idx as f64;
691                    if idx + 1 < data.len() {
692                        data[idx] * (1.0 - frac) + data[idx + 1] * frac
693                    } else {
694                        data[idx.min(data.len() - 1)]
695                    }
696                })
697                .collect()
698        };
699
700        let min = points.iter().copied().fold(f64::INFINITY, f64::min);
701        let max = points.iter().copied().fold(f64::NEG_INFINITY, f64::max);
702        let range = max - min;
703
704        let line: String = points
705            .iter()
706            .map(|&value| {
707                let normalized = if range == 0.0 {
708                    0.5
709                } else {
710                    (value - min) / range
711                };
712                let idx = (normalized * 7.0).round() as usize;
713                BLOCKS[idx.min(7)]
714            })
715            .collect();
716
717        self.styled(line, Style::new().fg(self.theme.primary));
718        Response::none()
719    }
720
721    /// Render a sparkline with per-point colors.
722    ///
723    /// Each point can have its own color via `(f64, Option<Color>)` tuples.
724    /// Use `f64::NAN` for absent values (rendered as spaces).
725    ///
726    /// # Example
727    /// ```ignore
728    /// # slt::run(|ui: &mut slt::Context| {
729    /// use slt::Color;
730    /// let data: Vec<(f64, Option<Color>)> = vec![
731    ///     (12.0, Some(Color::Green)),
732    ///     (9.0, Some(Color::Red)),
733    ///     (14.0, Some(Color::Green)),
734    ///     (f64::NAN, None),
735    ///     (18.0, Some(Color::Cyan)),
736    /// ];
737    /// ui.sparkline_styled(&data, 16);
738    /// # });
739    /// ```
740    pub fn sparkline_styled(&mut self, data: &[(f64, Option<Color>)], width: u32) -> Response {
741        const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
742
743        let w = width as usize;
744        if data.is_empty() || w == 0 {
745            return Response::none();
746        }
747
748        let window: Vec<(f64, Option<Color>)> = if data.len() >= w {
749            data[data.len() - w..].to_vec()
750        } else if data.len() == 1 {
751            vec![data[0]; w]
752        } else {
753            (0..w)
754                .map(|i| {
755                    let t = i as f64 * (data.len() - 1) as f64 / (w - 1) as f64;
756                    let idx = t.floor() as usize;
757                    let frac = t - idx as f64;
758                    let nearest = if frac < 0.5 {
759                        idx
760                    } else {
761                        (idx + 1).min(data.len() - 1)
762                    };
763                    let color = data[nearest].1;
764                    let (v1, _) = data[idx];
765                    let (v2, _) = data[(idx + 1).min(data.len() - 1)];
766                    let value = if v1.is_nan() || v2.is_nan() {
767                        if frac < 0.5 {
768                            v1
769                        } else {
770                            v2
771                        }
772                    } else {
773                        v1 * (1.0 - frac) + v2 * frac
774                    };
775                    (value, color)
776                })
777                .collect()
778        };
779
780        let mut finite_values = window
781            .iter()
782            .map(|(value, _)| *value)
783            .filter(|value| !value.is_nan());
784        let Some(first) = finite_values.next() else {
785            self.styled(
786                " ".repeat(window.len()),
787                Style::new().fg(self.theme.text_dim),
788            );
789            return Response::none();
790        };
791
792        let mut min = first;
793        let mut max = first;
794        for value in finite_values {
795            min = f64::min(min, value);
796            max = f64::max(max, value);
797        }
798        let range = max - min;
799
800        let mut cells: Vec<(char, Color)> = Vec::with_capacity(window.len());
801        for (value, color) in &window {
802            if value.is_nan() {
803                cells.push((' ', self.theme.text_dim));
804                continue;
805            }
806
807            let normalized = if range == 0.0 {
808                0.5
809            } else {
810                ((*value - min) / range).clamp(0.0, 1.0)
811            };
812            let idx = (normalized * 7.0).round() as usize;
813            cells.push((BLOCKS[idx.min(7)], color.unwrap_or(self.theme.primary)));
814        }
815
816        self.interaction_count += 1;
817        self.commands.push(Command::BeginContainer {
818            direction: Direction::Row,
819            gap: 0,
820            align: Align::Start,
821            justify: Justify::Start,
822            border: None,
823            border_sides: BorderSides::all(),
824            border_style: Style::new().fg(self.theme.border),
825            bg_color: None,
826            padding: Padding::default(),
827            margin: Margin::default(),
828            constraints: Constraints::default(),
829            title: None,
830            grow: 0,
831            group_name: None,
832        });
833
834        let mut seg = String::new();
835        let mut seg_color = cells[0].1;
836        for (ch, color) in cells {
837            if color != seg_color {
838                self.styled(seg, Style::new().fg(seg_color));
839                seg = String::new();
840                seg_color = color;
841            }
842            seg.push(ch);
843        }
844        if !seg.is_empty() {
845            self.styled(seg, Style::new().fg(seg_color));
846        }
847
848        self.commands.push(Command::EndContainer);
849        self.last_text_idx = None;
850
851        Response::none()
852    }
853
854    /// Render a multi-row line chart using braille characters.
855    ///
856    /// `width` and `height` are terminal cell dimensions. Internally this uses
857    /// braille dot resolution (`width*2` x `height*4`) for smoother plotting.
858    ///
859    /// # Example
860    ///
861    /// ```ignore
862    /// # slt::run(|ui: &mut slt::Context| {
863    /// let data = [1.0, 3.0, 2.0, 5.0, 4.0, 6.0, 3.0, 7.0];
864    /// ui.line_chart(&data, 40, 8);
865    /// # });
866    /// ```
867    pub fn line_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
868        self.line_chart_colored(data, width, height, self.theme.primary)
869    }
870
871    /// Render a multi-row line chart using a custom color.
872    pub fn line_chart_colored(
873        &mut self,
874        data: &[f64],
875        width: u32,
876        height: u32,
877        color: Color,
878    ) -> Response {
879        self.render_line_chart_internal(data, width, height, color, false)
880    }
881
882    /// Render a multi-row area chart using the primary theme color.
883    pub fn area_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
884        self.area_chart_colored(data, width, height, self.theme.primary)
885    }
886
887    /// Render a multi-row area chart using a custom color.
888    pub fn area_chart_colored(
889        &mut self,
890        data: &[f64],
891        width: u32,
892        height: u32,
893        color: Color,
894    ) -> Response {
895        self.render_line_chart_internal(data, width, height, color, true)
896    }
897
898    fn render_line_chart_internal(
899        &mut self,
900        data: &[f64],
901        width: u32,
902        height: u32,
903        color: Color,
904        fill: bool,
905    ) -> Response {
906        if data.is_empty() || width == 0 || height == 0 {
907            return Response::none();
908        }
909
910        let cols = width as usize;
911        let rows = height as usize;
912        let px_w = cols * 2;
913        let px_h = rows * 4;
914
915        let min = data.iter().copied().fold(f64::INFINITY, f64::min);
916        let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
917        let range = if (max - min).abs() < f64::EPSILON {
918            1.0
919        } else {
920            max - min
921        };
922
923        let points: Vec<usize> = (0..px_w)
924            .map(|px| {
925                let data_idx = if px_w <= 1 {
926                    0.0
927                } else {
928                    px as f64 * (data.len() - 1) as f64 / (px_w - 1) as f64
929                };
930                let idx = data_idx.floor() as usize;
931                let frac = data_idx - idx as f64;
932                let value = if idx + 1 < data.len() {
933                    data[idx] * (1.0 - frac) + data[idx + 1] * frac
934                } else {
935                    data[idx.min(data.len() - 1)]
936                };
937
938                let normalized = (value - min) / range;
939                let py = ((1.0 - normalized) * (px_h - 1) as f64).round() as usize;
940                py.min(px_h - 1)
941            })
942            .collect();
943
944        const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
945        const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
946
947        let mut grid = vec![vec![0u32; cols]; rows];
948
949        for i in 0..points.len() {
950            let px = i;
951            let py = points[i];
952            let char_col = px / 2;
953            let char_row = py / 4;
954            let sub_col = px % 2;
955            let sub_row = py % 4;
956
957            if char_col < cols && char_row < rows {
958                grid[char_row][char_col] |= if sub_col == 0 {
959                    LEFT_BITS[sub_row]
960                } else {
961                    RIGHT_BITS[sub_row]
962                };
963            }
964
965            if i + 1 < points.len() {
966                let py_next = points[i + 1];
967                let (y_start, y_end) = if py <= py_next {
968                    (py, py_next)
969                } else {
970                    (py_next, py)
971                };
972                for y in y_start..=y_end {
973                    let cell_row = y / 4;
974                    let sub_y = y % 4;
975                    if char_col < cols && cell_row < rows {
976                        grid[cell_row][char_col] |= if sub_col == 0 {
977                            LEFT_BITS[sub_y]
978                        } else {
979                            RIGHT_BITS[sub_y]
980                        };
981                    }
982                }
983            }
984
985            if fill {
986                for y in py..px_h {
987                    let cell_row = y / 4;
988                    let sub_y = y % 4;
989                    if char_col < cols && cell_row < rows {
990                        grid[cell_row][char_col] |= if sub_col == 0 {
991                            LEFT_BITS[sub_y]
992                        } else {
993                            RIGHT_BITS[sub_y]
994                        };
995                    }
996                }
997            }
998        }
999
1000        let style = Style::new().fg(color);
1001        for row in grid {
1002            let line: String = row
1003                .iter()
1004                .map(|&bits| char::from_u32(0x2800 + bits).unwrap_or(' '))
1005                .collect();
1006            self.styled(line, style);
1007        }
1008
1009        Response::none()
1010    }
1011
1012    /// Render an OHLC candlestick chart.
1013    pub fn candlestick(
1014        &mut self,
1015        candles: &[Candle],
1016        up_color: Color,
1017        down_color: Color,
1018    ) -> Response {
1019        if candles.is_empty() {
1020            return Response::none();
1021        }
1022
1023        let candles = candles.to_vec();
1024        self.container().grow(1).draw(move |buf, rect| {
1025            let w = rect.width as usize;
1026            let h = rect.height as usize;
1027            if w < 2 || h < 2 {
1028                return;
1029            }
1030
1031            let mut lo = f64::INFINITY;
1032            let mut hi = f64::NEG_INFINITY;
1033            for c in &candles {
1034                if c.low.is_finite() {
1035                    lo = lo.min(c.low);
1036                }
1037                if c.high.is_finite() {
1038                    hi = hi.max(c.high);
1039                }
1040            }
1041
1042            if !lo.is_finite() || !hi.is_finite() {
1043                return;
1044            }
1045
1046            let range = if (hi - lo).abs() < 0.01 { 1.0 } else { hi - lo };
1047            let map_y = |v: f64| -> usize {
1048                let t = ((v - lo) / range).clamp(0.0, 1.0);
1049                ((1.0 - t) * (h.saturating_sub(1)) as f64).round() as usize
1050            };
1051
1052            for (i, c) in candles.iter().enumerate() {
1053                if !c.open.is_finite()
1054                    || !c.high.is_finite()
1055                    || !c.low.is_finite()
1056                    || !c.close.is_finite()
1057                {
1058                    continue;
1059                }
1060
1061                let x0 = i * w / candles.len();
1062                let x1 = ((i + 1) * w / candles.len()).saturating_sub(1).max(x0);
1063                if x0 >= w {
1064                    continue;
1065                }
1066                let xm = (x0 + x1) / 2;
1067                let color = if c.close >= c.open {
1068                    up_color
1069                } else {
1070                    down_color
1071                };
1072
1073                let wt = map_y(c.high);
1074                let wb = map_y(c.low);
1075                for row in wt..=wb.min(h - 1) {
1076                    buf.set_char(
1077                        rect.x + xm as u32,
1078                        rect.y + row as u32,
1079                        '│',
1080                        Style::new().fg(color),
1081                    );
1082                }
1083
1084                let bt = map_y(c.open.max(c.close));
1085                let bb = map_y(c.open.min(c.close));
1086                for row in bt..=bb.min(h - 1) {
1087                    for col in x0..=x1.min(w - 1) {
1088                        buf.set_char(
1089                            rect.x + col as u32,
1090                            rect.y + row as u32,
1091                            '█',
1092                            Style::new().fg(color),
1093                        );
1094                    }
1095                }
1096            }
1097        });
1098
1099        Response::none()
1100    }
1101
1102    /// Render a heatmap from a 2D data grid.
1103    ///
1104    /// Each cell maps to a block character with color intensity:
1105    /// low values -> dim/dark, high values -> bright/saturated.
1106    ///
1107    /// # Arguments
1108    /// * `data` - Row-major 2D grid (outer = rows, inner = columns)
1109    /// * `width` - Widget width in terminal cells
1110    /// * `height` - Widget height in terminal cells
1111    /// * `low_color` - Color for minimum values
1112    /// * `high_color` - Color for maximum values
1113    pub fn heatmap(
1114        &mut self,
1115        data: &[Vec<f64>],
1116        width: u32,
1117        height: u32,
1118        low_color: Color,
1119        high_color: Color,
1120    ) -> Response {
1121        fn blend_color(a: Color, b: Color, t: f64) -> Color {
1122            let t = t.clamp(0.0, 1.0);
1123            match (a, b) {
1124                (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => Color::Rgb(
1125                    (r1 as f64 * (1.0 - t) + r2 as f64 * t).round() as u8,
1126                    (g1 as f64 * (1.0 - t) + g2 as f64 * t).round() as u8,
1127                    (b1 as f64 * (1.0 - t) + b2 as f64 * t).round() as u8,
1128                ),
1129                _ => {
1130                    if t > 0.5 {
1131                        b
1132                    } else {
1133                        a
1134                    }
1135                }
1136            }
1137        }
1138
1139        if data.is_empty() || width == 0 || height == 0 {
1140            return Response::none();
1141        }
1142
1143        let data_rows = data.len();
1144        let max_data_cols = data.iter().map(Vec::len).max().unwrap_or(0);
1145        if max_data_cols == 0 {
1146            return Response::none();
1147        }
1148
1149        let mut min_value = f64::INFINITY;
1150        let mut max_value = f64::NEG_INFINITY;
1151        for row in data {
1152            for value in row {
1153                if value.is_finite() {
1154                    min_value = min_value.min(*value);
1155                    max_value = max_value.max(*value);
1156                }
1157            }
1158        }
1159
1160        if !min_value.is_finite() || !max_value.is_finite() {
1161            return Response::none();
1162        }
1163
1164        let range = max_value - min_value;
1165        let zero_range = range.abs() < f64::EPSILON;
1166        let cols = width as usize;
1167        let rows = height as usize;
1168
1169        for row_idx in 0..rows {
1170            let data_row_idx = (row_idx * data_rows / rows).min(data_rows.saturating_sub(1));
1171            let source_row = &data[data_row_idx];
1172            let source_cols = source_row.len();
1173
1174            self.interaction_count += 1;
1175            self.commands.push(Command::BeginContainer {
1176                direction: Direction::Row,
1177                gap: 0,
1178                align: Align::Start,
1179                justify: Justify::Start,
1180                border: None,
1181                border_sides: BorderSides::all(),
1182                border_style: Style::new().fg(self.theme.border),
1183                bg_color: None,
1184                padding: Padding::default(),
1185                margin: Margin::default(),
1186                constraints: Constraints::default(),
1187                title: None,
1188                grow: 0,
1189                group_name: None,
1190            });
1191
1192            let mut segment = String::new();
1193            let mut segment_color: Option<Color> = None;
1194
1195            for col_idx in 0..cols {
1196                let normalized = if source_cols == 0 {
1197                    0.0
1198                } else {
1199                    let data_col_idx = (col_idx * source_cols / cols).min(source_cols - 1);
1200                    let value = source_row[data_col_idx];
1201
1202                    if !value.is_finite() {
1203                        0.0
1204                    } else if zero_range {
1205                        0.5
1206                    } else {
1207                        ((value - min_value) / range).clamp(0.0, 1.0)
1208                    }
1209                };
1210
1211                let color = blend_color(low_color, high_color, normalized);
1212
1213                match segment_color {
1214                    Some(current) if current == color => {
1215                        segment.push('█');
1216                    }
1217                    Some(current) => {
1218                        self.styled(std::mem::take(&mut segment), Style::new().fg(current));
1219                        segment.push('█');
1220                        segment_color = Some(color);
1221                    }
1222                    None => {
1223                        segment.push('█');
1224                        segment_color = Some(color);
1225                    }
1226                }
1227            }
1228
1229            if let Some(color) = segment_color {
1230                self.styled(segment, Style::new().fg(color));
1231            }
1232
1233            self.commands.push(Command::EndContainer);
1234            self.last_text_idx = None;
1235        }
1236
1237        Response::none()
1238    }
1239
1240    /// Render a braille drawing canvas.
1241    ///
1242    /// The closure receives a [`CanvasContext`] for pixel-level drawing. Each
1243    /// terminal cell maps to a 2x4 braille dot matrix, giving `width*2` x
1244    /// `height*4` pixel resolution.
1245    ///
1246    /// # Example
1247    ///
1248    /// ```ignore
1249    /// # slt::run(|ui: &mut slt::Context| {
1250    /// ui.canvas(40, 10, |cv| {
1251    ///     cv.line(0, 0, cv.width() - 1, cv.height() - 1);
1252    ///     cv.circle(40, 20, 15);
1253    /// });
1254    /// # });
1255    /// ```
1256    pub fn canvas(
1257        &mut self,
1258        width: u32,
1259        height: u32,
1260        draw: impl FnOnce(&mut CanvasContext),
1261    ) -> Response {
1262        if width == 0 || height == 0 {
1263            return Response::none();
1264        }
1265
1266        let mut canvas = CanvasContext::new(width as usize, height as usize);
1267        draw(&mut canvas);
1268
1269        for segments in canvas.render() {
1270            self.interaction_count += 1;
1271            self.commands.push(Command::BeginContainer {
1272                direction: Direction::Row,
1273                gap: 0,
1274                align: Align::Start,
1275                justify: Justify::Start,
1276                border: None,
1277                border_sides: BorderSides::all(),
1278                border_style: Style::new(),
1279                bg_color: None,
1280                padding: Padding::default(),
1281                margin: Margin::default(),
1282                constraints: Constraints::default(),
1283                title: None,
1284                grow: 0,
1285                group_name: None,
1286            });
1287            for (text, color) in segments {
1288                let c = if color == Color::Reset {
1289                    self.theme.primary
1290                } else {
1291                    color
1292                };
1293                self.styled(text, Style::new().fg(c));
1294            }
1295            self.commands.push(Command::EndContainer);
1296            self.last_text_idx = None;
1297        }
1298
1299        Response::none()
1300    }
1301
1302    /// Render a multi-series chart with axes, legend, and auto-scaling.
1303    ///
1304    /// `width` and `height` must be non-zero. For dynamic sizing, read terminal
1305    /// dimensions first (for example via `ui.width()` / `ui.height()`) and pass
1306    /// the computed values to this method.
1307    pub fn chart(
1308        &mut self,
1309        configure: impl FnOnce(&mut ChartBuilder),
1310        width: u32,
1311        height: u32,
1312    ) -> Response {
1313        if width == 0 || height == 0 {
1314            return Response::none();
1315        }
1316
1317        let axis_style = Style::new().fg(self.theme.text_dim);
1318        let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
1319        configure(&mut builder);
1320
1321        let config = builder.build();
1322        let rows = render_chart(&config);
1323
1324        for row in rows {
1325            self.interaction_count += 1;
1326            self.commands.push(Command::BeginContainer {
1327                direction: Direction::Row,
1328                gap: 0,
1329                align: Align::Start,
1330                justify: Justify::Start,
1331                border: None,
1332                border_sides: BorderSides::all(),
1333                border_style: Style::new().fg(self.theme.border),
1334                bg_color: None,
1335                padding: Padding::default(),
1336                margin: Margin::default(),
1337                constraints: Constraints::default(),
1338                title: None,
1339                grow: 0,
1340                group_name: None,
1341            });
1342            for (text, style) in row.segments {
1343                self.styled(text, style);
1344            }
1345            self.commands.push(Command::EndContainer);
1346            self.last_text_idx = None;
1347        }
1348
1349        Response::none()
1350    }
1351
1352    /// Renders a scatter plot.
1353    ///
1354    /// Each point is a (x, y) tuple. Uses braille markers.
1355    pub fn scatter(&mut self, data: &[(f64, f64)], width: u32, height: u32) -> Response {
1356        self.chart(
1357            |c| {
1358                c.scatter(data);
1359                c.grid(true);
1360            },
1361            width,
1362            height,
1363        )
1364    }
1365
1366    /// Render a histogram from raw data with auto-binning.
1367    pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> Response {
1368        self.histogram_with(data, |_| {}, width, height)
1369    }
1370
1371    /// Render a histogram with configuration options.
1372    pub fn histogram_with(
1373        &mut self,
1374        data: &[f64],
1375        configure: impl FnOnce(&mut HistogramBuilder),
1376        width: u32,
1377        height: u32,
1378    ) -> Response {
1379        if width == 0 || height == 0 {
1380            return Response::none();
1381        }
1382
1383        let mut options = HistogramBuilder::default();
1384        configure(&mut options);
1385        let axis_style = Style::new().fg(self.theme.text_dim);
1386        let config = build_histogram_config(data, &options, width, height, axis_style);
1387        let rows = render_chart(&config);
1388
1389        for row in rows {
1390            self.interaction_count += 1;
1391            self.commands.push(Command::BeginContainer {
1392                direction: Direction::Row,
1393                gap: 0,
1394                align: Align::Start,
1395                justify: Justify::Start,
1396                border: None,
1397                border_sides: BorderSides::all(),
1398                border_style: Style::new().fg(self.theme.border),
1399                bg_color: None,
1400                padding: Padding::default(),
1401                margin: Margin::default(),
1402                constraints: Constraints::default(),
1403                title: None,
1404                grow: 0,
1405                group_name: None,
1406            });
1407            for (text, style) in row.segments {
1408                self.styled(text, style);
1409            }
1410            self.commands.push(Command::EndContainer);
1411            self.last_text_idx = None;
1412        }
1413
1414        Response::none()
1415    }
1416}