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