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 = Self::horizontal_bar_text(normalized, max_width);
63
64 self.interaction_count += 1;
65 self.commands.push(Command::BeginContainer {
66 direction: Direction::Row,
67 gap: 1,
68 align: Align::Start,
69 justify: Justify::Start,
70 border: None,
71 border_sides: BorderSides::all(),
72 border_style: Style::new().fg(self.theme.border),
73 bg_color: None,
74 padding: Padding::default(),
75 margin: Margin::default(),
76 constraints: Constraints::default(),
77 title: None,
78 grow: 0,
79 group_name: None,
80 });
81 self.styled(
82 format!("{label}{label_padding}"),
83 Style::new().fg(self.theme.text),
84 );
85 self.styled(bar, Style::new().fg(self.theme.primary));
86 self.styled(
87 format_compact_number(*value),
88 Style::new().fg(self.theme.text_dim),
89 );
90 self.commands.push(Command::EndContainer);
91 self.last_text_idx = None;
92 }
93
94 self.commands.push(Command::EndContainer);
95 self.last_text_idx = None;
96
97 Response::none()
98 }
99
100 pub fn bar_chart_styled(
116 &mut self,
117 bars: &[Bar],
118 max_width: u32,
119 direction: BarDirection,
120 ) -> Response {
121 self.bar_chart_with(
122 bars,
123 |config| {
124 config.direction(direction);
125 },
126 max_width,
127 )
128 }
129
130 pub fn bar_chart_with(
131 &mut self,
132 bars: &[Bar],
133 configure: impl FnOnce(&mut BarChartConfig),
134 max_size: u32,
135 ) -> Response {
136 if bars.is_empty() {
137 return Response::none();
138 }
139
140 let mut config = BarChartConfig::default();
141 configure(&mut config);
142
143 let auto_max = bars
144 .iter()
145 .map(|bar| bar.value)
146 .fold(f64::NEG_INFINITY, f64::max);
147 let max_value = config.max_value.unwrap_or(auto_max);
148 let denom = if max_value > 0.0 { max_value } else { 1.0 };
149
150 match config.direction {
151 BarDirection::Horizontal => {
152 self.render_horizontal_styled_bars(bars, max_size, denom, config.bar_gap)
153 }
154 BarDirection::Vertical => self.render_vertical_styled_bars(
155 bars,
156 max_size,
157 denom,
158 config.bar_width,
159 config.bar_gap,
160 ),
161 }
162
163 Response::none()
164 }
165
166 fn render_horizontal_styled_bars(
167 &mut self,
168 bars: &[Bar],
169 max_width: u32,
170 denom: f64,
171 bar_gap: u16,
172 ) {
173 let max_label_width = bars
174 .iter()
175 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
176 .max()
177 .unwrap_or(0);
178
179 self.interaction_count += 1;
180 self.commands.push(Command::BeginContainer {
181 direction: Direction::Column,
182 gap: bar_gap as u32,
183 align: Align::Start,
184 justify: Justify::Start,
185 border: None,
186 border_sides: BorderSides::all(),
187 border_style: Style::new().fg(self.theme.border),
188 bg_color: None,
189 padding: Padding::default(),
190 margin: Margin::default(),
191 constraints: Constraints::default(),
192 title: None,
193 grow: 0,
194 group_name: None,
195 });
196
197 for bar in bars {
198 self.render_horizontal_styled_bar_row(bar, max_label_width, max_width, denom);
199 }
200
201 self.commands.push(Command::EndContainer);
202 self.last_text_idx = None;
203 }
204
205 fn render_horizontal_styled_bar_row(
206 &mut self,
207 bar: &Bar,
208 max_label_width: usize,
209 max_width: u32,
210 denom: f64,
211 ) {
212 let label_width = UnicodeWidthStr::width(bar.label.as_str());
213 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
214 let normalized = (bar.value / denom).clamp(0.0, 1.0);
215 let bar_text = Self::horizontal_bar_text(normalized, max_width);
216 let color = bar.color.unwrap_or(self.theme.primary);
217
218 self.interaction_count += 1;
219 self.commands.push(Command::BeginContainer {
220 direction: Direction::Row,
221 gap: 1,
222 align: Align::Start,
223 justify: Justify::Start,
224 border: None,
225 border_sides: BorderSides::all(),
226 border_style: Style::new().fg(self.theme.border),
227 bg_color: None,
228 padding: Padding::default(),
229 margin: Margin::default(),
230 constraints: Constraints::default(),
231 title: None,
232 grow: 0,
233 group_name: None,
234 });
235 self.styled(
236 format!("{}{label_padding}", bar.label),
237 Style::new().fg(self.theme.text),
238 );
239 self.styled(bar_text, Style::new().fg(color));
240 self.styled(
241 Self::bar_display_value(bar),
242 bar.value_style
243 .unwrap_or(Style::new().fg(self.theme.text_dim)),
244 );
245 self.commands.push(Command::EndContainer);
246 self.last_text_idx = None;
247 }
248
249 fn render_vertical_styled_bars(
250 &mut self,
251 bars: &[Bar],
252 max_height: u32,
253 denom: f64,
254 bar_width: u16,
255 bar_gap: u16,
256 ) {
257 let chart_height = max_height.max(1) as usize;
258 let bar_width = bar_width.max(1) as usize;
259 let value_labels: Vec<String> = bars.iter().map(Self::bar_display_value).collect();
260 let label_width = bars
261 .iter()
262 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
263 .max()
264 .unwrap_or(1);
265 let value_width = value_labels
266 .iter()
267 .map(|value| UnicodeWidthStr::width(value.as_str()))
268 .max()
269 .unwrap_or(1);
270 let col_width = bar_width.max(label_width.max(value_width).max(1));
271 let bar_units: Vec<usize> = bars
272 .iter()
273 .map(|bar| {
274 ((bar.value / denom).clamp(0.0, 1.0) * chart_height as f64 * 8.0).round() as usize
275 })
276 .collect();
277
278 self.interaction_count += 1;
279 self.commands.push(Command::BeginContainer {
280 direction: Direction::Column,
281 gap: 0,
282 align: Align::Start,
283 justify: Justify::Start,
284 border: None,
285 border_sides: BorderSides::all(),
286 border_style: Style::new().fg(self.theme.border),
287 bg_color: None,
288 padding: Padding::default(),
289 margin: Margin::default(),
290 constraints: Constraints::default(),
291 title: None,
292 grow: 0,
293 group_name: None,
294 });
295
296 self.render_vertical_bar_body(
297 bars,
298 &bar_units,
299 chart_height,
300 col_width,
301 bar_width,
302 bar_gap,
303 &value_labels,
304 );
305 self.render_vertical_bar_labels(bars, col_width, bar_gap);
306
307 self.commands.push(Command::EndContainer);
308 self.last_text_idx = None;
309 }
310
311 #[allow(clippy::too_many_arguments)]
312 fn render_vertical_bar_body(
313 &mut self,
314 bars: &[Bar],
315 bar_units: &[usize],
316 chart_height: usize,
317 col_width: usize,
318 bar_width: usize,
319 bar_gap: u16,
320 value_labels: &[String],
321 ) {
322 const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
323
324 let top_rows: Vec<usize> = bar_units
326 .iter()
327 .map(|units| {
328 if *units == 0 {
329 usize::MAX
330 } else {
331 (*units - 1) / 8
332 }
333 })
334 .collect();
335
336 for row in (0..chart_height).rev() {
337 self.interaction_count += 1;
338 self.commands.push(Command::BeginContainer {
339 direction: Direction::Row,
340 gap: bar_gap as u32,
341 align: Align::Start,
342 justify: Justify::Start,
343 border: None,
344 border_sides: BorderSides::all(),
345 border_style: Style::new().fg(self.theme.border),
346 bg_color: None,
347 padding: Padding::default(),
348 margin: Margin::default(),
349 constraints: Constraints::default(),
350 title: None,
351 grow: 0,
352 group_name: None,
353 });
354
355 let row_base = row * 8;
356 for (i, (bar, units)) in bars.iter().zip(bar_units.iter()).enumerate() {
357 let color = bar.color.unwrap_or(self.theme.primary);
358
359 if *units <= row_base {
360 if top_rows[i] != usize::MAX && row == top_rows[i] + 1 {
362 let label = &value_labels[i];
363 let centered = Self::center_and_truncate_text(label, col_width);
364 self.styled(
365 centered,
366 bar.value_style.unwrap_or(Style::new().fg(color).bold()),
367 );
368 } else {
369 let empty = " ".repeat(col_width);
370 self.styled(empty, Style::new());
371 }
372 continue;
373 }
374
375 if row == top_rows[i] && top_rows[i] + 1 >= chart_height {
376 let label = &value_labels[i];
377 let centered = Self::center_and_truncate_text(label, col_width);
378 self.styled(
379 centered,
380 bar.value_style.unwrap_or(Style::new().fg(color).bold()),
381 );
382 continue;
383 }
384
385 let delta = *units - row_base;
386 let fill = if delta >= 8 {
387 '█'
388 } else {
389 FRACTION_BLOCKS[delta]
390 };
391 let fill_text = fill.to_string().repeat(bar_width);
392 let centered_fill = center_text(&fill_text, col_width);
393 self.styled(centered_fill, Style::new().fg(color));
394 }
395
396 self.commands.push(Command::EndContainer);
397 self.last_text_idx = None;
398 }
399 }
400
401 fn render_vertical_bar_labels(&mut self, bars: &[Bar], col_width: usize, bar_gap: u16) {
402 self.interaction_count += 1;
403 self.commands.push(Command::BeginContainer {
404 direction: Direction::Row,
405 gap: bar_gap as u32,
406 align: Align::Start,
407 justify: Justify::Start,
408 border: None,
409 border_sides: BorderSides::all(),
410 border_style: Style::new().fg(self.theme.border),
411 bg_color: None,
412 padding: Padding::default(),
413 margin: Margin::default(),
414 constraints: Constraints::default(),
415 title: None,
416 grow: 0,
417 group_name: None,
418 });
419 for bar in bars {
420 self.styled(
421 Self::center_and_truncate_text(&bar.label, col_width),
422 Style::new().fg(self.theme.text),
423 );
424 }
425 self.commands.push(Command::EndContainer);
426 self.last_text_idx = None;
427 }
428
429 pub fn bar_chart_grouped(&mut self, groups: &[BarGroup], max_width: u32) -> Response {
446 self.bar_chart_grouped_with(groups, |_| {}, max_width)
447 }
448
449 pub fn bar_chart_grouped_with(
450 &mut self,
451 groups: &[BarGroup],
452 configure: impl FnOnce(&mut BarChartConfig),
453 max_size: u32,
454 ) -> Response {
455 if groups.is_empty() {
456 return Response::none();
457 }
458
459 let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
460 if all_bars.is_empty() {
461 return Response::none();
462 }
463
464 let mut config = BarChartConfig::default();
465 configure(&mut config);
466
467 let auto_max = all_bars
468 .iter()
469 .map(|bar| bar.value)
470 .fold(f64::NEG_INFINITY, f64::max);
471 let max_value = config.max_value.unwrap_or(auto_max);
472 let denom = if max_value > 0.0 { max_value } else { 1.0 };
473
474 match config.direction {
475 BarDirection::Horizontal => {
476 self.render_grouped_horizontal_bars(groups, max_size, denom, &config)
477 }
478 BarDirection::Vertical => {
479 self.render_grouped_vertical_bars(groups, max_size, denom, &config)
480 }
481 }
482
483 Response::none()
484 }
485
486 fn render_grouped_horizontal_bars(
487 &mut self,
488 groups: &[BarGroup],
489 max_width: u32,
490 denom: f64,
491 config: &BarChartConfig,
492 ) {
493 let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
494 let max_label_width = all_bars
495 .iter()
496 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
497 .max()
498 .unwrap_or(0);
499
500 self.interaction_count += 1;
501 self.commands.push(Command::BeginContainer {
502 direction: Direction::Column,
503 gap: config.group_gap as u32,
504 align: Align::Start,
505 justify: Justify::Start,
506 border: None,
507 border_sides: BorderSides::all(),
508 border_style: Style::new().fg(self.theme.border),
509 bg_color: None,
510 padding: Padding::default(),
511 margin: Margin::default(),
512 constraints: Constraints::default(),
513 title: None,
514 grow: 0,
515 group_name: None,
516 });
517
518 for group in groups {
519 self.interaction_count += 1;
520 self.commands.push(Command::BeginContainer {
521 direction: Direction::Column,
522 gap: config.bar_gap as u32,
523 align: Align::Start,
524 justify: Justify::Start,
525 border: None,
526 border_sides: BorderSides::all(),
527 border_style: Style::new().fg(self.theme.border),
528 bg_color: None,
529 padding: Padding::default(),
530 margin: Margin::default(),
531 constraints: Constraints::default(),
532 title: None,
533 grow: 0,
534 group_name: None,
535 });
536
537 self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
538
539 for bar in &group.bars {
540 let label_width = UnicodeWidthStr::width(bar.label.as_str());
541 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
542 let normalized = (bar.value / denom).clamp(0.0, 1.0);
543 let bar_text = Self::horizontal_bar_text(normalized, max_width);
544
545 self.interaction_count += 1;
546 self.commands.push(Command::BeginContainer {
547 direction: Direction::Row,
548 gap: 1,
549 align: Align::Start,
550 justify: Justify::Start,
551 border: None,
552 border_sides: BorderSides::all(),
553 border_style: Style::new().fg(self.theme.border),
554 bg_color: None,
555 padding: Padding::default(),
556 margin: Margin::default(),
557 constraints: Constraints::default(),
558 title: None,
559 grow: 0,
560 group_name: None,
561 });
562 self.styled(
563 format!(" {}{label_padding}", bar.label),
564 Style::new().fg(self.theme.text),
565 );
566 self.styled(
567 bar_text,
568 Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
569 );
570 self.styled(
571 Self::bar_display_value(bar),
572 bar.value_style
573 .unwrap_or(Style::new().fg(self.theme.text_dim)),
574 );
575 self.commands.push(Command::EndContainer);
576 self.last_text_idx = None;
577 }
578
579 self.commands.push(Command::EndContainer);
580 self.last_text_idx = None;
581 }
582
583 self.commands.push(Command::EndContainer);
584 self.last_text_idx = None;
585 }
586
587 fn render_grouped_vertical_bars(
588 &mut self,
589 groups: &[BarGroup],
590 max_height: u32,
591 denom: f64,
592 config: &BarChartConfig,
593 ) {
594 self.interaction_count += 1;
595 self.commands.push(Command::BeginContainer {
596 direction: Direction::Column,
597 gap: config.group_gap as u32,
598 align: Align::Start,
599 justify: Justify::Start,
600 border: None,
601 border_sides: BorderSides::all(),
602 border_style: Style::new().fg(self.theme.border),
603 bg_color: None,
604 padding: Padding::default(),
605 margin: Margin::default(),
606 constraints: Constraints::default(),
607 title: None,
608 grow: 0,
609 group_name: None,
610 });
611
612 for group in groups {
613 self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
614 if !group.bars.is_empty() {
615 self.render_vertical_styled_bars(
616 &group.bars,
617 max_height,
618 denom,
619 config.bar_width,
620 config.bar_gap,
621 );
622 }
623 }
624
625 self.commands.push(Command::EndContainer);
626 self.last_text_idx = None;
627 }
628
629 fn horizontal_bar_text(normalized: f64, max_width: u32) -> String {
630 let filled = (normalized.clamp(0.0, 1.0) * max_width as f64).round() as usize;
631 "█".repeat(filled)
632 }
633
634 fn bar_display_value(bar: &Bar) -> String {
635 bar.text_value
636 .clone()
637 .unwrap_or_else(|| format_compact_number(bar.value))
638 }
639
640 fn center_and_truncate_text(text: &str, width: usize) -> String {
641 if width == 0 {
642 return String::new();
643 }
644
645 let mut out = String::new();
646 let mut used = 0usize;
647 for ch in text.chars() {
648 let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
649 if used + cw > width {
650 break;
651 }
652 out.push(ch);
653 used += cw;
654 }
655 center_text(&out, width)
656 }
657
658 pub fn sparkline(&mut self, data: &[f64], width: u32) -> Response {
674 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
675
676 let w = width as usize;
677 if data.is_empty() || w == 0 {
678 return Response::none();
679 }
680
681 let points: Vec<f64> = if data.len() >= w {
682 data[data.len() - w..].to_vec()
683 } else if data.len() == 1 {
684 vec![data[0]; w]
685 } else {
686 (0..w)
687 .map(|i| {
688 let t = i as f64 * (data.len() - 1) as f64 / (w - 1) as f64;
689 let idx = t.floor() as usize;
690 let frac = t - idx as f64;
691 if idx + 1 < data.len() {
692 data[idx] * (1.0 - frac) + data[idx + 1] * frac
693 } else {
694 data[idx.min(data.len() - 1)]
695 }
696 })
697 .collect()
698 };
699
700 let min = points.iter().copied().fold(f64::INFINITY, f64::min);
701 let max = points.iter().copied().fold(f64::NEG_INFINITY, f64::max);
702 let range = max - min;
703
704 let line: String = points
705 .iter()
706 .map(|&value| {
707 let normalized = if range == 0.0 {
708 0.5
709 } else {
710 (value - min) / range
711 };
712 let idx = (normalized * 7.0).round() as usize;
713 BLOCKS[idx.min(7)]
714 })
715 .collect();
716
717 self.styled(line, Style::new().fg(self.theme.primary));
718 Response::none()
719 }
720
721 pub fn sparkline_styled(&mut self, data: &[(f64, Option<Color>)], width: u32) -> Response {
741 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
742
743 let w = width as usize;
744 if data.is_empty() || w == 0 {
745 return Response::none();
746 }
747
748 let window: Vec<(f64, Option<Color>)> = if data.len() >= w {
749 data[data.len() - w..].to_vec()
750 } else if data.len() == 1 {
751 vec![data[0]; w]
752 } else {
753 (0..w)
754 .map(|i| {
755 let t = i as f64 * (data.len() - 1) as f64 / (w - 1) as f64;
756 let idx = t.floor() as usize;
757 let frac = t - idx as f64;
758 let nearest = if frac < 0.5 {
759 idx
760 } else {
761 (idx + 1).min(data.len() - 1)
762 };
763 let color = data[nearest].1;
764 let (v1, _) = data[idx];
765 let (v2, _) = data[(idx + 1).min(data.len() - 1)];
766 let value = if v1.is_nan() || v2.is_nan() {
767 if frac < 0.5 {
768 v1
769 } else {
770 v2
771 }
772 } else {
773 v1 * (1.0 - frac) + v2 * frac
774 };
775 (value, color)
776 })
777 .collect()
778 };
779
780 let mut finite_values = window
781 .iter()
782 .map(|(value, _)| *value)
783 .filter(|value| !value.is_nan());
784 let Some(first) = finite_values.next() else {
785 self.styled(
786 " ".repeat(window.len()),
787 Style::new().fg(self.theme.text_dim),
788 );
789 return Response::none();
790 };
791
792 let mut min = first;
793 let mut max = first;
794 for value in finite_values {
795 min = f64::min(min, value);
796 max = f64::max(max, value);
797 }
798 let range = max - min;
799
800 let mut cells: Vec<(char, Color)> = Vec::with_capacity(window.len());
801 for (value, color) in &window {
802 if value.is_nan() {
803 cells.push((' ', self.theme.text_dim));
804 continue;
805 }
806
807 let normalized = if range == 0.0 {
808 0.5
809 } else {
810 ((*value - min) / range).clamp(0.0, 1.0)
811 };
812 let idx = (normalized * 7.0).round() as usize;
813 cells.push((BLOCKS[idx.min(7)], color.unwrap_or(self.theme.primary)));
814 }
815
816 self.interaction_count += 1;
817 self.commands.push(Command::BeginContainer {
818 direction: Direction::Row,
819 gap: 0,
820 align: Align::Start,
821 justify: Justify::Start,
822 border: None,
823 border_sides: BorderSides::all(),
824 border_style: Style::new().fg(self.theme.border),
825 bg_color: None,
826 padding: Padding::default(),
827 margin: Margin::default(),
828 constraints: Constraints::default(),
829 title: None,
830 grow: 0,
831 group_name: None,
832 });
833
834 let mut seg = String::new();
835 let mut seg_color = cells[0].1;
836 for (ch, color) in cells {
837 if color != seg_color {
838 self.styled(seg, Style::new().fg(seg_color));
839 seg = String::new();
840 seg_color = color;
841 }
842 seg.push(ch);
843 }
844 if !seg.is_empty() {
845 self.styled(seg, Style::new().fg(seg_color));
846 }
847
848 self.commands.push(Command::EndContainer);
849 self.last_text_idx = None;
850
851 Response::none()
852 }
853
854 pub fn line_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
868 self.line_chart_colored(data, width, height, self.theme.primary)
869 }
870
871 pub fn line_chart_colored(
873 &mut self,
874 data: &[f64],
875 width: u32,
876 height: u32,
877 color: Color,
878 ) -> Response {
879 self.render_line_chart_internal(data, width, height, color, false)
880 }
881
882 pub fn area_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
884 self.area_chart_colored(data, width, height, self.theme.primary)
885 }
886
887 pub fn area_chart_colored(
889 &mut self,
890 data: &[f64],
891 width: u32,
892 height: u32,
893 color: Color,
894 ) -> Response {
895 self.render_line_chart_internal(data, width, height, color, true)
896 }
897
898 fn render_line_chart_internal(
899 &mut self,
900 data: &[f64],
901 width: u32,
902 height: u32,
903 color: Color,
904 fill: bool,
905 ) -> Response {
906 if data.is_empty() || width == 0 || height == 0 {
907 return Response::none();
908 }
909
910 let cols = width as usize;
911 let rows = height as usize;
912 let px_w = cols * 2;
913 let px_h = rows * 4;
914
915 let min = data.iter().copied().fold(f64::INFINITY, f64::min);
916 let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
917 let range = if (max - min).abs() < f64::EPSILON {
918 1.0
919 } else {
920 max - min
921 };
922
923 let points: Vec<usize> = (0..px_w)
924 .map(|px| {
925 let data_idx = if px_w <= 1 {
926 0.0
927 } else {
928 px as f64 * (data.len() - 1) as f64 / (px_w - 1) as f64
929 };
930 let idx = data_idx.floor() as usize;
931 let frac = data_idx - idx as f64;
932 let value = if idx + 1 < data.len() {
933 data[idx] * (1.0 - frac) + data[idx + 1] * frac
934 } else {
935 data[idx.min(data.len() - 1)]
936 };
937
938 let normalized = (value - min) / range;
939 let py = ((1.0 - normalized) * (px_h - 1) as f64).round() as usize;
940 py.min(px_h - 1)
941 })
942 .collect();
943
944 const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
945 const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
946
947 let mut grid = vec![vec![0u32; cols]; rows];
948
949 for i in 0..points.len() {
950 let px = i;
951 let py = points[i];
952 let char_col = px / 2;
953 let char_row = py / 4;
954 let sub_col = px % 2;
955 let sub_row = py % 4;
956
957 if char_col < cols && char_row < rows {
958 grid[char_row][char_col] |= if sub_col == 0 {
959 LEFT_BITS[sub_row]
960 } else {
961 RIGHT_BITS[sub_row]
962 };
963 }
964
965 if i + 1 < points.len() {
966 let py_next = points[i + 1];
967 let (y_start, y_end) = if py <= py_next {
968 (py, py_next)
969 } else {
970 (py_next, py)
971 };
972 for y in y_start..=y_end {
973 let cell_row = y / 4;
974 let sub_y = y % 4;
975 if char_col < cols && cell_row < rows {
976 grid[cell_row][char_col] |= if sub_col == 0 {
977 LEFT_BITS[sub_y]
978 } else {
979 RIGHT_BITS[sub_y]
980 };
981 }
982 }
983 }
984
985 if fill {
986 for y in py..px_h {
987 let cell_row = y / 4;
988 let sub_y = y % 4;
989 if char_col < cols && cell_row < rows {
990 grid[cell_row][char_col] |= if sub_col == 0 {
991 LEFT_BITS[sub_y]
992 } else {
993 RIGHT_BITS[sub_y]
994 };
995 }
996 }
997 }
998 }
999
1000 let style = Style::new().fg(color);
1001 for row in grid {
1002 let line: String = row
1003 .iter()
1004 .map(|&bits| char::from_u32(0x2800 + bits).unwrap_or(' '))
1005 .collect();
1006 self.styled(line, style);
1007 }
1008
1009 Response::none()
1010 }
1011
1012 pub fn candlestick(
1014 &mut self,
1015 candles: &[Candle],
1016 up_color: Color,
1017 down_color: Color,
1018 ) -> Response {
1019 if candles.is_empty() {
1020 return Response::none();
1021 }
1022
1023 let candles = candles.to_vec();
1024 self.container().grow(1).draw(move |buf, rect| {
1025 let w = rect.width as usize;
1026 let h = rect.height as usize;
1027 if w < 2 || h < 2 {
1028 return;
1029 }
1030
1031 let mut lo = f64::INFINITY;
1032 let mut hi = f64::NEG_INFINITY;
1033 for c in &candles {
1034 if c.low.is_finite() {
1035 lo = lo.min(c.low);
1036 }
1037 if c.high.is_finite() {
1038 hi = hi.max(c.high);
1039 }
1040 }
1041
1042 if !lo.is_finite() || !hi.is_finite() {
1043 return;
1044 }
1045
1046 let range = if (hi - lo).abs() < 0.01 { 1.0 } else { hi - lo };
1047 let map_y = |v: f64| -> usize {
1048 let t = ((v - lo) / range).clamp(0.0, 1.0);
1049 ((1.0 - t) * (h.saturating_sub(1)) as f64).round() as usize
1050 };
1051
1052 for (i, c) in candles.iter().enumerate() {
1053 if !c.open.is_finite()
1054 || !c.high.is_finite()
1055 || !c.low.is_finite()
1056 || !c.close.is_finite()
1057 {
1058 continue;
1059 }
1060
1061 let x0 = i * w / candles.len();
1062 let x1 = ((i + 1) * w / candles.len()).saturating_sub(1).max(x0);
1063 if x0 >= w {
1064 continue;
1065 }
1066 let xm = (x0 + x1) / 2;
1067 let color = if c.close >= c.open {
1068 up_color
1069 } else {
1070 down_color
1071 };
1072
1073 let wt = map_y(c.high);
1074 let wb = map_y(c.low);
1075 for row in wt..=wb.min(h - 1) {
1076 buf.set_char(
1077 rect.x + xm as u32,
1078 rect.y + row as u32,
1079 '│',
1080 Style::new().fg(color),
1081 );
1082 }
1083
1084 let bt = map_y(c.open.max(c.close));
1085 let bb = map_y(c.open.min(c.close));
1086 for row in bt..=bb.min(h - 1) {
1087 for col in x0..=x1.min(w - 1) {
1088 buf.set_char(
1089 rect.x + col as u32,
1090 rect.y + row as u32,
1091 '█',
1092 Style::new().fg(color),
1093 );
1094 }
1095 }
1096 }
1097 });
1098
1099 Response::none()
1100 }
1101
1102 pub fn heatmap(
1114 &mut self,
1115 data: &[Vec<f64>],
1116 width: u32,
1117 height: u32,
1118 low_color: Color,
1119 high_color: Color,
1120 ) -> Response {
1121 fn blend_color(a: Color, b: Color, t: f64) -> Color {
1122 let t = t.clamp(0.0, 1.0);
1123 match (a, b) {
1124 (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => Color::Rgb(
1125 (r1 as f64 * (1.0 - t) + r2 as f64 * t).round() as u8,
1126 (g1 as f64 * (1.0 - t) + g2 as f64 * t).round() as u8,
1127 (b1 as f64 * (1.0 - t) + b2 as f64 * t).round() as u8,
1128 ),
1129 _ => {
1130 if t > 0.5 {
1131 b
1132 } else {
1133 a
1134 }
1135 }
1136 }
1137 }
1138
1139 if data.is_empty() || width == 0 || height == 0 {
1140 return Response::none();
1141 }
1142
1143 let data_rows = data.len();
1144 let max_data_cols = data.iter().map(Vec::len).max().unwrap_or(0);
1145 if max_data_cols == 0 {
1146 return Response::none();
1147 }
1148
1149 let mut min_value = f64::INFINITY;
1150 let mut max_value = f64::NEG_INFINITY;
1151 for row in data {
1152 for value in row {
1153 if value.is_finite() {
1154 min_value = min_value.min(*value);
1155 max_value = max_value.max(*value);
1156 }
1157 }
1158 }
1159
1160 if !min_value.is_finite() || !max_value.is_finite() {
1161 return Response::none();
1162 }
1163
1164 let range = max_value - min_value;
1165 let zero_range = range.abs() < f64::EPSILON;
1166 let cols = width as usize;
1167 let rows = height as usize;
1168
1169 for row_idx in 0..rows {
1170 let data_row_idx = (row_idx * data_rows / rows).min(data_rows.saturating_sub(1));
1171 let source_row = &data[data_row_idx];
1172 let source_cols = source_row.len();
1173
1174 self.interaction_count += 1;
1175 self.commands.push(Command::BeginContainer {
1176 direction: Direction::Row,
1177 gap: 0,
1178 align: Align::Start,
1179 justify: Justify::Start,
1180 border: None,
1181 border_sides: BorderSides::all(),
1182 border_style: Style::new().fg(self.theme.border),
1183 bg_color: None,
1184 padding: Padding::default(),
1185 margin: Margin::default(),
1186 constraints: Constraints::default(),
1187 title: None,
1188 grow: 0,
1189 group_name: None,
1190 });
1191
1192 let mut segment = String::new();
1193 let mut segment_color: Option<Color> = None;
1194
1195 for col_idx in 0..cols {
1196 let normalized = if source_cols == 0 {
1197 0.0
1198 } else {
1199 let data_col_idx = (col_idx * source_cols / cols).min(source_cols - 1);
1200 let value = source_row[data_col_idx];
1201
1202 if !value.is_finite() {
1203 0.0
1204 } else if zero_range {
1205 0.5
1206 } else {
1207 ((value - min_value) / range).clamp(0.0, 1.0)
1208 }
1209 };
1210
1211 let color = blend_color(low_color, high_color, normalized);
1212
1213 match segment_color {
1214 Some(current) if current == color => {
1215 segment.push('█');
1216 }
1217 Some(current) => {
1218 self.styled(std::mem::take(&mut segment), Style::new().fg(current));
1219 segment.push('█');
1220 segment_color = Some(color);
1221 }
1222 None => {
1223 segment.push('█');
1224 segment_color = Some(color);
1225 }
1226 }
1227 }
1228
1229 if let Some(color) = segment_color {
1230 self.styled(segment, Style::new().fg(color));
1231 }
1232
1233 self.commands.push(Command::EndContainer);
1234 self.last_text_idx = None;
1235 }
1236
1237 Response::none()
1238 }
1239
1240 pub fn canvas(
1257 &mut self,
1258 width: u32,
1259 height: u32,
1260 draw: impl FnOnce(&mut CanvasContext),
1261 ) -> Response {
1262 if width == 0 || height == 0 {
1263 return Response::none();
1264 }
1265
1266 let mut canvas = CanvasContext::new(width as usize, height as usize);
1267 draw(&mut canvas);
1268
1269 for segments in canvas.render() {
1270 self.interaction_count += 1;
1271 self.commands.push(Command::BeginContainer {
1272 direction: Direction::Row,
1273 gap: 0,
1274 align: Align::Start,
1275 justify: Justify::Start,
1276 border: None,
1277 border_sides: BorderSides::all(),
1278 border_style: Style::new(),
1279 bg_color: None,
1280 padding: Padding::default(),
1281 margin: Margin::default(),
1282 constraints: Constraints::default(),
1283 title: None,
1284 grow: 0,
1285 group_name: None,
1286 });
1287 for (text, color) in segments {
1288 let c = if color == Color::Reset {
1289 self.theme.primary
1290 } else {
1291 color
1292 };
1293 self.styled(text, Style::new().fg(c));
1294 }
1295 self.commands.push(Command::EndContainer);
1296 self.last_text_idx = None;
1297 }
1298
1299 Response::none()
1300 }
1301
1302 pub fn chart(
1308 &mut self,
1309 configure: impl FnOnce(&mut ChartBuilder),
1310 width: u32,
1311 height: u32,
1312 ) -> Response {
1313 if width == 0 || height == 0 {
1314 return Response::none();
1315 }
1316
1317 let axis_style = Style::new().fg(self.theme.text_dim);
1318 let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
1319 configure(&mut builder);
1320
1321 let config = builder.build();
1322 let rows = render_chart(&config);
1323
1324 for row in rows {
1325 self.interaction_count += 1;
1326 self.commands.push(Command::BeginContainer {
1327 direction: Direction::Row,
1328 gap: 0,
1329 align: Align::Start,
1330 justify: Justify::Start,
1331 border: None,
1332 border_sides: BorderSides::all(),
1333 border_style: Style::new().fg(self.theme.border),
1334 bg_color: None,
1335 padding: Padding::default(),
1336 margin: Margin::default(),
1337 constraints: Constraints::default(),
1338 title: None,
1339 grow: 0,
1340 group_name: None,
1341 });
1342 for (text, style) in row.segments {
1343 self.styled(text, style);
1344 }
1345 self.commands.push(Command::EndContainer);
1346 self.last_text_idx = None;
1347 }
1348
1349 Response::none()
1350 }
1351
1352 pub fn scatter(&mut self, data: &[(f64, f64)], width: u32, height: u32) -> Response {
1356 self.chart(
1357 |c| {
1358 c.scatter(data);
1359 c.grid(true);
1360 },
1361 width,
1362 height,
1363 )
1364 }
1365
1366 pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> Response {
1368 self.histogram_with(data, |_| {}, width, height)
1369 }
1370
1371 pub fn histogram_with(
1373 &mut self,
1374 data: &[f64],
1375 configure: impl FnOnce(&mut HistogramBuilder),
1376 width: u32,
1377 height: u32,
1378 ) -> Response {
1379 if width == 0 || height == 0 {
1380 return Response::none();
1381 }
1382
1383 let mut options = HistogramBuilder::default();
1384 configure(&mut options);
1385 let axis_style = Style::new().fg(self.theme.text_dim);
1386 let config = build_histogram_config(data, &options, width, height, axis_style);
1387 let rows = render_chart(&config);
1388
1389 for row in rows {
1390 self.interaction_count += 1;
1391 self.commands.push(Command::BeginContainer {
1392 direction: Direction::Row,
1393 gap: 0,
1394 align: Align::Start,
1395 justify: Justify::Start,
1396 border: None,
1397 border_sides: BorderSides::all(),
1398 border_style: Style::new().fg(self.theme.border),
1399 bg_color: None,
1400 padding: Padding::default(),
1401 margin: Margin::default(),
1402 constraints: Constraints::default(),
1403 title: None,
1404 grow: 0,
1405 group_name: None,
1406 });
1407 for (text, style) in row.segments {
1408 self.styled(text, style);
1409 }
1410 self.commands.push(Command::EndContainer);
1411 self.last_text_idx = None;
1412 }
1413
1414 Response::none()
1415 }
1416}