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