Skip to main content

slt/context/
widgets_viz.rs

1//! Visualization widgets — charts, sparklines, heatmaps, treemaps,
2//! candlesticks, stacked bars, canvases, and QR codes.
3//!
4//! Layer 3 widgets that render dense numeric or geometric data. Most
5//! draw via the chart kernel ([`crate::chart`]) or the buffer-level
6//! drawing primitives in `super::container`.
7
8use super::*;
9
10struct VerticalBarLayout {
11    chart_height: usize,
12    bar_width: usize,
13    value_labels: Vec<String>,
14    col_width: usize,
15    bar_units: Vec<usize>,
16}
17
18fn finite_or_zero(value: f64) -> f64 {
19    if value.is_finite() { value } else { 0.0 }
20}
21
22fn positive_finite_max(values: impl Iterator<Item = f64>) -> f64 {
23    values
24        .filter(|value| value.is_finite() && *value > 0.0)
25        .fold(1.0, f64::max)
26}
27
28fn bounded_positive_sum(values: impl Iterator<Item = f64>) -> f64 {
29    const MAX_SAFE_SUM: f64 = f64::MAX / 16.0;
30    values
31        .filter(|value| value.is_finite() && *value > 0.0)
32        .fold(0.0, |sum, value| {
33            if sum >= MAX_SAFE_SUM - value.min(MAX_SAFE_SUM) {
34                MAX_SAFE_SUM
35            } else {
36                sum + value
37            }
38        })
39}
40
41fn unit_ratio(value: f64, min: f64, max: f64) -> f64 {
42    crate::chart::finite_ratio(value, min, max).unwrap_or(0.0)
43}
44
45fn finite_lerp(a: f64, b: f64, t: f64) -> f64 {
46    match (a.is_finite(), b.is_finite()) {
47        (true, true) => {
48            let scale = a.abs().max(b.abs()).max(1.0);
49            ((a / scale) * (1.0 - t) + (b / scale) * t) * scale
50        }
51        (true, false) => a,
52        (false, true) => b,
53        (false, false) => f64::NAN,
54    }
55}
56
57impl Context {
58    fn begin_viz_root(&mut self) -> Response {
59        let response = self.interaction();
60        self.commands
61            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
62                direction: Direction::Column,
63                gap: 0,
64                align: Align::Start,
65                align_self: None,
66                justify: Justify::Start,
67                border: None,
68                border_sides: BorderSides::all(),
69                border_style: Style::new().fg(self.theme.border),
70                bg_color: None,
71                padding: Padding::default(),
72                margin: Margin::default(),
73                constraints: Constraints::default(),
74                title: None,
75                grow: 0,
76                group_name: None,
77            })));
78        response
79    }
80
81    fn begin_sized_viz_root(&mut self, width: u32, height: u32) -> Response {
82        let response = self.begin_viz_root();
83        if let Some(Command::BeginContainer(args)) = self.commands.last_mut() {
84            args.constraints = Constraints::default().w(width).h(height);
85        }
86        response
87    }
88
89    fn end_viz_root(&mut self) {
90        self.commands.push(Command::EndContainer);
91        self.rollback.last_text_idx = None;
92    }
93
94    /// Render a horizontal bar chart from `(label, value)` pairs.
95    ///
96    /// Bars are normalized against the largest value and rendered with `█` up to
97    /// `max_width` characters.
98    ///
99    /// # Example
100    ///
101    /// ```no_run
102    /// # slt::run(|ui: &mut slt::Context| {
103    /// let data = [
104    ///     ("Sales", 160.0),
105    ///     ("Revenue", 120.0),
106    ///     ("Users", 220.0),
107    ///     ("Costs", 60.0),
108    /// ];
109    /// ui.bar_chart(&data, 24);
110    /// # });
111    /// ```
112    ///
113    /// For styled bars with per-bar colors, see [`bar_chart_with`](Self::bar_chart_with).
114    pub fn bar_chart(&mut self, data: &[(&str, f64)], max_width: u32) -> Response {
115        if data.is_empty() {
116            return Response::none();
117        }
118        let response = self.begin_viz_root();
119
120        let max_label_width = data
121            .iter()
122            .map(|(label, _)| UnicodeWidthStr::width(*label))
123            .max()
124            .unwrap_or(0);
125        let denom = positive_finite_max(data.iter().map(|(_, value)| *value));
126
127        self.skip_interaction_slot();
128        self.commands
129            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
130                direction: Direction::Column,
131                gap: 0,
132                align: Align::Start,
133                align_self: None,
134                justify: Justify::Start,
135                border: None,
136                border_sides: BorderSides::all(),
137                border_style: Style::new().fg(self.theme.border),
138                bg_color: None,
139                padding: Padding::default(),
140                margin: Margin::default(),
141                constraints: Constraints::default(),
142                title: None,
143                grow: 0,
144                group_name: None,
145            })));
146
147        for (label, value) in data {
148            let label_width = UnicodeWidthStr::width(*label);
149            let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
150            let value = finite_or_zero(*value);
151            let normalized = unit_ratio(value, 0.0, denom);
152            let bar = Self::horizontal_bar_text(normalized, max_width);
153
154            self.skip_interaction_slot();
155            self.commands
156                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
157                    direction: Direction::Row,
158                    gap: 1,
159                    align: Align::Start,
160                    align_self: None,
161                    justify: Justify::Start,
162                    border: None,
163                    border_sides: BorderSides::all(),
164                    border_style: Style::new().fg(self.theme.border),
165                    bg_color: None,
166                    padding: Padding::default(),
167                    margin: Margin::default(),
168                    constraints: Constraints::default(),
169                    title: None,
170                    grow: 0,
171                    group_name: None,
172                })));
173            let mut label_text = String::with_capacity(label.len() + label_padding.len());
174            label_text.push_str(label);
175            label_text.push_str(&label_padding);
176            self.styled(label_text, Style::new().fg(self.theme.text));
177            self.styled(bar, Style::new().fg(self.theme.primary));
178            self.styled(
179                format_compact_number(value),
180                Style::new().fg(self.theme.text_dim),
181            );
182            self.commands.push(Command::EndContainer);
183            self.rollback.last_text_idx = None;
184        }
185
186        self.commands.push(Command::EndContainer);
187        self.rollback.last_text_idx = None;
188        self.end_viz_root();
189
190        response
191    }
192
193    /// Render a bar chart with custom configuration.
194    pub fn bar_chart_with(
195        &mut self,
196        bars: &[Bar],
197        configure: impl FnOnce(&mut BarChartConfig),
198        max_size: u32,
199    ) -> Response {
200        if bars.is_empty() {
201            return Response::none();
202        }
203
204        let (config, denom) = self.bar_chart_styled_layout(bars, configure);
205        let response = self.begin_viz_root();
206        self.bar_chart_styled_render(bars, max_size, denom, &config);
207        self.end_viz_root();
208
209        response
210    }
211
212    fn bar_chart_styled_layout(
213        &self,
214        bars: &[Bar],
215        configure: impl FnOnce(&mut BarChartConfig),
216    ) -> (BarChartConfig, f64) {
217        let mut config = BarChartConfig::default();
218        configure(&mut config);
219
220        let auto_max = positive_finite_max(bars.iter().map(|bar| bar.value));
221        let configured_max = config
222            .max_value
223            .filter(|value| value.is_finite() && *value > 0.0);
224        let denom = configured_max.unwrap_or(auto_max);
225
226        (config, denom)
227    }
228
229    fn bar_chart_styled_render(
230        &mut self,
231        bars: &[Bar],
232        max_size: u32,
233        denom: f64,
234        config: &BarChartConfig,
235    ) {
236        match config.direction {
237            BarDirection::Horizontal => {
238                self.render_horizontal_styled_bars(bars, max_size, denom, config.bar_gap)
239            }
240            BarDirection::Vertical => self.render_vertical_styled_bars(
241                bars,
242                max_size,
243                denom,
244                config.bar_width,
245                config.bar_gap,
246            ),
247        }
248    }
249
250    fn render_horizontal_styled_bars(
251        &mut self,
252        bars: &[Bar],
253        max_width: u32,
254        denom: f64,
255        bar_gap: u16,
256    ) {
257        let max_label_width = bars
258            .iter()
259            .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
260            .max()
261            .unwrap_or(0);
262
263        self.skip_interaction_slot();
264        self.commands
265            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
266                direction: Direction::Column,
267                gap: bar_gap as i32,
268                align: Align::Start,
269                align_self: None,
270                justify: Justify::Start,
271                border: None,
272                border_sides: BorderSides::all(),
273                border_style: Style::new().fg(self.theme.border),
274                bg_color: None,
275                padding: Padding::default(),
276                margin: Margin::default(),
277                constraints: Constraints::default(),
278                title: None,
279                grow: 0,
280                group_name: None,
281            })));
282
283        for bar in bars {
284            self.render_horizontal_styled_bar_row(bar, max_label_width, max_width, denom);
285        }
286
287        self.commands.push(Command::EndContainer);
288        self.rollback.last_text_idx = None;
289    }
290
291    fn render_horizontal_styled_bar_row(
292        &mut self,
293        bar: &Bar,
294        max_label_width: usize,
295        max_width: u32,
296        denom: f64,
297    ) {
298        let label_width = UnicodeWidthStr::width(bar.label.as_str());
299        let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
300        let normalized = unit_ratio(finite_or_zero(bar.value), 0.0, denom);
301        let bar_text = Self::horizontal_bar_text(normalized, max_width);
302        let color = bar.color.unwrap_or(self.theme.primary);
303
304        self.skip_interaction_slot();
305        self.commands
306            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
307                direction: Direction::Row,
308                gap: 1,
309                align: Align::Start,
310                align_self: None,
311                justify: Justify::Start,
312                border: None,
313                border_sides: BorderSides::all(),
314                border_style: Style::new().fg(self.theme.border),
315                bg_color: None,
316                padding: Padding::default(),
317                margin: Margin::default(),
318                constraints: Constraints::default(),
319                title: None,
320                grow: 0,
321                group_name: None,
322            })));
323        let mut label_text = String::with_capacity(bar.label.len() + label_padding.len());
324        label_text.push_str(&bar.label);
325        label_text.push_str(&label_padding);
326        self.styled(label_text, Style::new().fg(self.theme.text));
327        self.styled(bar_text, Style::new().fg(color));
328        self.styled(
329            Self::bar_display_value(bar),
330            bar.value_style
331                .unwrap_or(Style::new().fg(self.theme.text_dim)),
332        );
333        self.commands.push(Command::EndContainer);
334        self.rollback.last_text_idx = None;
335    }
336
337    fn render_vertical_styled_bars(
338        &mut self,
339        bars: &[Bar],
340        max_height: u32,
341        denom: f64,
342        bar_width: u16,
343        bar_gap: u16,
344    ) {
345        let layout = self.compute_vertical_bar_layout(bars, max_height, denom, bar_width);
346
347        self.skip_interaction_slot();
348        self.commands
349            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
350                direction: Direction::Column,
351                gap: 0,
352                align: Align::Start,
353                align_self: None,
354                justify: Justify::Start,
355                border: None,
356                border_sides: BorderSides::all(),
357                border_style: Style::new().fg(self.theme.border),
358                bg_color: None,
359                padding: Padding::default(),
360                margin: Margin::default(),
361                constraints: Constraints::default(),
362                title: None,
363                grow: 0,
364                group_name: None,
365            })));
366
367        self.render_vertical_bar_body(
368            bars,
369            &layout.bar_units,
370            layout.chart_height,
371            layout.col_width,
372            layout.bar_width,
373            bar_gap,
374            &layout.value_labels,
375        );
376        self.render_vertical_bar_labels(bars, layout.col_width, bar_gap);
377
378        self.commands.push(Command::EndContainer);
379        self.rollback.last_text_idx = None;
380    }
381
382    fn compute_vertical_bar_layout(
383        &self,
384        bars: &[Bar],
385        max_height: u32,
386        denom: f64,
387        bar_width: u16,
388    ) -> VerticalBarLayout {
389        let chart_height = max_height.max(1) as usize;
390        let bar_width = bar_width.max(1) as usize;
391        let value_labels: Vec<String> = bars.iter().map(Self::bar_display_value).collect();
392        let label_width = bars
393            .iter()
394            .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
395            .max()
396            .unwrap_or(1);
397        let value_width = value_labels
398            .iter()
399            .map(|value| UnicodeWidthStr::width(value.as_str()))
400            .max()
401            .unwrap_or(1);
402        let col_width = bar_width.max(label_width.max(value_width).max(1));
403        let bar_units: Vec<usize> = bars
404            .iter()
405            .map(|bar| {
406                (unit_ratio(finite_or_zero(bar.value), 0.0, denom) * chart_height as f64 * 8.0)
407                    .round() as usize
408            })
409            .collect();
410
411        VerticalBarLayout {
412            chart_height,
413            bar_width,
414            value_labels,
415            col_width,
416            bar_units,
417        }
418    }
419
420    #[allow(clippy::too_many_arguments)]
421    fn render_vertical_bar_body(
422        &mut self,
423        bars: &[Bar],
424        bar_units: &[usize],
425        chart_height: usize,
426        col_width: usize,
427        bar_width: usize,
428        bar_gap: u16,
429        value_labels: &[String],
430    ) {
431        const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
432
433        // Pre-compute the topmost filled row for each bar (for value label placement).
434        let top_rows: Vec<usize> = bar_units
435            .iter()
436            .map(|units| {
437                if *units == 0 {
438                    usize::MAX
439                } else {
440                    (*units - 1) / 8
441                }
442            })
443            .collect();
444
445        for row in (0..chart_height).rev() {
446            self.skip_interaction_slot();
447            self.commands
448                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
449                    direction: Direction::Row,
450                    gap: bar_gap as i32,
451                    align: Align::Start,
452                    align_self: None,
453                    justify: Justify::Start,
454                    border: None,
455                    border_sides: BorderSides::all(),
456                    border_style: Style::new().fg(self.theme.border),
457                    bg_color: None,
458                    padding: Padding::default(),
459                    margin: Margin::default(),
460                    constraints: Constraints::default(),
461                    title: None,
462                    grow: 0,
463                    group_name: None,
464                })));
465
466            let row_base = row * 8;
467            for (i, (bar, units)) in bars.iter().zip(bar_units.iter()).enumerate() {
468                let color = bar.color.unwrap_or(self.theme.primary);
469
470                if *units <= row_base {
471                    // Value label one row above the bar top (plain text, no bg).
472                    if top_rows[i] != usize::MAX && row == top_rows[i] + 1 {
473                        let label = &value_labels[i];
474                        let centered = Self::center_and_truncate_text(label, col_width);
475                        self.styled(
476                            centered,
477                            bar.value_style.unwrap_or(Style::new().fg(color).bold()),
478                        );
479                    } else {
480                        let empty = " ".repeat(col_width);
481                        self.styled(empty, Style::new());
482                    }
483                    continue;
484                }
485
486                if row == top_rows[i] && top_rows[i] + 1 >= chart_height {
487                    let label = &value_labels[i];
488                    let centered = Self::center_and_truncate_text(label, col_width);
489                    self.styled(
490                        centered,
491                        bar.value_style.unwrap_or(Style::new().fg(color).bold()),
492                    );
493                    continue;
494                }
495
496                let delta = *units - row_base;
497                let fill = if delta >= 8 {
498                    '█'
499                } else {
500                    FRACTION_BLOCKS[delta]
501                };
502                let fill_text = fill.to_string().repeat(bar_width);
503                let centered_fill = center_text(&fill_text, col_width);
504                self.styled(centered_fill, Style::new().fg(color));
505            }
506
507            self.commands.push(Command::EndContainer);
508            self.rollback.last_text_idx = None;
509        }
510    }
511
512    fn render_vertical_bar_labels(&mut self, bars: &[Bar], col_width: usize, bar_gap: u16) {
513        self.skip_interaction_slot();
514        self.commands
515            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
516                direction: Direction::Row,
517                gap: bar_gap as i32,
518                align: Align::Start,
519                align_self: None,
520                justify: Justify::Start,
521                border: None,
522                border_sides: BorderSides::all(),
523                border_style: Style::new().fg(self.theme.border),
524                bg_color: None,
525                padding: Padding::default(),
526                margin: Margin::default(),
527                constraints: Constraints::default(),
528                title: None,
529                grow: 0,
530                group_name: None,
531            })));
532        for bar in bars {
533            self.styled(
534                Self::center_and_truncate_text(&bar.label, col_width),
535                Style::new().fg(self.theme.text),
536            );
537        }
538        self.commands.push(Command::EndContainer);
539        self.rollback.last_text_idx = None;
540    }
541
542    /// Render a grouped bar chart.
543    ///
544    /// Each group contains multiple bars rendered side by side. Useful for
545    /// comparing categories across groups (e.g., quarterly revenue by product).
546    ///
547    /// # Example
548    /// ```no_run
549    /// # slt::run(|ui: &mut slt::Context| {
550    /// use slt::{Bar, BarGroup, Color};
551    /// let groups = vec![
552    ///     BarGroup::new("2023", vec![Bar::new("Rev", 100.0).color(Color::Cyan), Bar::new("Cost", 60.0).color(Color::Red)]),
553    ///     BarGroup::new("2024", vec![Bar::new("Rev", 140.0).color(Color::Cyan), Bar::new("Cost", 80.0).color(Color::Red)]),
554    /// ];
555    /// ui.bar_chart_grouped(&groups, 40);
556    /// # });
557    /// ```
558    pub fn bar_chart_grouped(&mut self, groups: &[BarGroup], max_width: u32) -> Response {
559        self.bar_chart_grouped_with(groups, |_| {}, max_width)
560    }
561
562    /// Render a grouped bar chart with custom configuration.
563    pub fn bar_chart_grouped_with(
564        &mut self,
565        groups: &[BarGroup],
566        configure: impl FnOnce(&mut BarChartConfig),
567        max_size: u32,
568    ) -> Response {
569        if groups.is_empty() {
570            return Response::none();
571        }
572
573        let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
574        if all_bars.is_empty() {
575            return Response::none();
576        }
577
578        let mut config = BarChartConfig::default();
579        configure(&mut config);
580
581        let auto_max = positive_finite_max(all_bars.iter().map(|bar| bar.value));
582        let denom = config
583            .max_value
584            .filter(|value| value.is_finite() && *value > 0.0)
585            .unwrap_or(auto_max);
586
587        let response = self.begin_viz_root();
588        match config.direction {
589            BarDirection::Horizontal => {
590                self.render_grouped_horizontal_bars(groups, max_size, denom, &config)
591            }
592            BarDirection::Vertical => {
593                self.render_grouped_vertical_bars(groups, max_size, denom, &config)
594            }
595        }
596        self.end_viz_root();
597
598        response
599    }
600
601    fn render_grouped_horizontal_bars(
602        &mut self,
603        groups: &[BarGroup],
604        max_width: u32,
605        denom: f64,
606        config: &BarChartConfig,
607    ) {
608        let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
609        let max_label_width = all_bars
610            .iter()
611            .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
612            .max()
613            .unwrap_or(0);
614
615        self.skip_interaction_slot();
616        self.commands
617            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
618                direction: Direction::Column,
619                gap: config.group_gap as i32,
620                align: Align::Start,
621                align_self: None,
622                justify: Justify::Start,
623                border: None,
624                border_sides: BorderSides::all(),
625                border_style: Style::new().fg(self.theme.border),
626                bg_color: None,
627                padding: Padding::default(),
628                margin: Margin::default(),
629                constraints: Constraints::default(),
630                title: None,
631                grow: 0,
632                group_name: None,
633            })));
634
635        for group in groups {
636            self.skip_interaction_slot();
637            self.commands
638                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
639                    direction: Direction::Column,
640                    gap: config.bar_gap as i32,
641                    align: Align::Start,
642                    align_self: None,
643                    justify: Justify::Start,
644                    border: None,
645                    border_sides: BorderSides::all(),
646                    border_style: Style::new().fg(self.theme.border),
647                    bg_color: None,
648                    padding: Padding::default(),
649                    margin: Margin::default(),
650                    constraints: Constraints::default(),
651                    title: None,
652                    grow: 0,
653                    group_name: None,
654                })));
655
656            self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
657
658            for bar in &group.bars {
659                let label_width = UnicodeWidthStr::width(bar.label.as_str());
660                let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
661                let normalized = unit_ratio(finite_or_zero(bar.value), 0.0, denom);
662                let bar_text = Self::horizontal_bar_text(normalized, max_width);
663
664                self.skip_interaction_slot();
665                self.commands
666                    .push(Command::BeginContainer(Box::new(BeginContainerArgs {
667                        direction: Direction::Row,
668                        gap: 1,
669                        align: Align::Start,
670                        align_self: None,
671                        justify: Justify::Start,
672                        border: None,
673                        border_sides: BorderSides::all(),
674                        border_style: Style::new().fg(self.theme.border),
675                        bg_color: None,
676                        padding: Padding::default(),
677                        margin: Margin::default(),
678                        constraints: Constraints::default(),
679                        title: None,
680                        grow: 0,
681                        group_name: None,
682                    })));
683                let mut label_text =
684                    String::with_capacity(2 + bar.label.len() + label_padding.len());
685                label_text.push_str("  ");
686                label_text.push_str(&bar.label);
687                label_text.push_str(&label_padding);
688                self.styled(label_text, Style::new().fg(self.theme.text));
689                self.styled(
690                    bar_text,
691                    Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
692                );
693                self.styled(
694                    Self::bar_display_value(bar),
695                    bar.value_style
696                        .unwrap_or(Style::new().fg(self.theme.text_dim)),
697                );
698                self.commands.push(Command::EndContainer);
699                self.rollback.last_text_idx = None;
700            }
701
702            self.commands.push(Command::EndContainer);
703            self.rollback.last_text_idx = None;
704        }
705
706        self.commands.push(Command::EndContainer);
707        self.rollback.last_text_idx = None;
708    }
709
710    fn render_grouped_vertical_bars(
711        &mut self,
712        groups: &[BarGroup],
713        max_height: u32,
714        denom: f64,
715        config: &BarChartConfig,
716    ) {
717        self.skip_interaction_slot();
718        self.commands
719            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
720                direction: Direction::Column,
721                gap: config.group_gap as i32,
722                align: Align::Start,
723                align_self: None,
724                justify: Justify::Start,
725                border: None,
726                border_sides: BorderSides::all(),
727                border_style: Style::new().fg(self.theme.border),
728                bg_color: None,
729                padding: Padding::default(),
730                margin: Margin::default(),
731                constraints: Constraints::default(),
732                title: None,
733                grow: 0,
734                group_name: None,
735            })));
736
737        for group in groups {
738            self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
739            if !group.bars.is_empty() {
740                self.render_vertical_styled_bars(
741                    &group.bars,
742                    max_height,
743                    denom,
744                    config.bar_width,
745                    config.bar_gap,
746                );
747            }
748        }
749
750        self.commands.push(Command::EndContainer);
751        self.rollback.last_text_idx = None;
752    }
753
754    fn horizontal_bar_text(normalized: f64, max_width: u32) -> String {
755        let normalized = if normalized.is_finite() {
756            normalized.clamp(0.0, 1.0)
757        } else {
758            0.0
759        };
760        let filled = (normalized * max_width as f64).round() as usize;
761        "█".repeat(filled)
762    }
763
764    fn bar_display_value(bar: &Bar) -> String {
765        bar.text_value
766            .clone()
767            .unwrap_or_else(|| format_compact_number(finite_or_zero(bar.value)))
768    }
769
770    fn center_and_truncate_text(text: &str, width: usize) -> String {
771        if width == 0 {
772            return String::new();
773        }
774
775        let out = crate::chart::clip_text_cells(text, width);
776        center_text(&out, width)
777    }
778
779    /// Render a single-line sparkline from numeric data.
780    ///
781    /// Uses the last `width` points (or fewer if the data is shorter) and maps
782    /// each point to one of `▁▂▃▄▅▆▇█`.
783    ///
784    /// # Example
785    ///
786    /// ```no_run
787    /// # slt::run(|ui: &mut slt::Context| {
788    /// let samples = [12.0, 9.0, 14.0, 18.0, 16.0, 21.0, 20.0, 24.0];
789    /// ui.sparkline(&samples, 16);
790    /// # });
791    /// ```
792    ///
793    /// For per-point colors and missing values, see [`sparkline_styled`](Self::sparkline_styled).
794    pub fn sparkline(&mut self, data: &[f64], width: u32) -> Response {
795        const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
796
797        let w = width as usize;
798        if data.is_empty() || w == 0 {
799            return Response::none();
800        }
801
802        let points: Vec<f64> = if data.len() >= w {
803            data[data.len() - w..].to_vec()
804        } else if data.len() == 1 {
805            vec![data[0]; w]
806        } else {
807            (0..w)
808                .map(|i| {
809                    let t = i as f64 * (data.len() - 1) as f64 / (w - 1) as f64;
810                    let idx = t.floor() as usize;
811                    let frac = t - idx as f64;
812                    if idx + 1 < data.len() {
813                        finite_lerp(data[idx], data[idx + 1], frac)
814                    } else {
815                        data[idx.min(data.len() - 1)]
816                    }
817                })
818                .collect()
819        };
820
821        let mut finite = points.iter().copied().filter(|value| value.is_finite());
822        let Some(first) = finite.next() else {
823            let response = self.interaction();
824            self.styled(" ".repeat(w), Style::new().fg(self.theme.text_dim))
825                .w(width);
826            return response;
827        };
828        let (mut min, mut max) = (first, first);
829        for value in finite {
830            min = min.min(value);
831            max = max.max(value);
832        }
833
834        let line: String = points
835            .iter()
836            .map(|&value| {
837                if !value.is_finite() {
838                    return ' ';
839                }
840                let normalized = if min == max {
841                    0.5
842                } else {
843                    unit_ratio(value, min, max)
844                };
845                let idx = (normalized * 7.0).round() as usize;
846                BLOCKS[idx.min(7)]
847            })
848            .collect();
849
850        let response = self.interaction();
851        self.styled(line, Style::new().fg(self.theme.primary))
852            .w(width);
853        response
854    }
855
856    /// Render a sparkline with per-point colors.
857    ///
858    /// Each point can have its own color via `(f64, Option<Color>)` tuples.
859    /// Use `f64::NAN` for absent values (rendered as spaces).
860    ///
861    /// # Example
862    /// ```no_run
863    /// # slt::run(|ui: &mut slt::Context| {
864    /// use slt::Color;
865    /// let data: Vec<(f64, Option<Color>)> = vec![
866    ///     (12.0, Some(Color::Green)),
867    ///     (9.0, Some(Color::Red)),
868    ///     (14.0, Some(Color::Green)),
869    ///     (f64::NAN, None),
870    ///     (18.0, Some(Color::Cyan)),
871    /// ];
872    /// ui.sparkline_styled(&data, 16);
873    /// # });
874    /// ```
875    pub fn sparkline_styled(&mut self, data: &[(f64, Option<Color>)], width: u32) -> Response {
876        const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
877
878        let w = width as usize;
879        if data.is_empty() || w == 0 {
880            return Response::none();
881        }
882
883        let window: Vec<(f64, Option<Color>)> = if data.len() >= w {
884            data[data.len() - w..].to_vec()
885        } else if data.len() == 1 {
886            vec![data[0]; w]
887        } else {
888            (0..w)
889                .map(|i| {
890                    let t = i as f64 * (data.len() - 1) as f64 / (w - 1) as f64;
891                    let idx = t.floor() as usize;
892                    let frac = t - idx as f64;
893                    let nearest = if frac < 0.5 {
894                        idx
895                    } else {
896                        (idx + 1).min(data.len() - 1)
897                    };
898                    let color = data[nearest].1;
899                    let (v1, _) = data[idx];
900                    let (v2, _) = data[(idx + 1).min(data.len() - 1)];
901                    let value = if !v1.is_finite() || !v2.is_finite() {
902                        if frac < 0.5 { v1 } else { v2 }
903                    } else {
904                        finite_lerp(v1, v2, frac)
905                    };
906                    (value, color)
907                })
908                .collect()
909        };
910
911        let mut finite_values = window
912            .iter()
913            .map(|(value, _)| *value)
914            .filter(|value| value.is_finite());
915        let Some(first) = finite_values.next() else {
916            let response = self.interaction();
917            self.styled(
918                " ".repeat(window.len()),
919                Style::new().fg(self.theme.text_dim),
920            );
921            return response;
922        };
923
924        let mut min = first;
925        let mut max = first;
926        for value in finite_values {
927            min = f64::min(min, value);
928            max = f64::max(max, value);
929        }
930        let mut cells: Vec<(char, Color)> = Vec::with_capacity(window.len());
931        for (value, color) in &window {
932            if !value.is_finite() {
933                cells.push((' ', self.theme.text_dim));
934                continue;
935            }
936
937            let normalized = if min == max {
938                0.5
939            } else {
940                unit_ratio(*value, min, max)
941            };
942            let idx = (normalized * 7.0).round() as usize;
943            cells.push((BLOCKS[idx.min(7)], color.unwrap_or(self.theme.primary)));
944        }
945
946        let response = self.begin_sized_viz_root(width, 1);
947        self.skip_interaction_slot();
948        self.commands
949            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
950                direction: Direction::Row,
951                gap: 0,
952                align: Align::Start,
953                align_self: None,
954                justify: Justify::Start,
955                border: None,
956                border_sides: BorderSides::all(),
957                border_style: Style::new().fg(self.theme.border),
958                bg_color: None,
959                padding: Padding::default(),
960                margin: Margin::default(),
961                constraints: Constraints::default(),
962                title: None,
963                grow: 0,
964                group_name: None,
965            })));
966
967        if cells.is_empty() {
968            self.commands.push(Command::EndContainer);
969            self.rollback.last_text_idx = None;
970            self.end_viz_root();
971            return response;
972        }
973
974        let mut seg = String::new();
975        let mut seg_color = cells[0].1;
976        for (ch, color) in cells {
977            if color != seg_color {
978                self.styled(seg, Style::new().fg(seg_color));
979                seg = String::new();
980                seg_color = color;
981            }
982            seg.push(ch);
983        }
984        if !seg.is_empty() {
985            self.styled(seg, Style::new().fg(seg_color));
986        }
987
988        self.commands.push(Command::EndContainer);
989        self.rollback.last_text_idx = None;
990        self.end_viz_root();
991
992        response
993    }
994
995    /// Render a multi-row line chart using braille characters.
996    ///
997    /// `width` and `height` are terminal cell dimensions. Internally this uses
998    /// braille dot resolution (`width*2` x `height*4`) for smoother plotting.
999    ///
1000    /// # Example
1001    ///
1002    /// ```no_run
1003    /// # slt::run(|ui: &mut slt::Context| {
1004    /// let data = [1.0, 3.0, 2.0, 5.0, 4.0, 6.0, 3.0, 7.0];
1005    /// ui.line_chart(&data, 40, 8);
1006    /// # });
1007    /// ```
1008    pub fn line_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
1009        self.line_chart_colored(data, width, height, self.theme.primary)
1010    }
1011
1012    /// Render a multi-row line chart using a custom color.
1013    pub fn line_chart_colored(
1014        &mut self,
1015        data: &[f64],
1016        width: u32,
1017        height: u32,
1018        color: Color,
1019    ) -> Response {
1020        self.render_line_chart_internal(data, width, height, color, false)
1021    }
1022
1023    /// Render a multi-row area chart using the primary theme color.
1024    pub fn area_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
1025        self.area_chart_colored(data, width, height, self.theme.primary)
1026    }
1027
1028    /// Render a multi-row area chart using a custom color.
1029    pub fn area_chart_colored(
1030        &mut self,
1031        data: &[f64],
1032        width: u32,
1033        height: u32,
1034        color: Color,
1035    ) -> Response {
1036        self.render_line_chart_internal(data, width, height, color, true)
1037    }
1038
1039    fn render_line_chart_internal(
1040        &mut self,
1041        data: &[f64],
1042        width: u32,
1043        height: u32,
1044        color: Color,
1045        fill: bool,
1046    ) -> Response {
1047        if data.is_empty() || width == 0 || height == 0 {
1048            return Response::none();
1049        }
1050        let data: Vec<f64> = data
1051            .iter()
1052            .copied()
1053            .filter(|value| value.is_finite())
1054            .collect();
1055        if data.is_empty() {
1056            return Response::none();
1057        }
1058
1059        let cols = width as usize;
1060        let rows = height as usize;
1061        let px_w = cols * 2;
1062        let px_h = rows * 4;
1063
1064        let min = data.iter().copied().fold(f64::INFINITY, f64::min);
1065        let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
1066
1067        let points: Vec<usize> = (0..px_w)
1068            .map(|px| {
1069                let data_idx = if px_w <= 1 {
1070                    0.0
1071                } else {
1072                    px as f64 * (data.len() - 1) as f64 / (px_w - 1) as f64
1073                };
1074                let idx = data_idx.floor() as usize;
1075                let frac = data_idx - idx as f64;
1076                let value = if idx + 1 < data.len() {
1077                    finite_lerp(data[idx], data[idx + 1], frac)
1078                } else {
1079                    data[idx.min(data.len() - 1)]
1080                };
1081
1082                let normalized = if min == max {
1083                    0.5
1084                } else {
1085                    unit_ratio(value, min, max)
1086                };
1087                let py = ((1.0 - normalized) * (px_h - 1) as f64).round() as usize;
1088                py.min(px_h - 1)
1089            })
1090            .collect();
1091
1092        // Braille dot bit masks shared with `chart::braille` (fix #114).
1093        use crate::chart::{BRAILLE_LEFT_BITS as LEFT_BITS, BRAILLE_RIGHT_BITS as RIGHT_BITS};
1094
1095        let mut grid = vec![vec![0u32; cols]; rows];
1096
1097        for i in 0..points.len() {
1098            let px = i;
1099            let py = points[i];
1100            let char_col = px / 2;
1101            let char_row = py / 4;
1102            let sub_col = px % 2;
1103            let sub_row = py % 4;
1104
1105            if char_col < cols && char_row < rows {
1106                grid[char_row][char_col] |= if sub_col == 0 {
1107                    LEFT_BITS[sub_row]
1108                } else {
1109                    RIGHT_BITS[sub_row]
1110                };
1111            }
1112
1113            if i + 1 < points.len() {
1114                let py_next = points[i + 1];
1115                let (y_start, y_end) = if py <= py_next {
1116                    (py, py_next)
1117                } else {
1118                    (py_next, py)
1119                };
1120                for y in y_start..=y_end {
1121                    let cell_row = y / 4;
1122                    let sub_y = y % 4;
1123                    if char_col < cols && cell_row < rows {
1124                        grid[cell_row][char_col] |= if sub_col == 0 {
1125                            LEFT_BITS[sub_y]
1126                        } else {
1127                            RIGHT_BITS[sub_y]
1128                        };
1129                    }
1130                }
1131            }
1132
1133            if fill {
1134                for y in py..px_h {
1135                    let cell_row = y / 4;
1136                    let sub_y = y % 4;
1137                    if char_col < cols && cell_row < rows {
1138                        grid[cell_row][char_col] |= if sub_col == 0 {
1139                            LEFT_BITS[sub_y]
1140                        } else {
1141                            RIGHT_BITS[sub_y]
1142                        };
1143                    }
1144                }
1145            }
1146        }
1147
1148        let style = Style::new().fg(color);
1149        let response = self.begin_sized_viz_root(width, height);
1150        for row in grid {
1151            let line: String = row
1152                .iter()
1153                .map(|&bits| char::from_u32(0x2800 + bits).unwrap_or(' '))
1154                .collect();
1155            self.styled(line, style);
1156        }
1157        self.end_viz_root();
1158
1159        response
1160    }
1161
1162    /// Render an OHLC candlestick chart.
1163    pub fn candlestick(
1164        &mut self,
1165        candles: &[Candle],
1166        up_color: Color,
1167        down_color: Color,
1168    ) -> Response {
1169        if candles.is_empty() {
1170            return Response::none();
1171        }
1172
1173        let candles: Vec<Candle> = candles
1174            .iter()
1175            .copied()
1176            .filter(|candle| {
1177                candle.open.is_finite()
1178                    && candle.high.is_finite()
1179                    && candle.low.is_finite()
1180                    && candle.close.is_finite()
1181            })
1182            .collect();
1183        if candles.is_empty() {
1184            return Response::none();
1185        }
1186        let response = self.interaction();
1187        self.container().grow(1).draw(move |buf, rect| {
1188            let w = rect.width as usize;
1189            let h = rect.height as usize;
1190            if w < 2 || h < 2 {
1191                return;
1192            }
1193
1194            let mut lo = f64::INFINITY;
1195            let mut hi = f64::NEG_INFINITY;
1196            for c in &candles {
1197                if c.low.is_finite() {
1198                    lo = lo.min(c.low);
1199                }
1200                if c.high.is_finite() {
1201                    hi = hi.max(c.high);
1202                }
1203            }
1204
1205            if !lo.is_finite() || !hi.is_finite() {
1206                return;
1207            }
1208
1209            let map_y = |v: f64| -> usize {
1210                let t = if lo == hi { 0.5 } else { unit_ratio(v, lo, hi) };
1211                ((1.0 - t) * (h.saturating_sub(1)) as f64).round() as usize
1212            };
1213
1214            for (i, c) in candles.iter().enumerate() {
1215                let x0 = i * w / candles.len();
1216                let x1 = ((i + 1) * w / candles.len()).saturating_sub(1).max(x0);
1217                if x0 >= w {
1218                    continue;
1219                }
1220                let xm = (x0 + x1) / 2;
1221                let color = if c.close >= c.open {
1222                    up_color
1223                } else {
1224                    down_color
1225                };
1226
1227                let wt = map_y(c.high);
1228                let wb = map_y(c.low);
1229                for row in wt..=wb.min(h - 1) {
1230                    buf.set_char(
1231                        rect.x + xm as u32,
1232                        rect.y + row as u32,
1233                        '│',
1234                        Style::new().fg(color),
1235                    );
1236                }
1237
1238                let bt = map_y(c.open.max(c.close));
1239                let bb = map_y(c.open.min(c.close));
1240                for row in bt..=bb.min(h - 1) {
1241                    for col in x0..=x1.min(w - 1) {
1242                        buf.set_char(
1243                            rect.x + col as u32,
1244                            rect.y + row as u32,
1245                            '█',
1246                            Style::new().fg(color),
1247                        );
1248                    }
1249                }
1250            }
1251        });
1252
1253        response
1254    }
1255
1256    /// Render a heatmap from a 2D data grid.
1257    ///
1258    /// Each cell maps to a block character with color intensity:
1259    /// low values -> dim/dark, high values -> bright/saturated.
1260    ///
1261    /// # Arguments
1262    /// * `data` - Row-major 2D grid (outer = rows, inner = columns)
1263    /// * `width` - Widget width in terminal cells
1264    /// * `height` - Widget height in terminal cells
1265    /// * `low_color` - Color for minimum values
1266    /// * `high_color` - Color for maximum values
1267    pub fn heatmap(
1268        &mut self,
1269        data: &[Vec<f64>],
1270        width: u32,
1271        height: u32,
1272        low_color: Color,
1273        high_color: Color,
1274    ) -> Response {
1275        if data.is_empty() || width == 0 || height == 0 {
1276            return Response::none();
1277        }
1278
1279        let data_rows = data.len();
1280        let max_data_cols = data.iter().map(Vec::len).max().unwrap_or(0);
1281        if max_data_cols == 0 {
1282            return Response::none();
1283        }
1284
1285        let mut min_value = f64::INFINITY;
1286        let mut max_value = f64::NEG_INFINITY;
1287        for row in data {
1288            for value in row {
1289                if value.is_finite() {
1290                    min_value = min_value.min(*value);
1291                    max_value = max_value.max(*value);
1292                }
1293            }
1294        }
1295
1296        if !min_value.is_finite() || !max_value.is_finite() {
1297            return Response::none();
1298        }
1299
1300        let zero_range = min_value == max_value;
1301        let cols = width as usize;
1302        let rows = height as usize;
1303
1304        let response = self.begin_sized_viz_root(width, height);
1305        for row_idx in 0..rows {
1306            let data_row_idx = (row_idx * data_rows / rows).min(data_rows.saturating_sub(1));
1307            let source_row = &data[data_row_idx];
1308            let source_cols = source_row.len();
1309
1310            self.skip_interaction_slot();
1311            self.commands
1312                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1313                    direction: Direction::Row,
1314                    gap: 0,
1315                    align: Align::Start,
1316                    align_self: None,
1317                    justify: Justify::Start,
1318                    border: None,
1319                    border_sides: BorderSides::all(),
1320                    border_style: Style::new().fg(self.theme.border),
1321                    bg_color: None,
1322                    padding: Padding::default(),
1323                    margin: Margin::default(),
1324                    constraints: Constraints::default(),
1325                    title: None,
1326                    grow: 0,
1327                    group_name: None,
1328                })));
1329
1330            let mut segment = String::new();
1331            let mut segment_color: Option<Color> = None;
1332
1333            for col_idx in 0..cols {
1334                let normalized = if source_cols == 0 {
1335                    0.0
1336                } else {
1337                    let data_col_idx = (col_idx * source_cols / cols).min(source_cols - 1);
1338                    let value = source_row[data_col_idx];
1339
1340                    if !value.is_finite() {
1341                        0.0
1342                    } else if zero_range {
1343                        0.5
1344                    } else {
1345                        unit_ratio(value, min_value, max_value)
1346                    }
1347                };
1348
1349                let color = blend_color(low_color, high_color, normalized);
1350
1351                match segment_color {
1352                    Some(current) if current == color => {
1353                        segment.push('█');
1354                    }
1355                    Some(current) => {
1356                        self.styled(std::mem::take(&mut segment), Style::new().fg(current));
1357                        segment.push('█');
1358                        segment_color = Some(color);
1359                    }
1360                    None => {
1361                        segment.push('█');
1362                        segment_color = Some(color);
1363                    }
1364                }
1365            }
1366
1367            if let Some(color) = segment_color {
1368                self.styled(segment, Style::new().fg(color));
1369            }
1370
1371            self.commands.push(Command::EndContainer);
1372            self.rollback.last_text_idx = None;
1373        }
1374        self.end_viz_root();
1375
1376        response
1377    }
1378
1379    /// Render a braille drawing canvas.
1380    ///
1381    /// The closure receives a [`CanvasContext`] for pixel-level drawing. Each
1382    /// terminal cell maps to a 2x4 braille dot matrix, giving `width*2` x
1383    /// `height*4` pixel resolution.
1384    ///
1385    /// # Example
1386    ///
1387    /// ```no_run
1388    /// # slt::run(|ui: &mut slt::Context| {
1389    /// ui.canvas(40, 10, |cv| {
1390    ///     cv.line(0, 0, cv.width() - 1, cv.height() - 1);
1391    ///     cv.circle(40, 20, 15);
1392    /// });
1393    /// # });
1394    /// ```
1395    pub fn canvas(
1396        &mut self,
1397        width: u32,
1398        height: u32,
1399        draw: impl FnOnce(&mut CanvasContext),
1400    ) -> Response {
1401        if width == 0 || height == 0 {
1402            return Response::none();
1403        }
1404
1405        let mut canvas = CanvasContext::new(width as usize, height as usize);
1406        draw(&mut canvas);
1407
1408        let response = self.begin_sized_viz_root(width, height);
1409        for segments in canvas.render() {
1410            self.skip_interaction_slot();
1411            self.commands
1412                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1413                    direction: Direction::Row,
1414                    gap: 0,
1415                    align: Align::Start,
1416                    align_self: None,
1417                    justify: Justify::Start,
1418                    border: None,
1419                    border_sides: BorderSides::all(),
1420                    border_style: Style::new(),
1421                    bg_color: None,
1422                    padding: Padding::default(),
1423                    margin: Margin::default(),
1424                    constraints: Constraints::default(),
1425                    title: None,
1426                    grow: 0,
1427                    group_name: None,
1428                })));
1429            for (text, color) in segments {
1430                let c = if color == Color::Reset {
1431                    self.theme.primary
1432                } else {
1433                    color
1434                };
1435                self.styled(text, Style::new().fg(c));
1436            }
1437            self.commands.push(Command::EndContainer);
1438            self.rollback.last_text_idx = None;
1439        }
1440        self.end_viz_root();
1441
1442        response
1443    }
1444
1445    /// Render a multi-series chart with axes, legend, and auto-scaling.
1446    ///
1447    /// `width` and `height` must be non-zero. For dynamic sizing, read terminal
1448    /// dimensions first (for example via `ui.width()` / `ui.height()`) and pass
1449    /// the computed values to this method.
1450    pub fn chart(
1451        &mut self,
1452        configure: impl FnOnce(&mut ChartBuilder),
1453        width: u32,
1454        height: u32,
1455    ) -> Response {
1456        if width == 0 || height == 0 {
1457            return Response::none();
1458        }
1459
1460        let axis_style = Style::new().fg(self.theme.text_dim);
1461        let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
1462        configure(&mut builder);
1463
1464        let config = builder.build();
1465        let rows = render_chart(&config);
1466
1467        let response = self.begin_sized_viz_root(width, height);
1468        for row in rows {
1469            self.skip_interaction_slot();
1470            self.commands
1471                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1472                    direction: Direction::Row,
1473                    gap: 0,
1474                    align: Align::Start,
1475                    align_self: None,
1476                    justify: Justify::Start,
1477                    border: None,
1478                    border_sides: BorderSides::all(),
1479                    border_style: Style::new().fg(self.theme.border),
1480                    bg_color: None,
1481                    padding: Padding::default(),
1482                    margin: Margin::default(),
1483                    constraints: Constraints::default(),
1484                    title: None,
1485                    grow: 0,
1486                    group_name: None,
1487                })));
1488            for (text, style) in row.segments {
1489                self.styled(text, style);
1490            }
1491            self.commands.push(Command::EndContainer);
1492            self.rollback.last_text_idx = None;
1493        }
1494        self.end_viz_root();
1495
1496        response
1497    }
1498
1499    /// Renders a scatter plot.
1500    ///
1501    /// Each point is a (x, y) tuple. Uses braille markers.
1502    pub fn scatter(&mut self, data: &[(f64, f64)], width: u32, height: u32) -> Response {
1503        self.chart(
1504            |c| {
1505                c.scatter(data);
1506                c.grid(true);
1507            },
1508            width,
1509            height,
1510        )
1511    }
1512
1513    /// Render a histogram from raw data with auto-binning.
1514    pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> Response {
1515        self.histogram_with(data, |_| {}, width, height)
1516    }
1517
1518    /// Render a histogram with configuration options.
1519    pub fn histogram_with(
1520        &mut self,
1521        data: &[f64],
1522        configure: impl FnOnce(&mut HistogramBuilder),
1523        width: u32,
1524        height: u32,
1525    ) -> Response {
1526        if width == 0 || height == 0 {
1527            return Response::none();
1528        }
1529
1530        let mut options = HistogramBuilder::default();
1531        configure(&mut options);
1532        let axis_style = Style::new().fg(self.theme.text_dim);
1533        let config = build_histogram_config(data, &options, width, height, axis_style);
1534        let rows = render_chart(&config);
1535
1536        let response = self.begin_sized_viz_root(width, height);
1537        for row in rows {
1538            self.skip_interaction_slot();
1539            self.commands
1540                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1541                    direction: Direction::Row,
1542                    gap: 0,
1543                    align: Align::Start,
1544                    align_self: None,
1545                    justify: Justify::Start,
1546                    border: None,
1547                    border_sides: BorderSides::all(),
1548                    border_style: Style::new().fg(self.theme.border),
1549                    bg_color: None,
1550                    padding: Padding::default(),
1551                    margin: Margin::default(),
1552                    constraints: Constraints::default(),
1553                    title: None,
1554                    grow: 0,
1555                    group_name: None,
1556                })));
1557            for (text, style) in row.segments {
1558                self.styled(text, style);
1559            }
1560            self.commands.push(Command::EndContainer);
1561            self.rollback.last_text_idx = None;
1562        }
1563        self.end_viz_root();
1564
1565        response
1566    }
1567
1568    #[cfg(feature = "qrcode")]
1569    #[cfg_attr(docsrs, doc(cfg(feature = "qrcode")))]
1570    /// Render a QR code using half-block characters.
1571    pub fn qr_code(&mut self, data: impl AsRef<str>) -> Response {
1572        let code = match qrcode::QrCode::new(data.as_ref()) {
1573            Ok(code) => code,
1574            Err(_) => {
1575                let response = self.interaction();
1576                self.text("[QR Error]");
1577                return response;
1578            }
1579        };
1580
1581        let modules_per_side = code.width();
1582        let modules = code.to_colors();
1583        let qr_side = modules_per_side + 2;
1584        let qr_width = qr_side;
1585        let qr_height = qr_side.div_ceil(2);
1586        let theme_text = self.theme.text;
1587        let theme_bg = self.theme.bg;
1588
1589        let response = self.interaction();
1590        self.container()
1591            .w(qr_width as u32)
1592            .h(qr_height as u32)
1593            .draw(move |buf, rect| {
1594                let draw_w = (rect.width as usize).min(qr_width);
1595                let draw_h = (rect.height as usize).min(qr_height);
1596
1597                for row in 0..draw_h {
1598                    let upper_y = row * 2;
1599                    let lower_y = upper_y + 1;
1600
1601                    for x in 0..draw_w {
1602                        let resolve_module_color = |mx: usize, my: usize| -> Color {
1603                            let dark =
1604                                if mx == 0 || my == 0 || mx == qr_side - 1 || my == qr_side - 1 {
1605                                    false
1606                                } else {
1607                                    let inner_x = mx - 1;
1608                                    let inner_y = my - 1;
1609                                    let idx = inner_y * modules_per_side + inner_x;
1610                                    matches!(modules.get(idx), Some(qrcode::types::Color::Dark))
1611                                };
1612
1613                            if dark { theme_text } else { theme_bg }
1614                        };
1615
1616                        let upper = resolve_module_color(x, upper_y);
1617                        let lower = if lower_y < qr_side {
1618                            resolve_module_color(x, lower_y)
1619                        } else {
1620                            theme_bg
1621                        };
1622
1623                        buf.set_char(
1624                            rect.x + x as u32,
1625                            rect.y + row as u32,
1626                            '▀',
1627                            Style::new().fg(upper).bg(lower),
1628                        );
1629                    }
1630                }
1631            });
1632
1633        response
1634    }
1635
1636    /// Render a heatmap using half-block characters for 2× vertical resolution.
1637    ///
1638    /// Each terminal cell packs two data rows using `▀` with `fg` for the upper
1639    /// half and `bg` for the lower half. This doubles the effective vertical
1640    /// resolution compared to [`heatmap`](Self::heatmap).
1641    ///
1642    /// # Example
1643    ///
1644    /// ```no_run
1645    /// # slt::run(|ui: &mut slt::Context| {
1646    /// use slt::Color;
1647    /// let data: Vec<Vec<f64>> = (0..20)
1648    ///     .map(|r| (0..40).map(|c| ((r * 3 + c * 7) % 20) as f64).collect())
1649    ///     .collect();
1650    /// ui.heatmap_halfblock(&data, 40, 10, Color::Rgb(10, 10, 40), Color::Rgb(255, 80, 30));
1651    /// # });
1652    /// ```
1653    pub fn heatmap_halfblock(
1654        &mut self,
1655        data: &[Vec<f64>],
1656        width: u32,
1657        height: u32,
1658        low_color: Color,
1659        high_color: Color,
1660    ) -> Response {
1661        if data.is_empty() || width == 0 || height == 0 {
1662            return Response::none();
1663        }
1664
1665        let data_rows = data.len();
1666        let max_data_cols = data.iter().map(Vec::len).max().unwrap_or(0);
1667        if max_data_cols == 0 {
1668            return Response::none();
1669        }
1670
1671        let mut min_value = f64::INFINITY;
1672        let mut max_value = f64::NEG_INFINITY;
1673        for row in data {
1674            for value in row {
1675                if value.is_finite() {
1676                    min_value = min_value.min(*value);
1677                    max_value = max_value.max(*value);
1678                }
1679            }
1680        }
1681
1682        if !min_value.is_finite() || !max_value.is_finite() {
1683            return Response::none();
1684        }
1685
1686        let zero_range = min_value == max_value;
1687
1688        let data = data.to_vec();
1689        let cols = width as usize;
1690        let rows = height as usize;
1691        // Each terminal row maps to 2 data rows
1692        let virtual_rows = rows * 2;
1693
1694        let response = self.interaction();
1695        self.container().w(width).h(height).draw(move |buf, rect| {
1696            let w = rect.width as usize;
1697            let h = rect.height as usize;
1698            if w == 0 || h == 0 {
1699                return;
1700            }
1701
1702            let sample = |data_row_idx: usize, col_idx: usize| -> f64 {
1703                let src_row = &data[data_row_idx.min(data_rows.saturating_sub(1))];
1704                let src_cols = src_row.len();
1705                if src_cols == 0 {
1706                    return 0.0;
1707                }
1708                let data_col = (col_idx * src_cols / cols.max(1)).min(src_cols - 1);
1709                let v = src_row[data_col];
1710                if !v.is_finite() {
1711                    0.0
1712                } else if zero_range {
1713                    0.5
1714                } else {
1715                    unit_ratio(v, min_value, max_value)
1716                }
1717            };
1718
1719            for row in 0..h {
1720                let upper_data_row =
1721                    (row * 2 * data_rows / virtual_rows).min(data_rows.saturating_sub(1));
1722                let lower_data_row =
1723                    ((row * 2 + 1) * data_rows / virtual_rows).min(data_rows.saturating_sub(1));
1724
1725                for col in 0..w.min(cols) {
1726                    let upper_t = sample(upper_data_row, col);
1727                    let lower_t = sample(lower_data_row, col);
1728                    let upper_color = blend_color(low_color, high_color, upper_t);
1729                    let lower_color = blend_color(low_color, high_color, lower_t);
1730
1731                    buf.set_char(
1732                        rect.x + col as u32,
1733                        rect.y + row as u32,
1734                        '▀',
1735                        Style::new().fg(upper_color).bg(lower_color),
1736                    );
1737                }
1738            }
1739        });
1740
1741        response
1742    }
1743
1744    /// Render a candlestick chart with heavy box-drawing and half-block precision.
1745    ///
1746    /// Uses `┃` for wicks (heavier than `│`) and `▀`/`▄` at body edges for
1747    /// sub-cell vertical precision, effectively doubling the price resolution.
1748    ///
1749    /// Each terminal cell represents two half-cells vertically: the body's open
1750    /// and close prices snap to the nearest half-cell, so a body that covers an
1751    /// odd number of half-cells terminates with `▀` (top half only) or `▄`
1752    /// (bottom half only) rather than rounding to a full cell.
1753    ///
1754    /// # Example
1755    ///
1756    /// ```no_run
1757    /// # slt::run(|ui: &mut slt::Context| {
1758    /// use slt::{Candle, Color};
1759    /// let candles = vec![
1760    ///     Candle { open: 100.0, high: 108.0, low: 98.0, close: 105.0 },
1761    ///     Candle { open: 105.0, high: 112.0, low: 103.0, close: 110.0 },
1762    /// ];
1763    /// ui.candlestick_hd(&candles, Color::Rgb(38, 166, 91), Color::Rgb(234, 57, 67));
1764    /// # });
1765    /// ```
1766    pub fn candlestick_hd(
1767        &mut self,
1768        candles: &[Candle],
1769        up_color: Color,
1770        down_color: Color,
1771    ) -> Response {
1772        if candles.is_empty() {
1773            return Response::none();
1774        }
1775
1776        let candles: Vec<Candle> = candles
1777            .iter()
1778            .copied()
1779            .filter(|candle| {
1780                candle.open.is_finite()
1781                    && candle.high.is_finite()
1782                    && candle.low.is_finite()
1783                    && candle.close.is_finite()
1784            })
1785            .collect();
1786        if candles.is_empty() {
1787            return Response::none();
1788        }
1789        let response = self.interaction();
1790        self.container().grow(1).draw(move |buf, rect| {
1791            let w = rect.width as usize;
1792            let h = rect.height as usize;
1793            if w < 2 || h < 2 {
1794                return;
1795            }
1796
1797            let mut lo = f64::INFINITY;
1798            let mut hi = f64::NEG_INFINITY;
1799            for c in &candles {
1800                if c.low.is_finite() {
1801                    lo = lo.min(c.low);
1802                }
1803                if c.high.is_finite() {
1804                    hi = hi.max(c.high);
1805                }
1806            }
1807            if !lo.is_finite() || !hi.is_finite() {
1808                return;
1809            }
1810
1811            // Cell-resolution price-to-row map (used for wicks).
1812            let map_y = |v: f64| -> usize {
1813                let t = if lo == hi { 0.5 } else { unit_ratio(v, lo, hi) };
1814                ((1.0 - t) * h.saturating_sub(1) as f64).round() as usize
1815            };
1816            // Half-cell resolution price-to-row map (used for body edges).
1817            // Returns half-cell index in [0, 2h-1]; 0 = top of cell 0, 2h-1 = bottom of cell h-1.
1818            let half_rows = h.saturating_mul(2);
1819            let map_y_half = |v: f64| -> usize {
1820                let t = if lo == hi { 0.5 } else { unit_ratio(v, lo, hi) };
1821                ((1.0 - t) * half_rows.saturating_sub(1) as f64).round() as usize
1822            };
1823
1824            let n = candles.len();
1825
1826            for (i, c) in candles.iter().enumerate() {
1827                // Distribute candles evenly across full width
1828                let x0 = i * w / n;
1829                let x1 = ((i + 1) * w / n).saturating_sub(1).max(x0);
1830                if x0 >= w {
1831                    continue;
1832                }
1833                // Wick at exact center of body range (inclusive)
1834                let xm = x0 + (x1 - x0) / 2;
1835                let color = if c.close >= c.open {
1836                    up_color
1837                } else {
1838                    down_color
1839                };
1840
1841                // Wick (cell-resolution; body draws over wick within its range).
1842                let wick_top = map_y(c.high);
1843                let wick_bot = map_y(c.low);
1844                for row in wick_top..=wick_bot.min(h - 1) {
1845                    buf.set_char(
1846                        rect.x + xm as u32,
1847                        rect.y + row as u32,
1848                        '┃',
1849                        Style::new().fg(color),
1850                    );
1851                }
1852
1853                // Body uses half-cell precision: each cell covers two half-cells
1854                // (top = 2·row, bottom = 2·row + 1). Body extends from
1855                // `body_top_half` (highest price) down to `body_bot_half`
1856                // (lowest price) inclusive, in half-cell units.
1857                let body_top_half = map_y_half(c.open.max(c.close));
1858                let body_bot_half = map_y_half(c.open.min(c.close));
1859                let row_first = body_top_half / 2;
1860                let row_last = (body_bot_half / 2).min(h - 1);
1861                for row in row_first..=row_last {
1862                    let top_hc = row * 2;
1863                    let bot_hc = row * 2 + 1;
1864                    let top_in = top_hc >= body_top_half && top_hc <= body_bot_half;
1865                    let bot_in = bot_hc >= body_top_half && bot_hc <= body_bot_half;
1866                    let body_char = match (top_in, bot_in) {
1867                        (true, true) => '█',
1868                        (true, false) => '▀',
1869                        (false, true) => '▄',
1870                        (false, false) => continue,
1871                    };
1872                    for col in x0..=x1.min(w - 1) {
1873                        buf.set_char(
1874                            rect.x + col as u32,
1875                            rect.y + row as u32,
1876                            body_char,
1877                            Style::new().fg(color),
1878                        );
1879                    }
1880                }
1881            }
1882        });
1883
1884        response
1885    }
1886
1887    /// Render a treemap using the squarified layout algorithm.
1888    ///
1889    /// Each item occupies a rectangle proportional to its value, filled with the
1890    /// item's color and labeled when space permits.
1891    ///
1892    /// # Example
1893    ///
1894    /// ```no_run
1895    /// # slt::run(|ui: &mut slt::Context| {
1896    /// use slt::{TreemapItem, Color};
1897    /// let items = vec![
1898    ///     TreemapItem::new("Rust", 40.0, Color::Cyan),
1899    ///     TreemapItem::new("Go", 25.0, Color::Blue),
1900    ///     TreemapItem::new("Python", 20.0, Color::Yellow),
1901    ///     TreemapItem::new("Java", 15.0, Color::Red),
1902    /// ];
1903    /// ui.treemap(&items);
1904    /// # });
1905    /// ```
1906    pub fn treemap(&mut self, items: &[TreemapItem]) -> Response {
1907        if items.is_empty() {
1908            return Response::none();
1909        }
1910
1911        let items: Vec<TreemapItem> = items
1912            .iter()
1913            .filter(|item| item.value.is_finite() && item.value > 0.0)
1914            .cloned()
1915            .collect();
1916        if items.is_empty() {
1917            return Response::none();
1918        }
1919        let response = self.interaction();
1920        self.container().grow(1).draw(move |buf, rect| {
1921            let w = rect.width as usize;
1922            let h = rect.height as usize;
1923            if w < 2 || h < 2 {
1924                return;
1925            }
1926
1927            // Filter out items that would be too small to render (< 1 cell)
1928            let total_area = w as f64 * h as f64;
1929            let max_value = items.iter().map(|item| item.value).fold(0.0, f64::max);
1930            let total_value: f64 = items.iter().map(|item| item.value / max_value).sum();
1931            let min_area_threshold = 1.0; // at least 1 cell
1932            let visible_items: Vec<&TreemapItem> = if total_value > 0.0 {
1933                items
1934                    .iter()
1935                    .filter(|item| {
1936                        (item.value / max_value) / total_value * total_area >= min_area_threshold
1937                    })
1938                    .collect()
1939            } else {
1940                return;
1941            };
1942
1943            if visible_items.is_empty() {
1944                return;
1945            }
1946
1947            // Build filtered items for layout
1948            let filtered: Vec<TreemapItem> = visible_items.into_iter().cloned().collect();
1949            let rects = squarify_layout(&filtered, 0.0, 0.0, w as f64, h as f64);
1950
1951            for (item, r) in filtered.iter().zip(rects.iter()) {
1952                // Integer cell bounds — use round for consistent placement
1953                let x0 = r.x.round() as usize;
1954                let y0 = r.y.round() as usize;
1955                let x1 = (r.x + r.w).round() as usize;
1956                let y1 = (r.y + r.h).round() as usize;
1957
1958                let cell_w = x1.min(w).saturating_sub(x0);
1959                let cell_h = y1.min(h).saturating_sub(y0);
1960                if cell_w == 0 || cell_h == 0 {
1961                    continue;
1962                }
1963
1964                // Fill the rectangle with the item's color
1965                for row in y0..y1.min(h) {
1966                    for col in x0..x1.min(w) {
1967                        buf.set_char(
1968                            rect.x + col as u32,
1969                            rect.y + row as u32,
1970                            ' ',
1971                            Style::new().bg(item.color),
1972                        );
1973                    }
1974                }
1975
1976                let text_color = treemap_label_color(item.color);
1977
1978                // Label: truncate to fit with ellipsis, center in cell
1979                // (unicode-safe, fix #112; ellipsis fix for fix #v020-truncate)
1980                if cell_w >= 2 {
1981                    let max_label_w = cell_w.saturating_sub(1);
1982                    let label_owned = crate::chart::truncate_label(&item.label, max_label_w);
1983                    let label = label_owned.as_str();
1984                    let label_unicode_w = UnicodeWidthStr::width(label);
1985                    let label_y = y0 + cell_h / 2;
1986                    let label_x = x0 + (cell_w.saturating_sub(label_unicode_w)) / 2;
1987                    if label_y < y1.min(h) {
1988                        buf.set_string(
1989                            rect.x + label_x as u32,
1990                            rect.y + label_y as u32,
1991                            label,
1992                            Style::new().fg(text_color).bg(item.color).bold(),
1993                        );
1994                    }
1995
1996                    // Value label below if space permits
1997                    if cell_h >= 3 {
1998                        let value_str = format_compact_number(item.value);
1999                        let value_y = label_y + 1;
2000                        let value_width = UnicodeWidthStr::width(value_str.as_str());
2001                        if value_y < y1.min(h) && value_width < cell_w {
2002                            let vx = x0 + (cell_w.saturating_sub(value_width)) / 2;
2003                            buf.set_string(
2004                                rect.x + vx as u32,
2005                                rect.y + value_y as u32,
2006                                &value_str,
2007                                Style::new().fg(text_color).bg(item.color).dim(),
2008                            );
2009                        }
2010                    }
2011                }
2012            }
2013        });
2014
2015        response
2016    }
2017
2018    /// Render a stacked bar chart with custom configuration.
2019    ///
2020    /// Each group's bars are stacked on top of each other rather than placed
2021    /// side-by-side.
2022    ///
2023    /// # Example
2024    ///
2025    /// ```no_run
2026    /// # slt::run(|ui: &mut slt::Context| {
2027    /// use slt::{Bar, BarGroup, Color};
2028    /// let groups = vec![
2029    ///     BarGroup::new("2023", vec![
2030    ///         Bar::new("Rev", 100.0).color(Color::Cyan),
2031    ///         Bar::new("Cost", 60.0).color(Color::Red),
2032    ///     ]),
2033    ///     BarGroup::new("2024", vec![
2034    ///         Bar::new("Rev", 140.0).color(Color::Cyan),
2035    ///         Bar::new("Cost", 80.0).color(Color::Red),
2036    ///     ]),
2037    /// ];
2038    /// ui.bar_chart_stacked(&groups, 20);
2039    /// # });
2040    /// ```
2041    pub fn bar_chart_stacked(&mut self, groups: &[BarGroup], max_height: u32) -> Response {
2042        self.bar_chart_stacked_with(groups, |_| {}, max_height)
2043    }
2044
2045    /// Render a stacked bar chart with custom configuration.
2046    ///
2047    /// Uses [`BarChartConfig`] for bar width, gap, and max value settings.
2048    pub fn bar_chart_stacked_with(
2049        &mut self,
2050        groups: &[BarGroup],
2051        configure: impl FnOnce(&mut BarChartConfig),
2052        max_height: u32,
2053    ) -> Response {
2054        if groups.is_empty() {
2055            return Response::none();
2056        }
2057
2058        let all_bars: Vec<&Bar> = groups.iter().flat_map(|g| g.bars.iter()).collect();
2059        if all_bars.is_empty() {
2060            return Response::none();
2061        }
2062
2063        let mut config = BarChartConfig::default();
2064        config.bar_width(3).bar_gap(1);
2065        configure(&mut config);
2066
2067        // Find max stacked total
2068        let max_total: f64 = groups
2069            .iter()
2070            .map(|g| bounded_positive_sum(g.bars.iter().map(|bar| bar.value)))
2071            .fold(1.0, f64::max);
2072        let denom = config
2073            .max_value
2074            .filter(|value| value.is_finite() && *value > 0.0)
2075            .unwrap_or(max_total);
2076
2077        let chart_height = max_height.max(1) as usize;
2078        let bar_width = config.bar_width.max(1) as usize;
2079        let gap = config.bar_gap as i32;
2080
2081        const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
2082
2083        let response = self.begin_viz_root();
2084        self.skip_interaction_slot();
2085        self.commands
2086            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
2087                direction: Direction::Column,
2088                gap: 0,
2089                align: Align::Start,
2090                align_self: None,
2091                justify: Justify::Start,
2092                border: None,
2093                border_sides: BorderSides::all(),
2094                border_style: Style::new().fg(self.theme.border),
2095                bg_color: None,
2096                padding: Padding::default(),
2097                margin: Margin::default(),
2098                constraints: Constraints::default(),
2099                title: None,
2100                grow: 0,
2101                group_name: None,
2102            })));
2103
2104        // Compute stacked units per group
2105        struct StackedSegment {
2106            units: usize,
2107            color: Color,
2108        }
2109        let stacked_groups: Vec<(String, Vec<StackedSegment>)> = groups
2110            .iter()
2111            .map(|g| {
2112                let segs: Vec<StackedSegment> = g
2113                    .bars
2114                    .iter()
2115                    .map(|b| {
2116                        let normalized = unit_ratio(finite_or_zero(b.value).max(0.0), 0.0, denom);
2117                        StackedSegment {
2118                            units: (normalized * chart_height as f64 * 8.0).round() as usize,
2119                            color: b.color.unwrap_or(self.theme.primary),
2120                        }
2121                    })
2122                    .collect();
2123                (g.label.clone(), segs)
2124            })
2125            .collect();
2126
2127        // Render rows top to bottom
2128        for row in (0..chart_height).rev() {
2129            self.skip_interaction_slot();
2130            self.commands
2131                .push(Command::BeginContainer(Box::new(BeginContainerArgs {
2132                    direction: Direction::Row,
2133                    gap,
2134                    align: Align::Start,
2135                    align_self: None,
2136                    justify: Justify::Start,
2137                    border: None,
2138                    border_sides: BorderSides::all(),
2139                    border_style: Style::new().fg(self.theme.border),
2140                    bg_color: None,
2141                    padding: Padding::default(),
2142                    margin: Margin::default(),
2143                    constraints: Constraints::default(),
2144                    title: None,
2145                    grow: 0,
2146                    group_name: None,
2147                })));
2148
2149            let row_base = row * 8;
2150
2151            for (_label, segs) in &stacked_groups {
2152                // Find which segment covers this row
2153                let mut accumulated = 0usize;
2154                let mut cell_char = ' ';
2155                let mut cell_color = self.theme.bg;
2156
2157                for seg in segs {
2158                    let seg_bottom = accumulated;
2159                    let seg_top = accumulated + seg.units;
2160
2161                    if seg_top <= row_base {
2162                        // Segment is entirely below this row
2163                        accumulated = seg_top;
2164                        continue;
2165                    }
2166
2167                    if seg_bottom >= row_base + 8 {
2168                        // Segment is entirely above this row
2169                        break;
2170                    }
2171
2172                    // This segment covers (part of) this row
2173                    let local_bottom = seg_bottom.saturating_sub(row_base);
2174                    let local_top = (seg_top - row_base).min(8);
2175                    let fill = local_top - local_bottom;
2176
2177                    if local_bottom == 0 {
2178                        // This segment starts from the bottom of the cell
2179                        cell_char = if fill >= 8 {
2180                            '█'
2181                        } else {
2182                            FRACTION_BLOCKS[fill]
2183                        };
2184                        cell_color = seg.color;
2185                    } else {
2186                        // This segment starts partway up — just use full block
2187                        cell_char = '█';
2188                        cell_color = seg.color;
2189                    }
2190
2191                    accumulated = seg_top;
2192                }
2193
2194                let fill_text = cell_char.to_string().repeat(bar_width);
2195                self.styled(fill_text, Style::new().fg(cell_color));
2196            }
2197
2198            self.commands.push(Command::EndContainer);
2199            self.rollback.last_text_idx = None;
2200        }
2201
2202        // Labels row
2203        self.skip_interaction_slot();
2204        self.commands
2205            .push(Command::BeginContainer(Box::new(BeginContainerArgs {
2206                direction: Direction::Row,
2207                gap,
2208                align: Align::Start,
2209                align_self: None,
2210                justify: Justify::Start,
2211                border: None,
2212                border_sides: BorderSides::all(),
2213                border_style: Style::new().fg(self.theme.border),
2214                bg_color: None,
2215                padding: Padding::default(),
2216                margin: Margin::default(),
2217                constraints: Constraints::default(),
2218                title: None,
2219                grow: 0,
2220                group_name: None,
2221            })));
2222        for (label, _) in &stacked_groups {
2223            self.styled(
2224                Self::center_and_truncate_text(label, bar_width),
2225                Style::new().fg(self.theme.text),
2226            );
2227        }
2228        self.commands.push(Command::EndContainer);
2229        self.rollback.last_text_idx = None;
2230
2231        self.commands.push(Command::EndContainer);
2232        self.rollback.last_text_idx = None;
2233        self.end_viz_root();
2234
2235        response
2236    }
2237}
2238
2239/// A single item in a treemap.
2240#[derive(Debug, Clone)]
2241pub struct TreemapItem {
2242    /// Display label.
2243    pub label: String,
2244    /// Numeric value determining area.
2245    pub value: f64,
2246    /// Fill color for this item's rectangle.
2247    pub color: Color,
2248}
2249
2250impl TreemapItem {
2251    /// Create a new treemap item.
2252    pub fn new(label: impl Into<String>, value: f64, color: Color) -> Self {
2253        Self {
2254            label: label.into(),
2255            value,
2256            color,
2257        }
2258    }
2259}
2260
2261/// Rectangle produced by the squarified layout.
2262#[derive(Clone)]
2263struct LayoutRect {
2264    x: f64,
2265    y: f64,
2266    w: f64,
2267    h: f64,
2268}
2269
2270/// Squarified treemap layout algorithm (Bruls, Huizing, van Wijk 2000).
2271fn squarify_layout(items: &[TreemapItem], x: f64, y: f64, w: f64, h: f64) -> Vec<LayoutRect> {
2272    if items.is_empty()
2273        || !x.is_finite()
2274        || !y.is_finite()
2275        || !w.is_finite()
2276        || !h.is_finite()
2277        || w <= 0.0
2278        || h <= 0.0
2279    {
2280        return Vec::new();
2281    }
2282
2283    let max_value = items
2284        .iter()
2285        .map(|item| item.value)
2286        .filter(|value| value.is_finite() && *value > 0.0)
2287        .fold(0.0, f64::max);
2288    if max_value <= 0.0 {
2289        return items
2290            .iter()
2291            .map(|_| LayoutRect {
2292                x,
2293                y,
2294                w: 0.0,
2295                h: 0.0,
2296            })
2297            .collect();
2298    }
2299    let total: f64 = items
2300        .iter()
2301        .map(|item| {
2302            if item.value.is_finite() && item.value > 0.0 {
2303                item.value / max_value
2304            } else {
2305                0.0
2306            }
2307        })
2308        .sum();
2309
2310    // Normalize values to fill the available area
2311    let area = w * h;
2312    let mut sorted_indices: Vec<usize> = (0..items.len()).collect();
2313    sorted_indices.sort_by(|a, b| items[*b].value.total_cmp(&items[*a].value));
2314
2315    let areas: Vec<f64> = sorted_indices
2316        .iter()
2317        .map(|&i| {
2318            let value = if items[i].value.is_finite() && items[i].value > 0.0 {
2319                items[i].value / max_value
2320            } else {
2321                0.0
2322            };
2323            value / total * area
2324        })
2325        .collect();
2326
2327    let mut result = vec![
2328        LayoutRect {
2329            x: 0.0,
2330            y: 0.0,
2331            w: 0.0,
2332            h: 0.0,
2333        };
2334        items.len()
2335    ];
2336    squarify_recursive(&areas, &sorted_indices, x, y, w, h, &mut result);
2337    result
2338}
2339
2340/// Incremental form of [`worst_ratio`] that uses pre-aggregated row statistics.
2341///
2342/// `sum` is the total of all areas in the row (including non-positive ones, to
2343/// match `row.iter().sum()`). `pos_max` and `pos_min` are the max/min over
2344/// strictly positive areas only — set them to `f64::NEG_INFINITY` / `f64::INFINITY`
2345/// when there are no positives. `pos_count` is the number of strictly positive areas.
2346///
2347/// Returns the same value as `worst_ratio` for any row whose sum/min/max/count
2348/// match, since `worst_ratio`'s loop reduces to `max(side²·max / sum², sum² / (side²·min))`
2349/// when all summed areas are positive, and to `0.0` when none are.
2350#[inline]
2351fn worst_ratio_incremental(
2352    sum: f64,
2353    pos_max: f64,
2354    pos_min: f64,
2355    pos_count: usize,
2356    side: f64,
2357) -> f64 {
2358    if side <= 0.0 {
2359        return f64::INFINITY;
2360    }
2361    if pos_count == 0 {
2362        return 0.0;
2363    }
2364    let s2 = side * side;
2365    let sum2 = sum * sum;
2366    // sum2 > 0 here because pos_count > 0 implies sum > 0.
2367    (s2 * pos_max / sum2).max(sum2 / (s2 * pos_min))
2368}
2369
2370fn squarify_recursive(
2371    areas: &[f64],
2372    indices: &[usize],
2373    x: f64,
2374    y: f64,
2375    w: f64,
2376    h: f64,
2377    result: &mut [LayoutRect],
2378) {
2379    if areas.is_empty() || w <= 0.0 || h <= 0.0 {
2380        return;
2381    }
2382
2383    if areas.len() == 1 {
2384        result[indices[0]] = LayoutRect { x, y, w, h };
2385        return;
2386    }
2387
2388    let short_side = w.min(h);
2389    let mut row: Vec<f64> = Vec::new();
2390    let mut row_indices: Vec<usize> = Vec::new();
2391    // Incremental row statistics avoid cloning `row` each iteration just to
2392    // probe the ratio of `row + [area]`. `pos_*` track positive-only stats to
2393    // match `worst_ratio`'s zero-skip behavior exactly.
2394    let mut row_sum_acc = 0f64;
2395    let mut row_pos_max = f64::NEG_INFINITY;
2396    let mut row_pos_min = f64::INFINITY;
2397    let mut row_pos_count: usize = 0;
2398
2399    for (i, &area) in areas.iter().enumerate() {
2400        let cand_sum = row_sum_acc + area;
2401        let (cand_pos_max, cand_pos_min, cand_pos_count) = if area > 0.0 {
2402            (
2403                row_pos_max.max(area),
2404                row_pos_min.min(area),
2405                row_pos_count + 1,
2406            )
2407        } else {
2408            (row_pos_max, row_pos_min, row_pos_count)
2409        };
2410
2411        let candidate_ratio = worst_ratio_incremental(
2412            cand_sum,
2413            cand_pos_max,
2414            cand_pos_min,
2415            cand_pos_count,
2416            short_side,
2417        );
2418        let current_ratio = worst_ratio_incremental(
2419            row_sum_acc,
2420            row_pos_max,
2421            row_pos_min,
2422            row_pos_count,
2423            short_side,
2424        );
2425        if row.is_empty() || candidate_ratio <= current_ratio {
2426            row.push(area);
2427            row_indices.push(indices[i]);
2428            row_sum_acc = cand_sum;
2429            row_pos_max = cand_pos_max;
2430            row_pos_min = cand_pos_min;
2431            row_pos_count = cand_pos_count;
2432        } else {
2433            // Layout the current row
2434            let row_sum: f64 = row.iter().sum();
2435            let row_fraction = row_sum / (w * h).max(f64::EPSILON);
2436
2437            if w >= h {
2438                // Lay out vertically on the left
2439                let row_w = w * row_fraction;
2440                let mut cy = y;
2441                for (j, &a) in row.iter().enumerate() {
2442                    let cell_h = if row_sum > 0.0 {
2443                        h * (a / row_sum)
2444                    } else {
2445                        0.0
2446                    };
2447                    result[row_indices[j]] = LayoutRect {
2448                        x,
2449                        y: cy,
2450                        w: row_w,
2451                        h: cell_h,
2452                    };
2453                    cy += cell_h;
2454                }
2455                squarify_recursive(
2456                    &areas[i..],
2457                    &indices[i..],
2458                    x + row_w,
2459                    y,
2460                    w - row_w,
2461                    h,
2462                    result,
2463                );
2464            } else {
2465                // Lay out horizontally on top
2466                let row_h = h * row_fraction;
2467                let mut cx = x;
2468                for (j, &a) in row.iter().enumerate() {
2469                    let cell_w = if row_sum > 0.0 {
2470                        w * (a / row_sum)
2471                    } else {
2472                        0.0
2473                    };
2474                    result[row_indices[j]] = LayoutRect {
2475                        x: cx,
2476                        y,
2477                        w: cell_w,
2478                        h: row_h,
2479                    };
2480                    cx += cell_w;
2481                }
2482                squarify_recursive(
2483                    &areas[i..],
2484                    &indices[i..],
2485                    x,
2486                    y + row_h,
2487                    w,
2488                    h - row_h,
2489                    result,
2490                );
2491            }
2492            return;
2493        }
2494    }
2495
2496    // Layout remaining row
2497    if !row.is_empty() {
2498        let row_sum: f64 = row.iter().sum();
2499        if w >= h {
2500            let mut cy = y;
2501            for (j, &a) in row.iter().enumerate() {
2502                let cell_h = if row_sum > 0.0 {
2503                    h * (a / row_sum)
2504                } else {
2505                    0.0
2506                };
2507                result[row_indices[j]] = LayoutRect {
2508                    x,
2509                    y: cy,
2510                    w,
2511                    h: cell_h,
2512                };
2513                cy += cell_h;
2514            }
2515        } else {
2516            let mut cx = x;
2517            for (j, &a) in row.iter().enumerate() {
2518                let cell_w = if row_sum > 0.0 {
2519                    w * (a / row_sum)
2520                } else {
2521                    0.0
2522                };
2523                result[row_indices[j]] = LayoutRect {
2524                    x: cx,
2525                    y,
2526                    w: cell_w,
2527                    h,
2528                };
2529                cx += cell_w;
2530            }
2531        }
2532    }
2533}
2534
2535/// Linearly interpolate between two RGB colors.
2536///
2537/// Both colors must be `Color::Rgb` for true interpolation; mixed/named inputs
2538/// fall back to a binary threshold at `t = 0.5`. `t` is clamped to `[0.0, 1.0]`.
2539#[inline]
2540fn blend_color(a: Color, b: Color, t: f64) -> Color {
2541    let t = if t.is_finite() {
2542        t.clamp(0.0, 1.0)
2543    } else {
2544        0.0
2545    };
2546    match (a, b) {
2547        (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => Color::Rgb(
2548            (r1 as f64 * (1.0 - t) + r2 as f64 * t).round() as u8,
2549            (g1 as f64 * (1.0 - t) + g2 as f64 * t).round() as u8,
2550            (b1 as f64 * (1.0 - t) + b2 as f64 * t).round() as u8,
2551        ),
2552        _ => {
2553            if t > 0.5 {
2554                b
2555            } else {
2556                a
2557            }
2558        }
2559    }
2560}
2561
2562/// Choose a contrasting label color for treemap cells.
2563fn treemap_label_color(bg: Color) -> Color {
2564    Color::contrast_fg(bg)
2565}
2566
2567#[cfg(all(test, feature = "qrcode"))]
2568#[test]
2569fn test_qr_code() {
2570    let mut backend = crate::TestBackend::new(60, 30);
2571    backend.render(|ui| {
2572        let _ = ui.qr_code("hello");
2573    });
2574
2575    let output = backend.to_string();
2576    assert!(output.contains('▀') || output.contains('█'));
2577}
2578
2579#[test]
2580fn treemap_cjk_label_no_panic() {
2581    use super::TreemapItem;
2582    use crate::style::Color;
2583    let mut backend = crate::TestBackend::new(20, 10);
2584    backend.render(|ui| {
2585        let _ = ui.treemap(&[
2586            TreemapItem::new("한글파일", 100.0, Color::Cyan),
2587            TreemapItem::new("English", 50.0, Color::Yellow),
2588            TreemapItem::new("🎉파티", 30.0, Color::Green),
2589        ]);
2590    });
2591    backend.assert_contains("한글파일");
2592    backend.assert_contains("English");
2593}
2594
2595#[test]
2596fn treemap_named_indexed_and_rgb_colors_choose_contrast() {
2597    assert_eq!(treemap_label_color(Color::Black), Color::Rgb(255, 255, 255));
2598    assert_eq!(treemap_label_color(Color::White), Color::Rgb(0, 0, 0));
2599    assert_eq!(
2600        treemap_label_color(Color::Indexed(231)),
2601        Color::Rgb(0, 0, 0)
2602    );
2603    assert_eq!(
2604        treemap_label_color(Color::Rgb(10, 20, 30)),
2605        Color::Rgb(255, 255, 255)
2606    );
2607}
2608
2609#[test]
2610fn viz_widgets_return_outer_warm_frame_rects() {
2611    let mut sparkline_backend = crate::TestBackend::new(20, 4);
2612    let mut sparkline_response = Response::none();
2613    sparkline_backend.render(|ui| sparkline_response = ui.sparkline(&[1.0, 2.0, 3.0], 8));
2614    sparkline_backend.render(|ui| sparkline_response = ui.sparkline(&[1.0, 2.0, 3.0], 8));
2615    assert_eq!(sparkline_response.rect.width, 8);
2616    assert_eq!(sparkline_response.rect.height, 1);
2617
2618    let mut chart_backend = crate::TestBackend::new(30, 10);
2619    let mut chart_response = Response::none();
2620    chart_backend.render(|ui| {
2621        chart_response = ui.line_chart(&[1.0, 3.0, 2.0], 12, 4);
2622    });
2623    chart_backend.render(|ui| {
2624        chart_response = ui.line_chart(&[1.0, 3.0, 2.0], 12, 4);
2625    });
2626    assert_eq!(chart_response.rect.width, 12);
2627    assert_eq!(chart_response.rect.height, 4);
2628}
2629
2630#[test]
2631fn viz_non_finite_inputs_follow_missing_or_zero_policy() {
2632    let mut backend = crate::TestBackend::new(40, 12);
2633    backend.render(|ui| {
2634        let _ = ui.bar_chart(&[("nan", f64::NAN), ("inf", f64::INFINITY), ("ok", 2.0)], 8);
2635        let _ = ui.sparkline(&[f64::NEG_INFINITY, 1.0, f64::NAN, 2.0], 8);
2636    });
2637    let output = backend.to_string();
2638    assert!(!output.contains("NaN"));
2639    assert!(!output.contains("inf\u{221e}"));
2640}
2641
2642#[test]
2643fn extreme_finite_viz_ratios_remain_bounded() {
2644    for value in [-f64::MAX, 0.0, f64::MAX] {
2645        let ratio = unit_ratio(value, -f64::MAX, f64::MAX);
2646        assert!(ratio.is_finite());
2647        assert!((0.0..=1.0).contains(&ratio));
2648    }
2649}