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