1use super::*;
2
3impl Context {
4 pub fn bar_chart(&mut self, data: &[(&str, f64)], max_width: u32) -> Response {
25 if data.is_empty() {
26 return Response::none();
27 }
28
29 let max_label_width = data
30 .iter()
31 .map(|(label, _)| UnicodeWidthStr::width(*label))
32 .max()
33 .unwrap_or(0);
34 let max_value = data
35 .iter()
36 .map(|(_, value)| *value)
37 .fold(f64::NEG_INFINITY, f64::max);
38 let denom = if max_value > 0.0 { max_value } else { 1.0 };
39
40 self.interaction_count += 1;
41 self.commands.push(Command::BeginContainer {
42 direction: Direction::Column,
43 gap: 0,
44 align: Align::Start,
45 justify: Justify::Start,
46 border: None,
47 border_sides: BorderSides::all(),
48 border_style: Style::new().fg(self.theme.border),
49 bg_color: None,
50 padding: Padding::default(),
51 margin: Margin::default(),
52 constraints: Constraints::default(),
53 title: None,
54 grow: 0,
55 group_name: None,
56 });
57
58 for (label, value) in data {
59 let label_width = UnicodeWidthStr::width(*label);
60 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
61 let normalized = (*value / denom).clamp(0.0, 1.0);
62 let bar_len = (normalized * max_width as f64).round() as usize;
63 let bar = "█".repeat(bar_len);
64
65 self.interaction_count += 1;
66 self.commands.push(Command::BeginContainer {
67 direction: Direction::Row,
68 gap: 1,
69 align: Align::Start,
70 justify: Justify::Start,
71 border: None,
72 border_sides: BorderSides::all(),
73 border_style: Style::new().fg(self.theme.border),
74 bg_color: None,
75 padding: Padding::default(),
76 margin: Margin::default(),
77 constraints: Constraints::default(),
78 title: None,
79 grow: 0,
80 group_name: None,
81 });
82 self.styled(
83 format!("{label}{label_padding}"),
84 Style::new().fg(self.theme.text),
85 );
86 self.styled(bar, Style::new().fg(self.theme.primary));
87 self.styled(
88 format_compact_number(*value),
89 Style::new().fg(self.theme.text_dim),
90 );
91 self.commands.push(Command::EndContainer);
92 self.last_text_idx = None;
93 }
94
95 self.commands.push(Command::EndContainer);
96 self.last_text_idx = None;
97
98 Response::none()
99 }
100
101 pub fn bar_chart_styled(
117 &mut self,
118 bars: &[Bar],
119 max_width: u32,
120 direction: BarDirection,
121 ) -> Response {
122 if bars.is_empty() {
123 return Response::none();
124 }
125
126 let max_value = bars
127 .iter()
128 .map(|bar| bar.value)
129 .fold(f64::NEG_INFINITY, f64::max);
130 let denom = if max_value > 0.0 { max_value } else { 1.0 };
131
132 match direction {
133 BarDirection::Horizontal => self.render_horizontal_styled_bars(bars, max_width, denom),
134 BarDirection::Vertical => self.render_vertical_styled_bars(bars, max_width, denom),
135 }
136
137 Response::none()
138 }
139
140 fn render_horizontal_styled_bars(&mut self, bars: &[Bar], max_width: u32, denom: f64) {
141 let max_label_width = bars
142 .iter()
143 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
144 .max()
145 .unwrap_or(0);
146
147 self.interaction_count += 1;
148 self.commands.push(Command::BeginContainer {
149 direction: Direction::Column,
150 gap: 0,
151 align: Align::Start,
152 justify: Justify::Start,
153 border: None,
154 border_sides: BorderSides::all(),
155 border_style: Style::new().fg(self.theme.border),
156 bg_color: None,
157 padding: Padding::default(),
158 margin: Margin::default(),
159 constraints: Constraints::default(),
160 title: None,
161 grow: 0,
162 group_name: None,
163 });
164
165 for bar in bars {
166 self.render_horizontal_styled_bar_row(bar, max_label_width, max_width, denom);
167 }
168
169 self.commands.push(Command::EndContainer);
170 self.last_text_idx = None;
171 }
172
173 fn render_horizontal_styled_bar_row(
174 &mut self,
175 bar: &Bar,
176 max_label_width: usize,
177 max_width: u32,
178 denom: f64,
179 ) {
180 let label_width = UnicodeWidthStr::width(bar.label.as_str());
181 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
182 let normalized = (bar.value / denom).clamp(0.0, 1.0);
183 let bar_len = (normalized * max_width as f64).round() as usize;
184 let bar_text = "█".repeat(bar_len);
185 let color = bar.color.unwrap_or(self.theme.primary);
186
187 self.interaction_count += 1;
188 self.commands.push(Command::BeginContainer {
189 direction: Direction::Row,
190 gap: 1,
191 align: Align::Start,
192 justify: Justify::Start,
193 border: None,
194 border_sides: BorderSides::all(),
195 border_style: Style::new().fg(self.theme.border),
196 bg_color: None,
197 padding: Padding::default(),
198 margin: Margin::default(),
199 constraints: Constraints::default(),
200 title: None,
201 grow: 0,
202 group_name: None,
203 });
204 self.styled(
205 format!("{}{label_padding}", bar.label),
206 Style::new().fg(self.theme.text),
207 );
208 self.styled(bar_text, Style::new().fg(color));
209 self.styled(
210 format_compact_number(bar.value),
211 Style::new().fg(self.theme.text_dim),
212 );
213 self.commands.push(Command::EndContainer);
214 self.last_text_idx = None;
215 }
216
217 fn render_vertical_styled_bars(&mut self, bars: &[Bar], max_width: u32, denom: f64) {
218 let chart_height = max_width.max(1) as usize;
219 let value_labels: Vec<String> = bars
220 .iter()
221 .map(|bar| format_compact_number(bar.value))
222 .collect();
223 let col_width = bars
224 .iter()
225 .zip(value_labels.iter())
226 .map(|(bar, value)| {
227 UnicodeWidthStr::width(bar.label.as_str())
228 .max(UnicodeWidthStr::width(value.as_str()))
229 .max(1)
230 })
231 .max()
232 .unwrap_or(1);
233 let bar_units: Vec<usize> = bars
234 .iter()
235 .map(|bar| {
236 ((bar.value / denom).clamp(0.0, 1.0) * chart_height as f64 * 8.0).round() as usize
237 })
238 .collect();
239
240 self.interaction_count += 1;
241 self.commands.push(Command::BeginContainer {
242 direction: Direction::Column,
243 gap: 0,
244 align: Align::Start,
245 justify: Justify::Start,
246 border: None,
247 border_sides: BorderSides::all(),
248 border_style: Style::new().fg(self.theme.border),
249 bg_color: None,
250 padding: Padding::default(),
251 margin: Margin::default(),
252 constraints: Constraints::default(),
253 title: None,
254 grow: 0,
255 group_name: None,
256 });
257
258 self.render_vertical_bar_values(&value_labels, col_width);
259 self.render_vertical_bar_body(bars, &bar_units, chart_height, col_width);
260 self.render_vertical_bar_labels(bars, col_width);
261
262 self.commands.push(Command::EndContainer);
263 self.last_text_idx = None;
264 }
265
266 fn render_vertical_bar_values(&mut self, value_labels: &[String], col_width: usize) {
267 self.interaction_count += 1;
268 self.commands.push(Command::BeginContainer {
269 direction: Direction::Row,
270 gap: 1,
271 align: Align::Start,
272 justify: Justify::Start,
273 border: None,
274 border_sides: BorderSides::all(),
275 border_style: Style::new().fg(self.theme.border),
276 bg_color: None,
277 padding: Padding::default(),
278 margin: Margin::default(),
279 constraints: Constraints::default(),
280 title: None,
281 grow: 0,
282 group_name: None,
283 });
284 for value in value_labels {
285 self.styled(
286 center_text(value, col_width),
287 Style::new().fg(self.theme.text_dim),
288 );
289 }
290 self.commands.push(Command::EndContainer);
291 self.last_text_idx = None;
292 }
293
294 fn render_vertical_bar_body(
295 &mut self,
296 bars: &[Bar],
297 bar_units: &[usize],
298 chart_height: usize,
299 col_width: usize,
300 ) {
301 const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
302
303 for row in (0..chart_height).rev() {
304 self.interaction_count += 1;
305 self.commands.push(Command::BeginContainer {
306 direction: Direction::Row,
307 gap: 1,
308 align: Align::Start,
309 justify: Justify::Start,
310 border: None,
311 border_sides: BorderSides::all(),
312 border_style: Style::new().fg(self.theme.border),
313 bg_color: None,
314 padding: Padding::default(),
315 margin: Margin::default(),
316 constraints: Constraints::default(),
317 title: None,
318 grow: 0,
319 group_name: None,
320 });
321
322 let row_base = row * 8;
323 for (bar, units) in bars.iter().zip(bar_units.iter()) {
324 let fill = if *units <= row_base {
325 ' '
326 } else {
327 let delta = *units - row_base;
328 if delta >= 8 {
329 '█'
330 } else {
331 FRACTION_BLOCKS[delta]
332 }
333 };
334 self.styled(
335 center_text(&fill.to_string(), col_width),
336 Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
337 );
338 }
339
340 self.commands.push(Command::EndContainer);
341 self.last_text_idx = None;
342 }
343 }
344
345 fn render_vertical_bar_labels(&mut self, bars: &[Bar], col_width: usize) {
346 self.interaction_count += 1;
347 self.commands.push(Command::BeginContainer {
348 direction: Direction::Row,
349 gap: 1,
350 align: Align::Start,
351 justify: Justify::Start,
352 border: None,
353 border_sides: BorderSides::all(),
354 border_style: Style::new().fg(self.theme.border),
355 bg_color: None,
356 padding: Padding::default(),
357 margin: Margin::default(),
358 constraints: Constraints::default(),
359 title: None,
360 grow: 0,
361 group_name: None,
362 });
363 for bar in bars {
364 self.styled(
365 center_text(&bar.label, col_width),
366 Style::new().fg(self.theme.text),
367 );
368 }
369 self.commands.push(Command::EndContainer);
370 self.last_text_idx = None;
371 }
372
373 pub fn bar_chart_grouped(&mut self, groups: &[BarGroup], max_width: u32) -> Response {
390 if groups.is_empty() {
391 return Response::none();
392 }
393
394 let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
395 if all_bars.is_empty() {
396 return Response::none();
397 }
398
399 let max_label_width = all_bars
400 .iter()
401 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
402 .max()
403 .unwrap_or(0);
404 let max_value = all_bars
405 .iter()
406 .map(|bar| bar.value)
407 .fold(f64::NEG_INFINITY, f64::max);
408 let denom = if max_value > 0.0 { max_value } else { 1.0 };
409
410 self.interaction_count += 1;
411 self.commands.push(Command::BeginContainer {
412 direction: Direction::Column,
413 gap: 1,
414 align: Align::Start,
415 justify: Justify::Start,
416 border: None,
417 border_sides: BorderSides::all(),
418 border_style: Style::new().fg(self.theme.border),
419 bg_color: None,
420 padding: Padding::default(),
421 margin: Margin::default(),
422 constraints: Constraints::default(),
423 title: None,
424 grow: 0,
425 group_name: None,
426 });
427
428 for group in groups {
429 self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
430
431 for bar in &group.bars {
432 let label_width = UnicodeWidthStr::width(bar.label.as_str());
433 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
434 let normalized = (bar.value / denom).clamp(0.0, 1.0);
435 let bar_len = (normalized * max_width as f64).round() as usize;
436 let bar_text = "█".repeat(bar_len);
437
438 self.interaction_count += 1;
439 self.commands.push(Command::BeginContainer {
440 direction: Direction::Row,
441 gap: 1,
442 align: Align::Start,
443 justify: Justify::Start,
444 border: None,
445 border_sides: BorderSides::all(),
446 border_style: Style::new().fg(self.theme.border),
447 bg_color: None,
448 padding: Padding::default(),
449 margin: Margin::default(),
450 constraints: Constraints::default(),
451 title: None,
452 grow: 0,
453 group_name: None,
454 });
455 self.styled(
456 format!(" {}{label_padding}", bar.label),
457 Style::new().fg(self.theme.text),
458 );
459 self.styled(
460 bar_text,
461 Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
462 );
463 self.styled(
464 format_compact_number(bar.value),
465 Style::new().fg(self.theme.text_dim),
466 );
467 self.commands.push(Command::EndContainer);
468 self.last_text_idx = None;
469 }
470 }
471
472 self.commands.push(Command::EndContainer);
473 self.last_text_idx = None;
474
475 Response::none()
476 }
477
478 pub fn sparkline(&mut self, data: &[f64], width: u32) -> Response {
494 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
495
496 let w = width as usize;
497 let window = if data.len() > w {
498 &data[data.len() - w..]
499 } else {
500 data
501 };
502
503 if window.is_empty() {
504 return Response::none();
505 }
506
507 let min = window.iter().copied().fold(f64::INFINITY, f64::min);
508 let max = window.iter().copied().fold(f64::NEG_INFINITY, f64::max);
509 let range = max - min;
510
511 let line: String = window
512 .iter()
513 .map(|&value| {
514 let normalized = if range == 0.0 {
515 0.5
516 } else {
517 (value - min) / range
518 };
519 let idx = (normalized * 7.0).round() as usize;
520 BLOCKS[idx.min(7)]
521 })
522 .collect();
523
524 self.styled(line, Style::new().fg(self.theme.primary));
525 Response::none()
526 }
527
528 pub fn sparkline_styled(&mut self, data: &[(f64, Option<Color>)], width: u32) -> Response {
548 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
549
550 let w = width as usize;
551 let window = if data.len() > w {
552 &data[data.len() - w..]
553 } else {
554 data
555 };
556
557 if window.is_empty() {
558 return Response::none();
559 }
560
561 let mut finite_values = window
562 .iter()
563 .map(|(value, _)| *value)
564 .filter(|value| !value.is_nan());
565 let Some(first) = finite_values.next() else {
566 self.styled(
567 " ".repeat(window.len()),
568 Style::new().fg(self.theme.text_dim),
569 );
570 return Response::none();
571 };
572
573 let mut min = first;
574 let mut max = first;
575 for value in finite_values {
576 min = f64::min(min, value);
577 max = f64::max(max, value);
578 }
579 let range = max - min;
580
581 let mut cells: Vec<(char, Color)> = Vec::with_capacity(window.len());
582 for (value, color) in window {
583 if value.is_nan() {
584 cells.push((' ', self.theme.text_dim));
585 continue;
586 }
587
588 let normalized = if range == 0.0 {
589 0.5
590 } else {
591 ((*value - min) / range).clamp(0.0, 1.0)
592 };
593 let idx = (normalized * 7.0).round() as usize;
594 cells.push((BLOCKS[idx.min(7)], color.unwrap_or(self.theme.primary)));
595 }
596
597 self.interaction_count += 1;
598 self.commands.push(Command::BeginContainer {
599 direction: Direction::Row,
600 gap: 0,
601 align: Align::Start,
602 justify: Justify::Start,
603 border: None,
604 border_sides: BorderSides::all(),
605 border_style: Style::new().fg(self.theme.border),
606 bg_color: None,
607 padding: Padding::default(),
608 margin: Margin::default(),
609 constraints: Constraints::default(),
610 title: None,
611 grow: 0,
612 group_name: None,
613 });
614
615 let mut seg = String::new();
616 let mut seg_color = cells[0].1;
617 for (ch, color) in cells {
618 if color != seg_color {
619 self.styled(seg, Style::new().fg(seg_color));
620 seg = String::new();
621 seg_color = color;
622 }
623 seg.push(ch);
624 }
625 if !seg.is_empty() {
626 self.styled(seg, Style::new().fg(seg_color));
627 }
628
629 self.commands.push(Command::EndContainer);
630 self.last_text_idx = None;
631
632 Response::none()
633 }
634
635 pub fn line_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
649 self.line_chart_colored(data, width, height, self.theme.primary)
650 }
651
652 pub fn line_chart_colored(
654 &mut self,
655 data: &[f64],
656 width: u32,
657 height: u32,
658 color: Color,
659 ) -> Response {
660 self.render_line_chart_internal(data, width, height, color, false)
661 }
662
663 pub fn area_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
665 self.area_chart_colored(data, width, height, self.theme.primary)
666 }
667
668 pub fn area_chart_colored(
670 &mut self,
671 data: &[f64],
672 width: u32,
673 height: u32,
674 color: Color,
675 ) -> Response {
676 self.render_line_chart_internal(data, width, height, color, true)
677 }
678
679 fn render_line_chart_internal(
680 &mut self,
681 data: &[f64],
682 width: u32,
683 height: u32,
684 color: Color,
685 fill: bool,
686 ) -> Response {
687 if data.is_empty() || width == 0 || height == 0 {
688 return Response::none();
689 }
690
691 let cols = width as usize;
692 let rows = height as usize;
693 let px_w = cols * 2;
694 let px_h = rows * 4;
695
696 let min = data.iter().copied().fold(f64::INFINITY, f64::min);
697 let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
698 let range = if (max - min).abs() < f64::EPSILON {
699 1.0
700 } else {
701 max - min
702 };
703
704 let points: Vec<usize> = (0..px_w)
705 .map(|px| {
706 let data_idx = if px_w <= 1 {
707 0.0
708 } else {
709 px as f64 * (data.len() - 1) as f64 / (px_w - 1) as f64
710 };
711 let idx = data_idx.floor() as usize;
712 let frac = data_idx - idx as f64;
713 let value = if idx + 1 < data.len() {
714 data[idx] * (1.0 - frac) + data[idx + 1] * frac
715 } else {
716 data[idx.min(data.len() - 1)]
717 };
718
719 let normalized = (value - min) / range;
720 let py = ((1.0 - normalized) * (px_h - 1) as f64).round() as usize;
721 py.min(px_h - 1)
722 })
723 .collect();
724
725 const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
726 const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
727
728 let mut grid = vec![vec![0u32; cols]; rows];
729
730 for i in 0..points.len() {
731 let px = i;
732 let py = points[i];
733 let char_col = px / 2;
734 let char_row = py / 4;
735 let sub_col = px % 2;
736 let sub_row = py % 4;
737
738 if char_col < cols && char_row < rows {
739 grid[char_row][char_col] |= if sub_col == 0 {
740 LEFT_BITS[sub_row]
741 } else {
742 RIGHT_BITS[sub_row]
743 };
744 }
745
746 if i + 1 < points.len() {
747 let py_next = points[i + 1];
748 let (y_start, y_end) = if py <= py_next {
749 (py, py_next)
750 } else {
751 (py_next, py)
752 };
753 for y in y_start..=y_end {
754 let cell_row = y / 4;
755 let sub_y = y % 4;
756 if char_col < cols && cell_row < rows {
757 grid[cell_row][char_col] |= if sub_col == 0 {
758 LEFT_BITS[sub_y]
759 } else {
760 RIGHT_BITS[sub_y]
761 };
762 }
763 }
764 }
765
766 if fill {
767 for y in py..px_h {
768 let cell_row = y / 4;
769 let sub_y = y % 4;
770 if char_col < cols && cell_row < rows {
771 grid[cell_row][char_col] |= if sub_col == 0 {
772 LEFT_BITS[sub_y]
773 } else {
774 RIGHT_BITS[sub_y]
775 };
776 }
777 }
778 }
779 }
780
781 let style = Style::new().fg(color);
782 for row in grid {
783 let line: String = row
784 .iter()
785 .map(|&bits| char::from_u32(0x2800 + bits).unwrap_or(' '))
786 .collect();
787 self.styled(line, style);
788 }
789
790 Response::none()
791 }
792
793 pub fn candlestick(
795 &mut self,
796 candles: &[Candle],
797 width: u32,
798 height: u32,
799 up_color: Color,
800 down_color: Color,
801 ) -> Response {
802 if candles.is_empty() || width == 0 || height == 0 {
803 return Response::none();
804 }
805
806 let cols = width as usize;
807 let rows = height as usize;
808
809 let mut min_price = f64::INFINITY;
810 let mut max_price = f64::NEG_INFINITY;
811 for candle in candles {
812 if candle.low.is_finite() {
813 min_price = min_price.min(candle.low);
814 }
815 if candle.high.is_finite() {
816 max_price = max_price.max(candle.high);
817 }
818 }
819
820 if !min_price.is_finite() || !max_price.is_finite() {
821 return Response::none();
822 }
823
824 let range = if (max_price - min_price).abs() < f64::EPSILON {
825 1.0
826 } else {
827 max_price - min_price
828 };
829 let map_row = |value: f64| -> usize {
830 let t = ((value - min_price) / range).clamp(0.0, 1.0);
831 ((1.0 - t) * (rows.saturating_sub(1)) as f64).round() as usize
832 };
833
834 let mut chars = vec![vec![' '; cols]; rows];
835 let mut colors = vec![vec![None::<Color>; cols]; rows];
836
837 for (index, candle) in candles.iter().enumerate() {
838 if !candle.open.is_finite()
839 || !candle.high.is_finite()
840 || !candle.low.is_finite()
841 || !candle.close.is_finite()
842 {
843 continue;
844 }
845
846 let x_start = index * cols / candles.len();
847 let mut x_end = ((index + 1) * cols / candles.len()).saturating_sub(1);
848 if x_end < x_start {
849 x_end = x_start;
850 }
851 if x_start >= cols {
852 continue;
853 }
854 x_end = x_end.min(cols.saturating_sub(1));
855 let wick_x = (x_start + x_end) / 2;
856
857 let high_row = map_row(candle.high);
858 let low_row = map_row(candle.low);
859 let open_row = map_row(candle.open);
860 let close_row = map_row(candle.close);
861
862 let (wick_top, wick_bottom) = if high_row <= low_row {
863 (high_row, low_row)
864 } else {
865 (low_row, high_row)
866 };
867 let color = if candle.close >= candle.open {
868 up_color
869 } else {
870 down_color
871 };
872
873 for row in wick_top..=wick_bottom.min(rows.saturating_sub(1)) {
874 chars[row][wick_x] = '│';
875 colors[row][wick_x] = Some(color);
876 }
877
878 let (body_top, body_bottom) = if open_row <= close_row {
879 (open_row, close_row)
880 } else {
881 (close_row, open_row)
882 };
883 for row in body_top..=body_bottom.min(rows.saturating_sub(1)) {
884 for col in x_start..=x_end {
885 chars[row][col] = '█';
886 colors[row][col] = Some(color);
887 }
888 }
889 }
890
891 for row in 0..rows {
892 self.interaction_count += 1;
893 self.commands.push(Command::BeginContainer {
894 direction: Direction::Row,
895 gap: 0,
896 align: Align::Start,
897 justify: Justify::Start,
898 border: None,
899 border_sides: BorderSides::all(),
900 border_style: Style::new().fg(self.theme.border),
901 bg_color: None,
902 padding: Padding::default(),
903 margin: Margin::default(),
904 constraints: Constraints::default(),
905 title: None,
906 grow: 0,
907 group_name: None,
908 });
909
910 let mut seg = String::new();
911 let mut seg_color = colors[row][0];
912 for col in 0..cols {
913 if colors[row][col] != seg_color {
914 let style = if let Some(c) = seg_color {
915 Style::new().fg(c)
916 } else {
917 Style::new()
918 };
919 self.styled(seg, style);
920 seg = String::new();
921 seg_color = colors[row][col];
922 }
923 seg.push(chars[row][col]);
924 }
925 if !seg.is_empty() {
926 let style = if let Some(c) = seg_color {
927 Style::new().fg(c)
928 } else {
929 Style::new()
930 };
931 self.styled(seg, style);
932 }
933
934 self.commands.push(Command::EndContainer);
935 self.last_text_idx = None;
936 }
937
938 Response::none()
939 }
940
941 pub fn heatmap(
953 &mut self,
954 data: &[Vec<f64>],
955 width: u32,
956 height: u32,
957 low_color: Color,
958 high_color: Color,
959 ) -> Response {
960 fn blend_color(a: Color, b: Color, t: f64) -> Color {
961 let t = t.clamp(0.0, 1.0);
962 match (a, b) {
963 (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => Color::Rgb(
964 (r1 as f64 * (1.0 - t) + r2 as f64 * t).round() as u8,
965 (g1 as f64 * (1.0 - t) + g2 as f64 * t).round() as u8,
966 (b1 as f64 * (1.0 - t) + b2 as f64 * t).round() as u8,
967 ),
968 _ => {
969 if t > 0.5 {
970 b
971 } else {
972 a
973 }
974 }
975 }
976 }
977
978 if data.is_empty() || width == 0 || height == 0 {
979 return Response::none();
980 }
981
982 let data_rows = data.len();
983 let max_data_cols = data.iter().map(Vec::len).max().unwrap_or(0);
984 if max_data_cols == 0 {
985 return Response::none();
986 }
987
988 let mut min_value = f64::INFINITY;
989 let mut max_value = f64::NEG_INFINITY;
990 for row in data {
991 for value in row {
992 if value.is_finite() {
993 min_value = min_value.min(*value);
994 max_value = max_value.max(*value);
995 }
996 }
997 }
998
999 if !min_value.is_finite() || !max_value.is_finite() {
1000 return Response::none();
1001 }
1002
1003 let range = max_value - min_value;
1004 let zero_range = range.abs() < f64::EPSILON;
1005 let cols = width as usize;
1006 let rows = height as usize;
1007
1008 for row_idx in 0..rows {
1009 let data_row_idx = (row_idx * data_rows / rows).min(data_rows.saturating_sub(1));
1010 let source_row = &data[data_row_idx];
1011 let source_cols = source_row.len();
1012
1013 self.interaction_count += 1;
1014 self.commands.push(Command::BeginContainer {
1015 direction: Direction::Row,
1016 gap: 0,
1017 align: Align::Start,
1018 justify: Justify::Start,
1019 border: None,
1020 border_sides: BorderSides::all(),
1021 border_style: Style::new().fg(self.theme.border),
1022 bg_color: None,
1023 padding: Padding::default(),
1024 margin: Margin::default(),
1025 constraints: Constraints::default(),
1026 title: None,
1027 grow: 0,
1028 group_name: None,
1029 });
1030
1031 let mut segment = String::new();
1032 let mut segment_color: Option<Color> = None;
1033
1034 for col_idx in 0..cols {
1035 let normalized = if source_cols == 0 {
1036 0.0
1037 } else {
1038 let data_col_idx = (col_idx * source_cols / cols).min(source_cols - 1);
1039 let value = source_row[data_col_idx];
1040
1041 if !value.is_finite() {
1042 0.0
1043 } else if zero_range {
1044 0.5
1045 } else {
1046 ((value - min_value) / range).clamp(0.0, 1.0)
1047 }
1048 };
1049
1050 let color = blend_color(low_color, high_color, normalized);
1051
1052 match segment_color {
1053 Some(current) if current == color => {
1054 segment.push('█');
1055 }
1056 Some(current) => {
1057 self.styled(std::mem::take(&mut segment), Style::new().fg(current));
1058 segment.push('█');
1059 segment_color = Some(color);
1060 }
1061 None => {
1062 segment.push('█');
1063 segment_color = Some(color);
1064 }
1065 }
1066 }
1067
1068 if let Some(color) = segment_color {
1069 self.styled(segment, Style::new().fg(color));
1070 }
1071
1072 self.commands.push(Command::EndContainer);
1073 self.last_text_idx = None;
1074 }
1075
1076 Response::none()
1077 }
1078
1079 pub fn canvas(
1096 &mut self,
1097 width: u32,
1098 height: u32,
1099 draw: impl FnOnce(&mut CanvasContext),
1100 ) -> Response {
1101 if width == 0 || height == 0 {
1102 return Response::none();
1103 }
1104
1105 let mut canvas = CanvasContext::new(width as usize, height as usize);
1106 draw(&mut canvas);
1107
1108 for segments in canvas.render() {
1109 self.interaction_count += 1;
1110 self.commands.push(Command::BeginContainer {
1111 direction: Direction::Row,
1112 gap: 0,
1113 align: Align::Start,
1114 justify: Justify::Start,
1115 border: None,
1116 border_sides: BorderSides::all(),
1117 border_style: Style::new(),
1118 bg_color: None,
1119 padding: Padding::default(),
1120 margin: Margin::default(),
1121 constraints: Constraints::default(),
1122 title: None,
1123 grow: 0,
1124 group_name: None,
1125 });
1126 for (text, color) in segments {
1127 let c = if color == Color::Reset {
1128 self.theme.primary
1129 } else {
1130 color
1131 };
1132 self.styled(text, Style::new().fg(c));
1133 }
1134 self.commands.push(Command::EndContainer);
1135 self.last_text_idx = None;
1136 }
1137
1138 Response::none()
1139 }
1140
1141 pub fn chart(
1147 &mut self,
1148 configure: impl FnOnce(&mut ChartBuilder),
1149 width: u32,
1150 height: u32,
1151 ) -> Response {
1152 if width == 0 || height == 0 {
1153 return Response::none();
1154 }
1155
1156 let axis_style = Style::new().fg(self.theme.text_dim);
1157 let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
1158 configure(&mut builder);
1159
1160 let config = builder.build();
1161 let rows = render_chart(&config);
1162
1163 for row in rows {
1164 self.interaction_count += 1;
1165 self.commands.push(Command::BeginContainer {
1166 direction: Direction::Row,
1167 gap: 0,
1168 align: Align::Start,
1169 justify: Justify::Start,
1170 border: None,
1171 border_sides: BorderSides::all(),
1172 border_style: Style::new().fg(self.theme.border),
1173 bg_color: None,
1174 padding: Padding::default(),
1175 margin: Margin::default(),
1176 constraints: Constraints::default(),
1177 title: None,
1178 grow: 0,
1179 group_name: None,
1180 });
1181 for (text, style) in row.segments {
1182 self.styled(text, style);
1183 }
1184 self.commands.push(Command::EndContainer);
1185 self.last_text_idx = None;
1186 }
1187
1188 Response::none()
1189 }
1190
1191 pub fn scatter(&mut self, data: &[(f64, f64)], width: u32, height: u32) -> Response {
1195 self.chart(
1196 |c| {
1197 c.scatter(data);
1198 c.grid(true);
1199 },
1200 width,
1201 height,
1202 )
1203 }
1204
1205 pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> Response {
1207 self.histogram_with(data, |_| {}, width, height)
1208 }
1209
1210 pub fn histogram_with(
1212 &mut self,
1213 data: &[f64],
1214 configure: impl FnOnce(&mut HistogramBuilder),
1215 width: u32,
1216 height: u32,
1217 ) -> Response {
1218 if width == 0 || height == 0 {
1219 return Response::none();
1220 }
1221
1222 let mut options = HistogramBuilder::default();
1223 configure(&mut options);
1224 let axis_style = Style::new().fg(self.theme.text_dim);
1225 let config = build_histogram_config(data, &options, width, height, axis_style);
1226 let rows = render_chart(&config);
1227
1228 for row in rows {
1229 self.interaction_count += 1;
1230 self.commands.push(Command::BeginContainer {
1231 direction: Direction::Row,
1232 gap: 0,
1233 align: Align::Start,
1234 justify: Justify::Start,
1235 border: None,
1236 border_sides: BorderSides::all(),
1237 border_style: Style::new().fg(self.theme.border),
1238 bg_color: None,
1239 padding: Padding::default(),
1240 margin: Margin::default(),
1241 constraints: Constraints::default(),
1242 title: None,
1243 grow: 0,
1244 group_name: None,
1245 });
1246 for (text, style) in row.segments {
1247 self.styled(text, style);
1248 }
1249 self.commands.push(Command::EndContainer);
1250 self.last_text_idx = None;
1251 }
1252
1253 Response::none()
1254 }
1255}