Skip to main content

slt/context/
widgets_viz.rs

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