1use 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 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 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 i32,
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 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 i32,
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 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 i32,
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 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 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 i32,
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 i32,
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 i32,
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 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 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 { v1 } else { v2 }
816 } else {
817 v1 * (1.0 - frac) + v2 * frac
818 };
819 (value, color)
820 })
821 .collect()
822 };
823
824 let mut finite_values = window
825 .iter()
826 .map(|(value, _)| *value)
827 .filter(|value| !value.is_nan());
828 let Some(first) = finite_values.next() else {
829 self.styled(
830 " ".repeat(window.len()),
831 Style::new().fg(self.theme.text_dim),
832 );
833 return Response::none();
834 };
835
836 let mut min = first;
837 let mut max = first;
838 for value in finite_values {
839 min = f64::min(min, value);
840 max = f64::max(max, value);
841 }
842 let range = max - min;
843
844 let mut cells: Vec<(char, Color)> = Vec::with_capacity(window.len());
845 for (value, color) in &window {
846 if value.is_nan() {
847 cells.push((' ', self.theme.text_dim));
848 continue;
849 }
850
851 let normalized = if range == 0.0 {
852 0.5
853 } else {
854 ((*value - min) / range).clamp(0.0, 1.0)
855 };
856 let idx = (normalized * 7.0).round() as usize;
857 cells.push((BLOCKS[idx.min(7)], color.unwrap_or(self.theme.primary)));
858 }
859
860 self.skip_interaction_slot();
861 self.commands
862 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
863 direction: Direction::Row,
864 gap: 0,
865 align: Align::Start,
866 align_self: None,
867 justify: Justify::Start,
868 border: None,
869 border_sides: BorderSides::all(),
870 border_style: Style::new().fg(self.theme.border),
871 bg_color: None,
872 padding: Padding::default(),
873 margin: Margin::default(),
874 constraints: Constraints::default(),
875 title: None,
876 grow: 0,
877 group_name: None,
878 })));
879
880 if cells.is_empty() {
881 self.commands.push(Command::EndContainer);
882 self.rollback.last_text_idx = None;
883 return Response::none();
884 }
885
886 let mut seg = String::new();
887 let mut seg_color = cells[0].1;
888 for (ch, color) in cells {
889 if color != seg_color {
890 self.styled(seg, Style::new().fg(seg_color));
891 seg = String::new();
892 seg_color = color;
893 }
894 seg.push(ch);
895 }
896 if !seg.is_empty() {
897 self.styled(seg, Style::new().fg(seg_color));
898 }
899
900 self.commands.push(Command::EndContainer);
901 self.rollback.last_text_idx = None;
902
903 Response::none()
904 }
905
906 pub fn line_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
920 self.line_chart_colored(data, width, height, self.theme.primary)
921 }
922
923 pub fn line_chart_colored(
925 &mut self,
926 data: &[f64],
927 width: u32,
928 height: u32,
929 color: Color,
930 ) -> Response {
931 self.render_line_chart_internal(data, width, height, color, false)
932 }
933
934 pub fn area_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
936 self.area_chart_colored(data, width, height, self.theme.primary)
937 }
938
939 pub fn area_chart_colored(
941 &mut self,
942 data: &[f64],
943 width: u32,
944 height: u32,
945 color: Color,
946 ) -> Response {
947 self.render_line_chart_internal(data, width, height, color, true)
948 }
949
950 fn render_line_chart_internal(
951 &mut self,
952 data: &[f64],
953 width: u32,
954 height: u32,
955 color: Color,
956 fill: bool,
957 ) -> Response {
958 if data.is_empty() || width == 0 || height == 0 {
959 return Response::none();
960 }
961
962 let cols = width as usize;
963 let rows = height as usize;
964 let px_w = cols * 2;
965 let px_h = rows * 4;
966
967 let min = data.iter().copied().fold(f64::INFINITY, f64::min);
968 let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
969 let range = if (max - min).abs() < f64::EPSILON {
970 1.0
971 } else {
972 max - min
973 };
974
975 let points: Vec<usize> = (0..px_w)
976 .map(|px| {
977 let data_idx = if px_w <= 1 {
978 0.0
979 } else {
980 px as f64 * (data.len() - 1) as f64 / (px_w - 1) as f64
981 };
982 let idx = data_idx.floor() as usize;
983 let frac = data_idx - idx as f64;
984 let value = if idx + 1 < data.len() {
985 data[idx] * (1.0 - frac) + data[idx + 1] * frac
986 } else {
987 data[idx.min(data.len() - 1)]
988 };
989
990 let normalized = (value - min) / range;
991 let py = ((1.0 - normalized) * (px_h - 1) as f64).round() as usize;
992 py.min(px_h - 1)
993 })
994 .collect();
995
996 use crate::chart::{BRAILLE_LEFT_BITS as LEFT_BITS, BRAILLE_RIGHT_BITS as RIGHT_BITS};
998
999 let mut grid = vec![vec![0u32; cols]; rows];
1000
1001 for i in 0..points.len() {
1002 let px = i;
1003 let py = points[i];
1004 let char_col = px / 2;
1005 let char_row = py / 4;
1006 let sub_col = px % 2;
1007 let sub_row = py % 4;
1008
1009 if char_col < cols && char_row < rows {
1010 grid[char_row][char_col] |= if sub_col == 0 {
1011 LEFT_BITS[sub_row]
1012 } else {
1013 RIGHT_BITS[sub_row]
1014 };
1015 }
1016
1017 if i + 1 < points.len() {
1018 let py_next = points[i + 1];
1019 let (y_start, y_end) = if py <= py_next {
1020 (py, py_next)
1021 } else {
1022 (py_next, py)
1023 };
1024 for y in y_start..=y_end {
1025 let cell_row = y / 4;
1026 let sub_y = y % 4;
1027 if char_col < cols && cell_row < rows {
1028 grid[cell_row][char_col] |= if sub_col == 0 {
1029 LEFT_BITS[sub_y]
1030 } else {
1031 RIGHT_BITS[sub_y]
1032 };
1033 }
1034 }
1035 }
1036
1037 if fill {
1038 for y in py..px_h {
1039 let cell_row = y / 4;
1040 let sub_y = y % 4;
1041 if char_col < cols && cell_row < rows {
1042 grid[cell_row][char_col] |= if sub_col == 0 {
1043 LEFT_BITS[sub_y]
1044 } else {
1045 RIGHT_BITS[sub_y]
1046 };
1047 }
1048 }
1049 }
1050 }
1051
1052 let style = Style::new().fg(color);
1053 for row in grid {
1054 let line: String = row
1055 .iter()
1056 .map(|&bits| char::from_u32(0x2800 + bits).unwrap_or(' '))
1057 .collect();
1058 self.styled(line, style);
1059 }
1060
1061 Response::none()
1062 }
1063
1064 pub fn candlestick(
1066 &mut self,
1067 candles: &[Candle],
1068 up_color: Color,
1069 down_color: Color,
1070 ) -> Response {
1071 if candles.is_empty() {
1072 return Response::none();
1073 }
1074
1075 let candles = candles.to_vec();
1076 self.container().grow(1).draw(move |buf, rect| {
1077 let w = rect.width as usize;
1078 let h = rect.height as usize;
1079 if w < 2 || h < 2 {
1080 return;
1081 }
1082
1083 let mut lo = f64::INFINITY;
1084 let mut hi = f64::NEG_INFINITY;
1085 for c in &candles {
1086 if c.low.is_finite() {
1087 lo = lo.min(c.low);
1088 }
1089 if c.high.is_finite() {
1090 hi = hi.max(c.high);
1091 }
1092 }
1093
1094 if !lo.is_finite() || !hi.is_finite() {
1095 return;
1096 }
1097
1098 let range = if (hi - lo).abs() < 0.01 { 1.0 } else { hi - lo };
1099 let map_y = |v: f64| -> usize {
1100 let t = ((v - lo) / range).clamp(0.0, 1.0);
1101 ((1.0 - t) * (h.saturating_sub(1)) as f64).round() as usize
1102 };
1103
1104 for (i, c) in candles.iter().enumerate() {
1105 if !c.open.is_finite()
1106 || !c.high.is_finite()
1107 || !c.low.is_finite()
1108 || !c.close.is_finite()
1109 {
1110 continue;
1111 }
1112
1113 let x0 = i * w / candles.len();
1114 let x1 = ((i + 1) * w / candles.len()).saturating_sub(1).max(x0);
1115 if x0 >= w {
1116 continue;
1117 }
1118 let xm = (x0 + x1) / 2;
1119 let color = if c.close >= c.open {
1120 up_color
1121 } else {
1122 down_color
1123 };
1124
1125 let wt = map_y(c.high);
1126 let wb = map_y(c.low);
1127 for row in wt..=wb.min(h - 1) {
1128 buf.set_char(
1129 rect.x + xm as u32,
1130 rect.y + row as u32,
1131 '│',
1132 Style::new().fg(color),
1133 );
1134 }
1135
1136 let bt = map_y(c.open.max(c.close));
1137 let bb = map_y(c.open.min(c.close));
1138 for row in bt..=bb.min(h - 1) {
1139 for col in x0..=x1.min(w - 1) {
1140 buf.set_char(
1141 rect.x + col as u32,
1142 rect.y + row as u32,
1143 '█',
1144 Style::new().fg(color),
1145 );
1146 }
1147 }
1148 }
1149 });
1150
1151 Response::none()
1152 }
1153
1154 pub fn heatmap(
1166 &mut self,
1167 data: &[Vec<f64>],
1168 width: u32,
1169 height: u32,
1170 low_color: Color,
1171 high_color: Color,
1172 ) -> Response {
1173 if data.is_empty() || width == 0 || height == 0 {
1174 return Response::none();
1175 }
1176
1177 let data_rows = data.len();
1178 let max_data_cols = data.iter().map(Vec::len).max().unwrap_or(0);
1179 if max_data_cols == 0 {
1180 return Response::none();
1181 }
1182
1183 let mut min_value = f64::INFINITY;
1184 let mut max_value = f64::NEG_INFINITY;
1185 for row in data {
1186 for value in row {
1187 if value.is_finite() {
1188 min_value = min_value.min(*value);
1189 max_value = max_value.max(*value);
1190 }
1191 }
1192 }
1193
1194 if !min_value.is_finite() || !max_value.is_finite() {
1195 return Response::none();
1196 }
1197
1198 let range = max_value - min_value;
1199 let zero_range = range.abs() < f64::EPSILON;
1200 let cols = width as usize;
1201 let rows = height as usize;
1202
1203 for row_idx in 0..rows {
1204 let data_row_idx = (row_idx * data_rows / rows).min(data_rows.saturating_sub(1));
1205 let source_row = &data[data_row_idx];
1206 let source_cols = source_row.len();
1207
1208 self.skip_interaction_slot();
1209 self.commands
1210 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1211 direction: Direction::Row,
1212 gap: 0,
1213 align: Align::Start,
1214 align_self: None,
1215 justify: Justify::Start,
1216 border: None,
1217 border_sides: BorderSides::all(),
1218 border_style: Style::new().fg(self.theme.border),
1219 bg_color: None,
1220 padding: Padding::default(),
1221 margin: Margin::default(),
1222 constraints: Constraints::default(),
1223 title: None,
1224 grow: 0,
1225 group_name: None,
1226 })));
1227
1228 let mut segment = String::new();
1229 let mut segment_color: Option<Color> = None;
1230
1231 for col_idx in 0..cols {
1232 let normalized = if source_cols == 0 {
1233 0.0
1234 } else {
1235 let data_col_idx = (col_idx * source_cols / cols).min(source_cols - 1);
1236 let value = source_row[data_col_idx];
1237
1238 if !value.is_finite() {
1239 0.0
1240 } else if zero_range {
1241 0.5
1242 } else {
1243 ((value - min_value) / range).clamp(0.0, 1.0)
1244 }
1245 };
1246
1247 let color = blend_color(low_color, high_color, normalized);
1248
1249 match segment_color {
1250 Some(current) if current == color => {
1251 segment.push('█');
1252 }
1253 Some(current) => {
1254 self.styled(std::mem::take(&mut segment), Style::new().fg(current));
1255 segment.push('█');
1256 segment_color = Some(color);
1257 }
1258 None => {
1259 segment.push('█');
1260 segment_color = Some(color);
1261 }
1262 }
1263 }
1264
1265 if let Some(color) = segment_color {
1266 self.styled(segment, Style::new().fg(color));
1267 }
1268
1269 self.commands.push(Command::EndContainer);
1270 self.rollback.last_text_idx = None;
1271 }
1272
1273 Response::none()
1274 }
1275
1276 pub fn canvas(
1293 &mut self,
1294 width: u32,
1295 height: u32,
1296 draw: impl FnOnce(&mut CanvasContext),
1297 ) -> Response {
1298 if width == 0 || height == 0 {
1299 return Response::none();
1300 }
1301
1302 let mut canvas = CanvasContext::new(width as usize, height as usize);
1303 draw(&mut canvas);
1304
1305 for segments in canvas.render() {
1306 self.skip_interaction_slot();
1307 self.commands
1308 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1309 direction: Direction::Row,
1310 gap: 0,
1311 align: Align::Start,
1312 align_self: None,
1313 justify: Justify::Start,
1314 border: None,
1315 border_sides: BorderSides::all(),
1316 border_style: Style::new(),
1317 bg_color: None,
1318 padding: Padding::default(),
1319 margin: Margin::default(),
1320 constraints: Constraints::default(),
1321 title: None,
1322 grow: 0,
1323 group_name: None,
1324 })));
1325 for (text, color) in segments {
1326 let c = if color == Color::Reset {
1327 self.theme.primary
1328 } else {
1329 color
1330 };
1331 self.styled(text, Style::new().fg(c));
1332 }
1333 self.commands.push(Command::EndContainer);
1334 self.rollback.last_text_idx = None;
1335 }
1336
1337 Response::none()
1338 }
1339
1340 pub fn chart(
1346 &mut self,
1347 configure: impl FnOnce(&mut ChartBuilder),
1348 width: u32,
1349 height: u32,
1350 ) -> Response {
1351 if width == 0 || height == 0 {
1352 return Response::none();
1353 }
1354
1355 let axis_style = Style::new().fg(self.theme.text_dim);
1356 let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
1357 configure(&mut builder);
1358
1359 let config = builder.build();
1360 let rows = render_chart(&config);
1361
1362 for row in rows {
1363 self.skip_interaction_slot();
1364 self.commands
1365 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1366 direction: Direction::Row,
1367 gap: 0,
1368 align: Align::Start,
1369 align_self: None,
1370 justify: Justify::Start,
1371 border: None,
1372 border_sides: BorderSides::all(),
1373 border_style: Style::new().fg(self.theme.border),
1374 bg_color: None,
1375 padding: Padding::default(),
1376 margin: Margin::default(),
1377 constraints: Constraints::default(),
1378 title: None,
1379 grow: 0,
1380 group_name: None,
1381 })));
1382 for (text, style) in row.segments {
1383 self.styled(text, style);
1384 }
1385 self.commands.push(Command::EndContainer);
1386 self.rollback.last_text_idx = None;
1387 }
1388
1389 Response::none()
1390 }
1391
1392 pub fn scatter(&mut self, data: &[(f64, f64)], width: u32, height: u32) -> Response {
1396 self.chart(
1397 |c| {
1398 c.scatter(data);
1399 c.grid(true);
1400 },
1401 width,
1402 height,
1403 )
1404 }
1405
1406 pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> Response {
1408 self.histogram_with(data, |_| {}, width, height)
1409 }
1410
1411 pub fn histogram_with(
1413 &mut self,
1414 data: &[f64],
1415 configure: impl FnOnce(&mut HistogramBuilder),
1416 width: u32,
1417 height: u32,
1418 ) -> Response {
1419 if width == 0 || height == 0 {
1420 return Response::none();
1421 }
1422
1423 let mut options = HistogramBuilder::default();
1424 configure(&mut options);
1425 let axis_style = Style::new().fg(self.theme.text_dim);
1426 let config = build_histogram_config(data, &options, width, height, axis_style);
1427 let rows = render_chart(&config);
1428
1429 for row in rows {
1430 self.skip_interaction_slot();
1431 self.commands
1432 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1433 direction: Direction::Row,
1434 gap: 0,
1435 align: Align::Start,
1436 align_self: None,
1437 justify: Justify::Start,
1438 border: None,
1439 border_sides: BorderSides::all(),
1440 border_style: Style::new().fg(self.theme.border),
1441 bg_color: None,
1442 padding: Padding::default(),
1443 margin: Margin::default(),
1444 constraints: Constraints::default(),
1445 title: None,
1446 grow: 0,
1447 group_name: None,
1448 })));
1449 for (text, style) in row.segments {
1450 self.styled(text, style);
1451 }
1452 self.commands.push(Command::EndContainer);
1453 self.rollback.last_text_idx = None;
1454 }
1455
1456 Response::none()
1457 }
1458
1459 #[cfg(feature = "qrcode")]
1460 #[cfg_attr(docsrs, doc(cfg(feature = "qrcode")))]
1461 pub fn qr_code(&mut self, data: impl AsRef<str>) -> Response {
1463 let code = match qrcode::QrCode::new(data.as_ref()) {
1464 Ok(code) => code,
1465 Err(_) => {
1466 self.text("[QR Error]");
1467 return Response::none();
1468 }
1469 };
1470
1471 let modules_per_side = code.width();
1472 let modules = code.to_colors();
1473 let qr_side = modules_per_side + 2;
1474 let qr_width = qr_side;
1475 let qr_height = qr_side.div_ceil(2);
1476 let theme_text = self.theme.text;
1477 let theme_bg = self.theme.bg;
1478
1479 self.container()
1480 .w(qr_width as u32)
1481 .h(qr_height as u32)
1482 .draw(move |buf, rect| {
1483 let draw_w = (rect.width as usize).min(qr_width);
1484 let draw_h = (rect.height as usize).min(qr_height);
1485
1486 for row in 0..draw_h {
1487 let upper_y = row * 2;
1488 let lower_y = upper_y + 1;
1489
1490 for x in 0..draw_w {
1491 let resolve_module_color = |mx: usize, my: usize| -> Color {
1492 let dark =
1493 if mx == 0 || my == 0 || mx == qr_side - 1 || my == qr_side - 1 {
1494 false
1495 } else {
1496 let inner_x = mx - 1;
1497 let inner_y = my - 1;
1498 let idx = inner_y * modules_per_side + inner_x;
1499 matches!(modules.get(idx), Some(qrcode::types::Color::Dark))
1500 };
1501
1502 if dark { theme_text } else { theme_bg }
1503 };
1504
1505 let upper = resolve_module_color(x, upper_y);
1506 let lower = if lower_y < qr_side {
1507 resolve_module_color(x, lower_y)
1508 } else {
1509 theme_bg
1510 };
1511
1512 buf.set_char(
1513 rect.x + x as u32,
1514 rect.y + row as u32,
1515 '▀',
1516 Style::new().fg(upper).bg(lower),
1517 );
1518 }
1519 }
1520 });
1521
1522 Response::none()
1523 }
1524
1525 pub fn heatmap_halfblock(
1543 &mut self,
1544 data: &[Vec<f64>],
1545 width: u32,
1546 height: u32,
1547 low_color: Color,
1548 high_color: Color,
1549 ) -> Response {
1550 if data.is_empty() || width == 0 || height == 0 {
1551 return Response::none();
1552 }
1553
1554 let data_rows = data.len();
1555 let max_data_cols = data.iter().map(Vec::len).max().unwrap_or(0);
1556 if max_data_cols == 0 {
1557 return Response::none();
1558 }
1559
1560 let mut min_value = f64::INFINITY;
1561 let mut max_value = f64::NEG_INFINITY;
1562 for row in data {
1563 for value in row {
1564 if value.is_finite() {
1565 min_value = min_value.min(*value);
1566 max_value = max_value.max(*value);
1567 }
1568 }
1569 }
1570
1571 if !min_value.is_finite() || !max_value.is_finite() {
1572 return Response::none();
1573 }
1574
1575 let range = max_value - min_value;
1576 let zero_range = range.abs() < f64::EPSILON;
1577
1578 let data = data.to_vec();
1579 let cols = width as usize;
1580 let rows = height as usize;
1581 let virtual_rows = rows * 2;
1583
1584 self.container().w(width).h(height).draw(move |buf, rect| {
1585 let w = rect.width as usize;
1586 let h = rect.height as usize;
1587 if w == 0 || h == 0 {
1588 return;
1589 }
1590
1591 let sample = |data_row_idx: usize, col_idx: usize| -> f64 {
1592 let src_row = &data[data_row_idx.min(data_rows.saturating_sub(1))];
1593 let src_cols = src_row.len();
1594 if src_cols == 0 {
1595 return 0.0;
1596 }
1597 let data_col = (col_idx * src_cols / cols.max(1)).min(src_cols - 1);
1598 let v = src_row[data_col];
1599 if !v.is_finite() {
1600 0.0
1601 } else if zero_range {
1602 0.5
1603 } else {
1604 ((v - min_value) / range).clamp(0.0, 1.0)
1605 }
1606 };
1607
1608 for row in 0..h {
1609 let upper_data_row =
1610 (row * 2 * data_rows / virtual_rows).min(data_rows.saturating_sub(1));
1611 let lower_data_row =
1612 ((row * 2 + 1) * data_rows / virtual_rows).min(data_rows.saturating_sub(1));
1613
1614 for col in 0..w.min(cols) {
1615 let upper_t = sample(upper_data_row, col);
1616 let lower_t = sample(lower_data_row, col);
1617 let upper_color = blend_color(low_color, high_color, upper_t);
1618 let lower_color = blend_color(low_color, high_color, lower_t);
1619
1620 buf.set_char(
1621 rect.x + col as u32,
1622 rect.y + row as u32,
1623 '▀',
1624 Style::new().fg(upper_color).bg(lower_color),
1625 );
1626 }
1627 }
1628 });
1629
1630 Response::none()
1631 }
1632
1633 pub fn candlestick_hd(
1656 &mut self,
1657 candles: &[Candle],
1658 up_color: Color,
1659 down_color: Color,
1660 ) -> Response {
1661 if candles.is_empty() {
1662 return Response::none();
1663 }
1664
1665 let candles = candles.to_vec();
1666 self.container().grow(1).draw(move |buf, rect| {
1667 let w = rect.width as usize;
1668 let h = rect.height as usize;
1669 if w < 2 || h < 2 {
1670 return;
1671 }
1672
1673 let mut lo = f64::INFINITY;
1674 let mut hi = f64::NEG_INFINITY;
1675 for c in &candles {
1676 if c.low.is_finite() {
1677 lo = lo.min(c.low);
1678 }
1679 if c.high.is_finite() {
1680 hi = hi.max(c.high);
1681 }
1682 }
1683 if !lo.is_finite() || !hi.is_finite() {
1684 return;
1685 }
1686
1687 let price_range = if (hi - lo).abs() < 0.01 { 1.0 } else { hi - lo };
1688 let map_y = |v: f64| -> usize {
1690 let t = ((v - lo) / price_range).clamp(0.0, 1.0);
1691 ((1.0 - t) * h.saturating_sub(1) as f64).round() as usize
1692 };
1693 let half_rows = h.saturating_mul(2);
1696 let map_y_half = |v: f64| -> usize {
1697 let t = ((v - lo) / price_range).clamp(0.0, 1.0);
1698 ((1.0 - t) * half_rows.saturating_sub(1) as f64).round() as usize
1699 };
1700
1701 let n = candles.len();
1702
1703 for (i, c) in candles.iter().enumerate() {
1704 if !c.open.is_finite()
1705 || !c.high.is_finite()
1706 || !c.low.is_finite()
1707 || !c.close.is_finite()
1708 {
1709 continue;
1710 }
1711
1712 let x0 = i * w / n;
1714 let x1 = ((i + 1) * w / n).saturating_sub(1).max(x0);
1715 if x0 >= w {
1716 continue;
1717 }
1718 let xm = x0 + (x1 - x0) / 2;
1720 let color = if c.close >= c.open {
1721 up_color
1722 } else {
1723 down_color
1724 };
1725
1726 let wick_top = map_y(c.high);
1728 let wick_bot = map_y(c.low);
1729 for row in wick_top..=wick_bot.min(h - 1) {
1730 buf.set_char(
1731 rect.x + xm as u32,
1732 rect.y + row as u32,
1733 '┃',
1734 Style::new().fg(color),
1735 );
1736 }
1737
1738 let body_top_half = map_y_half(c.open.max(c.close));
1743 let body_bot_half = map_y_half(c.open.min(c.close));
1744 let row_first = body_top_half / 2;
1745 let row_last = (body_bot_half / 2).min(h - 1);
1746 for row in row_first..=row_last {
1747 let top_hc = row * 2;
1748 let bot_hc = row * 2 + 1;
1749 let top_in = top_hc >= body_top_half && top_hc <= body_bot_half;
1750 let bot_in = bot_hc >= body_top_half && bot_hc <= body_bot_half;
1751 let body_char = match (top_in, bot_in) {
1752 (true, true) => '█',
1753 (true, false) => '▀',
1754 (false, true) => '▄',
1755 (false, false) => continue,
1756 };
1757 for col in x0..=x1.min(w - 1) {
1758 buf.set_char(
1759 rect.x + col as u32,
1760 rect.y + row as u32,
1761 body_char,
1762 Style::new().fg(color),
1763 );
1764 }
1765 }
1766 }
1767 });
1768
1769 Response::none()
1770 }
1771
1772 pub fn treemap(&mut self, items: &[TreemapItem]) -> Response {
1792 if items.is_empty() {
1793 return Response::none();
1794 }
1795
1796 let items = items.to_vec();
1797 self.container().grow(1).draw(move |buf, rect| {
1798 let w = rect.width as usize;
1799 let h = rect.height as usize;
1800 if w < 2 || h < 2 {
1801 return;
1802 }
1803
1804 let total_area = w as f64 * h as f64;
1806 let total_value: f64 = items.iter().map(|i| i.value.max(0.0)).sum();
1807 let min_area_threshold = 1.0; let visible_items: Vec<&TreemapItem> = if total_value > 0.0 {
1809 items
1810 .iter()
1811 .filter(|item| {
1812 item.value.max(0.0) / total_value * total_area >= min_area_threshold
1813 })
1814 .collect()
1815 } else {
1816 return;
1817 };
1818
1819 if visible_items.is_empty() {
1820 return;
1821 }
1822
1823 let filtered: Vec<TreemapItem> = visible_items.into_iter().cloned().collect();
1825 let rects = squarify_layout(&filtered, 0.0, 0.0, w as f64, h as f64);
1826
1827 for (item, r) in filtered.iter().zip(rects.iter()) {
1828 let x0 = r.x.round() as usize;
1830 let y0 = r.y.round() as usize;
1831 let x1 = (r.x + r.w).round() as usize;
1832 let y1 = (r.y + r.h).round() as usize;
1833
1834 let cell_w = x1.min(w).saturating_sub(x0);
1835 let cell_h = y1.min(h).saturating_sub(y0);
1836 if cell_w == 0 || cell_h == 0 {
1837 continue;
1838 }
1839
1840 for row in y0..y1.min(h) {
1842 for col in x0..x1.min(w) {
1843 buf.set_char(
1844 rect.x + col as u32,
1845 rect.y + row as u32,
1846 ' ',
1847 Style::new().bg(item.color),
1848 );
1849 }
1850 }
1851
1852 let text_color = treemap_label_color(item.color);
1853
1854 if cell_w >= 2 {
1857 let max_label_w = cell_w.saturating_sub(1);
1858 let label_owned = crate::chart::truncate_label(&item.label, max_label_w);
1859 let label = label_owned.as_str();
1860 let label_unicode_w = UnicodeWidthStr::width(label);
1861 let label_y = y0 + cell_h / 2;
1862 let label_x = x0 + (cell_w.saturating_sub(label_unicode_w)) / 2;
1863 if label_y < y1.min(h) {
1864 for (offset, ch) in label.chars().enumerate() {
1865 let cx = label_x + offset;
1866 if cx < x1.min(w) {
1867 buf.set_char(
1868 rect.x + cx as u32,
1869 rect.y + label_y as u32,
1870 ch,
1871 Style::new().fg(text_color).bg(item.color).bold(),
1872 );
1873 }
1874 }
1875 }
1876
1877 if cell_h >= 3 {
1879 let value_str = format_compact_number(item.value);
1880 let value_y = label_y + 1;
1881 if value_y < y1.min(h) && value_str.len() < cell_w {
1882 let vx = x0 + (cell_w.saturating_sub(value_str.len())) / 2;
1883 for (offset, ch) in value_str.chars().enumerate() {
1884 let cx = vx + offset;
1885 if cx < x1.min(w) {
1886 buf.set_char(
1887 rect.x + cx as u32,
1888 rect.y + value_y as u32,
1889 ch,
1890 Style::new().fg(text_color).bg(item.color).dim(),
1891 );
1892 }
1893 }
1894 }
1895 }
1896 }
1897 }
1898 });
1899
1900 Response::none()
1901 }
1902
1903 pub fn bar_chart_stacked(&mut self, groups: &[BarGroup], max_height: u32) -> Response {
1927 self.bar_chart_stacked_with(groups, |_| {}, max_height)
1928 }
1929
1930 pub fn bar_chart_stacked_with(
1934 &mut self,
1935 groups: &[BarGroup],
1936 configure: impl FnOnce(&mut BarChartConfig),
1937 max_height: u32,
1938 ) -> Response {
1939 if groups.is_empty() {
1940 return Response::none();
1941 }
1942
1943 let all_bars: Vec<&Bar> = groups.iter().flat_map(|g| g.bars.iter()).collect();
1944 if all_bars.is_empty() {
1945 return Response::none();
1946 }
1947
1948 let mut config = BarChartConfig::default();
1949 config.bar_width(3).bar_gap(1);
1950 configure(&mut config);
1951
1952 let max_total: f64 = groups
1954 .iter()
1955 .map(|g| g.bars.iter().map(|b| b.value.max(0.0)).sum::<f64>())
1956 .fold(f64::NEG_INFINITY, f64::max);
1957 let denom = config.max_value.unwrap_or(max_total);
1958 let denom = if denom > 0.0 { denom } else { 1.0 };
1959
1960 let chart_height = max_height.max(1) as usize;
1961 let bar_width = config.bar_width.max(1) as usize;
1962 let gap = config.bar_gap as i32;
1963
1964 const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
1965
1966 self.skip_interaction_slot();
1967 self.commands
1968 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
1969 direction: Direction::Column,
1970 gap: 0,
1971 align: Align::Start,
1972 align_self: None,
1973 justify: Justify::Start,
1974 border: None,
1975 border_sides: BorderSides::all(),
1976 border_style: Style::new().fg(self.theme.border),
1977 bg_color: None,
1978 padding: Padding::default(),
1979 margin: Margin::default(),
1980 constraints: Constraints::default(),
1981 title: None,
1982 grow: 0,
1983 group_name: None,
1984 })));
1985
1986 struct StackedSegment {
1988 units: usize,
1989 color: Color,
1990 }
1991 let stacked_groups: Vec<(String, Vec<StackedSegment>)> = groups
1992 .iter()
1993 .map(|g| {
1994 let segs: Vec<StackedSegment> = g
1995 .bars
1996 .iter()
1997 .map(|b| {
1998 let normalized = (b.value.max(0.0) / denom).clamp(0.0, 1.0);
1999 StackedSegment {
2000 units: (normalized * chart_height as f64 * 8.0).round() as usize,
2001 color: b.color.unwrap_or(self.theme.primary),
2002 }
2003 })
2004 .collect();
2005 (g.label.clone(), segs)
2006 })
2007 .collect();
2008
2009 for row in (0..chart_height).rev() {
2011 self.skip_interaction_slot();
2012 self.commands
2013 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
2014 direction: Direction::Row,
2015 gap,
2016 align: Align::Start,
2017 align_self: None,
2018 justify: Justify::Start,
2019 border: None,
2020 border_sides: BorderSides::all(),
2021 border_style: Style::new().fg(self.theme.border),
2022 bg_color: None,
2023 padding: Padding::default(),
2024 margin: Margin::default(),
2025 constraints: Constraints::default(),
2026 title: None,
2027 grow: 0,
2028 group_name: None,
2029 })));
2030
2031 let row_base = row * 8;
2032
2033 for (_label, segs) in &stacked_groups {
2034 let mut accumulated = 0usize;
2036 let mut cell_char = ' ';
2037 let mut cell_color = self.theme.bg;
2038
2039 for seg in segs {
2040 let seg_bottom = accumulated;
2041 let seg_top = accumulated + seg.units;
2042
2043 if seg_top <= row_base {
2044 accumulated = seg_top;
2046 continue;
2047 }
2048
2049 if seg_bottom >= row_base + 8 {
2050 break;
2052 }
2053
2054 let local_bottom = seg_bottom.saturating_sub(row_base);
2056 let local_top = (seg_top - row_base).min(8);
2057 let fill = local_top - local_bottom;
2058
2059 if local_bottom == 0 {
2060 cell_char = if fill >= 8 {
2062 '█'
2063 } else {
2064 FRACTION_BLOCKS[fill]
2065 };
2066 cell_color = seg.color;
2067 } else {
2068 cell_char = '█';
2070 cell_color = seg.color;
2071 }
2072
2073 accumulated = seg_top;
2074 }
2075
2076 let fill_text = cell_char.to_string().repeat(bar_width);
2077 self.styled(fill_text, Style::new().fg(cell_color));
2078 }
2079
2080 self.commands.push(Command::EndContainer);
2081 self.rollback.last_text_idx = None;
2082 }
2083
2084 self.skip_interaction_slot();
2086 self.commands
2087 .push(Command::BeginContainer(Box::new(BeginContainerArgs {
2088 direction: Direction::Row,
2089 gap,
2090 align: Align::Start,
2091 align_self: None,
2092 justify: Justify::Start,
2093 border: None,
2094 border_sides: BorderSides::all(),
2095 border_style: Style::new().fg(self.theme.border),
2096 bg_color: None,
2097 padding: Padding::default(),
2098 margin: Margin::default(),
2099 constraints: Constraints::default(),
2100 title: None,
2101 grow: 0,
2102 group_name: None,
2103 })));
2104 for (label, _) in &stacked_groups {
2105 self.styled(
2106 Self::center_and_truncate_text(label, bar_width),
2107 Style::new().fg(self.theme.text),
2108 );
2109 }
2110 self.commands.push(Command::EndContainer);
2111 self.rollback.last_text_idx = None;
2112
2113 self.commands.push(Command::EndContainer);
2114 self.rollback.last_text_idx = None;
2115
2116 Response::none()
2117 }
2118}
2119
2120#[derive(Debug, Clone)]
2122pub struct TreemapItem {
2123 pub label: String,
2125 pub value: f64,
2127 pub color: Color,
2129}
2130
2131impl TreemapItem {
2132 pub fn new(label: impl Into<String>, value: f64, color: Color) -> Self {
2134 Self {
2135 label: label.into(),
2136 value,
2137 color,
2138 }
2139 }
2140}
2141
2142#[derive(Clone)]
2144struct LayoutRect {
2145 x: f64,
2146 y: f64,
2147 w: f64,
2148 h: f64,
2149}
2150
2151fn squarify_layout(items: &[TreemapItem], x: f64, y: f64, w: f64, h: f64) -> Vec<LayoutRect> {
2153 if items.is_empty() || w <= 0.0 || h <= 0.0 {
2154 return Vec::new();
2155 }
2156
2157 let total: f64 = items.iter().map(|i| i.value.max(0.0)).sum();
2158 if total <= 0.0 {
2159 return items
2160 .iter()
2161 .map(|_| LayoutRect {
2162 x,
2163 y,
2164 w: 0.0,
2165 h: 0.0,
2166 })
2167 .collect();
2168 }
2169
2170 let area = w * h;
2172 let mut sorted_indices: Vec<usize> = (0..items.len()).collect();
2173 sorted_indices.sort_by(|a, b| items[*b].value.total_cmp(&items[*a].value));
2174
2175 let areas: Vec<f64> = sorted_indices
2176 .iter()
2177 .map(|&i| items[i].value.max(0.0) / total * area)
2178 .collect();
2179
2180 let mut result = vec![
2181 LayoutRect {
2182 x: 0.0,
2183 y: 0.0,
2184 w: 0.0,
2185 h: 0.0,
2186 };
2187 items.len()
2188 ];
2189 squarify_recursive(&areas, &sorted_indices, x, y, w, h, &mut result);
2190 result
2191}
2192
2193#[inline]
2204fn worst_ratio_incremental(
2205 sum: f64,
2206 pos_max: f64,
2207 pos_min: f64,
2208 pos_count: usize,
2209 side: f64,
2210) -> f64 {
2211 if side <= 0.0 {
2212 return f64::INFINITY;
2213 }
2214 if pos_count == 0 {
2215 return 0.0;
2216 }
2217 let s2 = side * side;
2218 let sum2 = sum * sum;
2219 (s2 * pos_max / sum2).max(sum2 / (s2 * pos_min))
2221}
2222
2223fn squarify_recursive(
2224 areas: &[f64],
2225 indices: &[usize],
2226 x: f64,
2227 y: f64,
2228 w: f64,
2229 h: f64,
2230 result: &mut [LayoutRect],
2231) {
2232 if areas.is_empty() || w <= 0.0 || h <= 0.0 {
2233 return;
2234 }
2235
2236 if areas.len() == 1 {
2237 result[indices[0]] = LayoutRect { x, y, w, h };
2238 return;
2239 }
2240
2241 let short_side = w.min(h);
2242 let mut row: Vec<f64> = Vec::new();
2243 let mut row_indices: Vec<usize> = Vec::new();
2244 let mut row_sum_acc = 0f64;
2248 let mut row_pos_max = f64::NEG_INFINITY;
2249 let mut row_pos_min = f64::INFINITY;
2250 let mut row_pos_count: usize = 0;
2251
2252 for (i, &area) in areas.iter().enumerate() {
2253 let cand_sum = row_sum_acc + area;
2254 let (cand_pos_max, cand_pos_min, cand_pos_count) = if area > 0.0 {
2255 (
2256 row_pos_max.max(area),
2257 row_pos_min.min(area),
2258 row_pos_count + 1,
2259 )
2260 } else {
2261 (row_pos_max, row_pos_min, row_pos_count)
2262 };
2263
2264 let candidate_ratio = worst_ratio_incremental(
2265 cand_sum,
2266 cand_pos_max,
2267 cand_pos_min,
2268 cand_pos_count,
2269 short_side,
2270 );
2271 let current_ratio = worst_ratio_incremental(
2272 row_sum_acc,
2273 row_pos_max,
2274 row_pos_min,
2275 row_pos_count,
2276 short_side,
2277 );
2278 if row.is_empty() || candidate_ratio <= current_ratio {
2279 row.push(area);
2280 row_indices.push(indices[i]);
2281 row_sum_acc = cand_sum;
2282 row_pos_max = cand_pos_max;
2283 row_pos_min = cand_pos_min;
2284 row_pos_count = cand_pos_count;
2285 } else {
2286 let row_sum: f64 = row.iter().sum();
2288 let row_fraction = row_sum / (w * h).max(f64::EPSILON);
2289
2290 if w >= h {
2291 let row_w = w * row_fraction;
2293 let mut cy = y;
2294 for (j, &a) in row.iter().enumerate() {
2295 let cell_h = if row_sum > 0.0 {
2296 h * (a / row_sum)
2297 } else {
2298 0.0
2299 };
2300 result[row_indices[j]] = LayoutRect {
2301 x,
2302 y: cy,
2303 w: row_w,
2304 h: cell_h,
2305 };
2306 cy += cell_h;
2307 }
2308 squarify_recursive(
2309 &areas[i..],
2310 &indices[i..],
2311 x + row_w,
2312 y,
2313 w - row_w,
2314 h,
2315 result,
2316 );
2317 } else {
2318 let row_h = h * row_fraction;
2320 let mut cx = x;
2321 for (j, &a) in row.iter().enumerate() {
2322 let cell_w = if row_sum > 0.0 {
2323 w * (a / row_sum)
2324 } else {
2325 0.0
2326 };
2327 result[row_indices[j]] = LayoutRect {
2328 x: cx,
2329 y,
2330 w: cell_w,
2331 h: row_h,
2332 };
2333 cx += cell_w;
2334 }
2335 squarify_recursive(
2336 &areas[i..],
2337 &indices[i..],
2338 x,
2339 y + row_h,
2340 w,
2341 h - row_h,
2342 result,
2343 );
2344 }
2345 return;
2346 }
2347 }
2348
2349 if !row.is_empty() {
2351 let row_sum: f64 = row.iter().sum();
2352 if w >= h {
2353 let mut cy = y;
2354 for (j, &a) in row.iter().enumerate() {
2355 let cell_h = if row_sum > 0.0 {
2356 h * (a / row_sum)
2357 } else {
2358 0.0
2359 };
2360 result[row_indices[j]] = LayoutRect {
2361 x,
2362 y: cy,
2363 w,
2364 h: cell_h,
2365 };
2366 cy += cell_h;
2367 }
2368 } else {
2369 let mut cx = x;
2370 for (j, &a) in row.iter().enumerate() {
2371 let cell_w = if row_sum > 0.0 {
2372 w * (a / row_sum)
2373 } else {
2374 0.0
2375 };
2376 result[row_indices[j]] = LayoutRect {
2377 x: cx,
2378 y,
2379 w: cell_w,
2380 h,
2381 };
2382 cx += cell_w;
2383 }
2384 }
2385 }
2386}
2387
2388#[inline]
2393fn blend_color(a: Color, b: Color, t: f64) -> Color {
2394 let t = t.clamp(0.0, 1.0);
2395 match (a, b) {
2396 (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => Color::Rgb(
2397 (r1 as f64 * (1.0 - t) + r2 as f64 * t).round() as u8,
2398 (g1 as f64 * (1.0 - t) + g2 as f64 * t).round() as u8,
2399 (b1 as f64 * (1.0 - t) + b2 as f64 * t).round() as u8,
2400 ),
2401 _ => {
2402 if t > 0.5 {
2403 b
2404 } else {
2405 a
2406 }
2407 }
2408 }
2409}
2410
2411fn treemap_label_color(bg: Color) -> Color {
2413 match bg {
2414 Color::Rgb(r, g, b) => {
2415 let lum = 0.299 * r as f64 + 0.587 * g as f64 + 0.114 * b as f64;
2417 if lum > 128.0 {
2418 Color::Rgb(0, 0, 0)
2419 } else {
2420 Color::Rgb(255, 255, 255)
2421 }
2422 }
2423 _ => Color::White,
2424 }
2425}
2426
2427#[cfg(all(test, feature = "qrcode"))]
2428#[test]
2429fn test_qr_code() {
2430 let mut backend = crate::TestBackend::new(60, 30);
2431 backend.render(|ui| {
2432 let _ = ui.qr_code("hello");
2433 });
2434
2435 let output = backend.to_string();
2436 assert!(output.contains('▀') || output.contains('█'));
2437}
2438
2439#[test]
2440fn treemap_cjk_label_no_panic() {
2441 use super::TreemapItem;
2442 use crate::style::Color;
2443 let mut backend = crate::TestBackend::new(20, 10);
2444 backend.render(|ui| {
2445 let _ = ui.treemap(&[
2446 TreemapItem::new("한글파일", 100.0, Color::Cyan),
2447 TreemapItem::new("English", 50.0, Color::Yellow),
2448 TreemapItem::new("🎉파티", 30.0, Color::Green),
2449 ]);
2450 });
2451 }