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