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 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 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 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 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 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 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 {
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 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 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 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 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 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 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 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 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 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 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 pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> Response {
1412 self.histogram_with(data, |_| {}, width, height)
1413 }
1414
1415 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 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 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 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 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 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 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 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 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 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 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 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 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; 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 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 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 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 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 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 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 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 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 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 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 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 accumulated = seg_top;
2053 continue;
2054 }
2055
2056 if seg_bottom >= row_base + 8 {
2057 break;
2059 }
2060
2061 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 cell_char = if fill >= 8 {
2069 '█'
2070 } else {
2071 FRACTION_BLOCKS[fill]
2072 };
2073 cell_color = seg.color;
2074 } else {
2075 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 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#[derive(Debug, Clone)]
2129pub struct TreemapItem {
2130 pub label: String,
2132 pub value: f64,
2134 pub color: Color,
2136}
2137
2138impl TreemapItem {
2139 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#[derive(Clone)]
2151struct LayoutRect {
2152 x: f64,
2153 y: f64,
2154 w: f64,
2155 h: f64,
2156}
2157
2158fn 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 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#[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 (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 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 let row_sum: f64 = row.iter().sum();
2295 let row_fraction = row_sum / (w * h).max(f64::EPSILON);
2296
2297 if w >= h {
2298 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 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 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#[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
2418fn treemap_label_color(bg: Color) -> Color {
2420 match bg {
2421 Color::Rgb(r, g, b) => {
2422 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 }