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 width: u32,
1017 height: u32,
1018 up_color: Color,
1019 down_color: Color,
1020 ) -> Response {
1021 if candles.is_empty() || width == 0 || height == 0 {
1022 return Response::none();
1023 }
1024
1025 let cols = width as usize;
1026 let rows = height as usize;
1027
1028 let mut min_price = f64::INFINITY;
1029 let mut max_price = f64::NEG_INFINITY;
1030 for candle in candles {
1031 if candle.low.is_finite() {
1032 min_price = min_price.min(candle.low);
1033 }
1034 if candle.high.is_finite() {
1035 max_price = max_price.max(candle.high);
1036 }
1037 }
1038
1039 if !min_price.is_finite() || !max_price.is_finite() {
1040 return Response::none();
1041 }
1042
1043 let range = if (max_price - min_price).abs() < f64::EPSILON {
1044 1.0
1045 } else {
1046 max_price - min_price
1047 };
1048 let map_row = |value: f64| -> usize {
1049 let t = ((value - min_price) / range).clamp(0.0, 1.0);
1050 ((1.0 - t) * (rows.saturating_sub(1)) as f64).round() as usize
1051 };
1052
1053 let mut chars = vec![vec![' '; cols]; rows];
1054 let mut colors = vec![vec![None::<Color>; cols]; rows];
1055
1056 for (index, candle) in candles.iter().enumerate() {
1057 if !candle.open.is_finite()
1058 || !candle.high.is_finite()
1059 || !candle.low.is_finite()
1060 || !candle.close.is_finite()
1061 {
1062 continue;
1063 }
1064
1065 let x_start = index * cols / candles.len();
1066 let mut x_end = ((index + 1) * cols / candles.len()).saturating_sub(1);
1067 if x_end < x_start {
1068 x_end = x_start;
1069 }
1070 if x_start >= cols {
1071 continue;
1072 }
1073 x_end = x_end.min(cols.saturating_sub(1));
1074 let wick_x = (x_start + x_end) / 2;
1075
1076 let high_row = map_row(candle.high);
1077 let low_row = map_row(candle.low);
1078 let open_row = map_row(candle.open);
1079 let close_row = map_row(candle.close);
1080
1081 let (wick_top, wick_bottom) = if high_row <= low_row {
1082 (high_row, low_row)
1083 } else {
1084 (low_row, high_row)
1085 };
1086 let color = if candle.close >= candle.open {
1087 up_color
1088 } else {
1089 down_color
1090 };
1091
1092 for row in wick_top..=wick_bottom.min(rows.saturating_sub(1)) {
1093 chars[row][wick_x] = '│';
1094 colors[row][wick_x] = Some(color);
1095 }
1096
1097 let (body_top, body_bottom) = if open_row <= close_row {
1098 (open_row, close_row)
1099 } else {
1100 (close_row, open_row)
1101 };
1102 for row in body_top..=body_bottom.min(rows.saturating_sub(1)) {
1103 for col in x_start..=x_end {
1104 chars[row][col] = '█';
1105 colors[row][col] = Some(color);
1106 }
1107 }
1108 }
1109
1110 for row in 0..rows {
1111 self.interaction_count += 1;
1112 self.commands.push(Command::BeginContainer {
1113 direction: Direction::Row,
1114 gap: 0,
1115 align: Align::Start,
1116 justify: Justify::Start,
1117 border: None,
1118 border_sides: BorderSides::all(),
1119 border_style: Style::new().fg(self.theme.border),
1120 bg_color: None,
1121 padding: Padding::default(),
1122 margin: Margin::default(),
1123 constraints: Constraints::default(),
1124 title: None,
1125 grow: 0,
1126 group_name: None,
1127 });
1128
1129 let mut seg = String::new();
1130 let mut seg_color = colors[row][0];
1131 for col in 0..cols {
1132 if colors[row][col] != seg_color {
1133 let style = if let Some(c) = seg_color {
1134 Style::new().fg(c)
1135 } else {
1136 Style::new()
1137 };
1138 self.styled(seg, style);
1139 seg = String::new();
1140 seg_color = colors[row][col];
1141 }
1142 seg.push(chars[row][col]);
1143 }
1144 if !seg.is_empty() {
1145 let style = if let Some(c) = seg_color {
1146 Style::new().fg(c)
1147 } else {
1148 Style::new()
1149 };
1150 self.styled(seg, style);
1151 }
1152
1153 self.commands.push(Command::EndContainer);
1154 self.last_text_idx = None;
1155 }
1156
1157 Response::none()
1158 }
1159
1160 pub fn heatmap(
1172 &mut self,
1173 data: &[Vec<f64>],
1174 width: u32,
1175 height: u32,
1176 low_color: Color,
1177 high_color: Color,
1178 ) -> Response {
1179 fn blend_color(a: Color, b: Color, t: f64) -> Color {
1180 let t = t.clamp(0.0, 1.0);
1181 match (a, b) {
1182 (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => Color::Rgb(
1183 (r1 as f64 * (1.0 - t) + r2 as f64 * t).round() as u8,
1184 (g1 as f64 * (1.0 - t) + g2 as f64 * t).round() as u8,
1185 (b1 as f64 * (1.0 - t) + b2 as f64 * t).round() as u8,
1186 ),
1187 _ => {
1188 if t > 0.5 {
1189 b
1190 } else {
1191 a
1192 }
1193 }
1194 }
1195 }
1196
1197 if data.is_empty() || width == 0 || height == 0 {
1198 return Response::none();
1199 }
1200
1201 let data_rows = data.len();
1202 let max_data_cols = data.iter().map(Vec::len).max().unwrap_or(0);
1203 if max_data_cols == 0 {
1204 return Response::none();
1205 }
1206
1207 let mut min_value = f64::INFINITY;
1208 let mut max_value = f64::NEG_INFINITY;
1209 for row in data {
1210 for value in row {
1211 if value.is_finite() {
1212 min_value = min_value.min(*value);
1213 max_value = max_value.max(*value);
1214 }
1215 }
1216 }
1217
1218 if !min_value.is_finite() || !max_value.is_finite() {
1219 return Response::none();
1220 }
1221
1222 let range = max_value - min_value;
1223 let zero_range = range.abs() < f64::EPSILON;
1224 let cols = width as usize;
1225 let rows = height as usize;
1226
1227 for row_idx in 0..rows {
1228 let data_row_idx = (row_idx * data_rows / rows).min(data_rows.saturating_sub(1));
1229 let source_row = &data[data_row_idx];
1230 let source_cols = source_row.len();
1231
1232 self.interaction_count += 1;
1233 self.commands.push(Command::BeginContainer {
1234 direction: Direction::Row,
1235 gap: 0,
1236 align: Align::Start,
1237 justify: Justify::Start,
1238 border: None,
1239 border_sides: BorderSides::all(),
1240 border_style: Style::new().fg(self.theme.border),
1241 bg_color: None,
1242 padding: Padding::default(),
1243 margin: Margin::default(),
1244 constraints: Constraints::default(),
1245 title: None,
1246 grow: 0,
1247 group_name: None,
1248 });
1249
1250 let mut segment = String::new();
1251 let mut segment_color: Option<Color> = None;
1252
1253 for col_idx in 0..cols {
1254 let normalized = if source_cols == 0 {
1255 0.0
1256 } else {
1257 let data_col_idx = (col_idx * source_cols / cols).min(source_cols - 1);
1258 let value = source_row[data_col_idx];
1259
1260 if !value.is_finite() {
1261 0.0
1262 } else if zero_range {
1263 0.5
1264 } else {
1265 ((value - min_value) / range).clamp(0.0, 1.0)
1266 }
1267 };
1268
1269 let color = blend_color(low_color, high_color, normalized);
1270
1271 match segment_color {
1272 Some(current) if current == color => {
1273 segment.push('█');
1274 }
1275 Some(current) => {
1276 self.styled(std::mem::take(&mut segment), Style::new().fg(current));
1277 segment.push('█');
1278 segment_color = Some(color);
1279 }
1280 None => {
1281 segment.push('█');
1282 segment_color = Some(color);
1283 }
1284 }
1285 }
1286
1287 if let Some(color) = segment_color {
1288 self.styled(segment, Style::new().fg(color));
1289 }
1290
1291 self.commands.push(Command::EndContainer);
1292 self.last_text_idx = None;
1293 }
1294
1295 Response::none()
1296 }
1297
1298 pub fn canvas(
1315 &mut self,
1316 width: u32,
1317 height: u32,
1318 draw: impl FnOnce(&mut CanvasContext),
1319 ) -> Response {
1320 if width == 0 || height == 0 {
1321 return Response::none();
1322 }
1323
1324 let mut canvas = CanvasContext::new(width as usize, height as usize);
1325 draw(&mut canvas);
1326
1327 for segments in canvas.render() {
1328 self.interaction_count += 1;
1329 self.commands.push(Command::BeginContainer {
1330 direction: Direction::Row,
1331 gap: 0,
1332 align: Align::Start,
1333 justify: Justify::Start,
1334 border: None,
1335 border_sides: BorderSides::all(),
1336 border_style: Style::new(),
1337 bg_color: None,
1338 padding: Padding::default(),
1339 margin: Margin::default(),
1340 constraints: Constraints::default(),
1341 title: None,
1342 grow: 0,
1343 group_name: None,
1344 });
1345 for (text, color) in segments {
1346 let c = if color == Color::Reset {
1347 self.theme.primary
1348 } else {
1349 color
1350 };
1351 self.styled(text, Style::new().fg(c));
1352 }
1353 self.commands.push(Command::EndContainer);
1354 self.last_text_idx = None;
1355 }
1356
1357 Response::none()
1358 }
1359
1360 pub fn chart(
1366 &mut self,
1367 configure: impl FnOnce(&mut ChartBuilder),
1368 width: u32,
1369 height: u32,
1370 ) -> Response {
1371 if width == 0 || height == 0 {
1372 return Response::none();
1373 }
1374
1375 let axis_style = Style::new().fg(self.theme.text_dim);
1376 let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
1377 configure(&mut builder);
1378
1379 let config = builder.build();
1380 let rows = render_chart(&config);
1381
1382 for row in rows {
1383 self.interaction_count += 1;
1384 self.commands.push(Command::BeginContainer {
1385 direction: Direction::Row,
1386 gap: 0,
1387 align: Align::Start,
1388 justify: Justify::Start,
1389 border: None,
1390 border_sides: BorderSides::all(),
1391 border_style: Style::new().fg(self.theme.border),
1392 bg_color: None,
1393 padding: Padding::default(),
1394 margin: Margin::default(),
1395 constraints: Constraints::default(),
1396 title: None,
1397 grow: 0,
1398 group_name: None,
1399 });
1400 for (text, style) in row.segments {
1401 self.styled(text, style);
1402 }
1403 self.commands.push(Command::EndContainer);
1404 self.last_text_idx = None;
1405 }
1406
1407 Response::none()
1408 }
1409
1410 pub fn scatter(&mut self, data: &[(f64, f64)], width: u32, height: u32) -> Response {
1414 self.chart(
1415 |c| {
1416 c.scatter(data);
1417 c.grid(true);
1418 },
1419 width,
1420 height,
1421 )
1422 }
1423
1424 pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> Response {
1426 self.histogram_with(data, |_| {}, width, height)
1427 }
1428
1429 pub fn histogram_with(
1431 &mut self,
1432 data: &[f64],
1433 configure: impl FnOnce(&mut HistogramBuilder),
1434 width: u32,
1435 height: u32,
1436 ) -> Response {
1437 if width == 0 || height == 0 {
1438 return Response::none();
1439 }
1440
1441 let mut options = HistogramBuilder::default();
1442 configure(&mut options);
1443 let axis_style = Style::new().fg(self.theme.text_dim);
1444 let config = build_histogram_config(data, &options, width, height, axis_style);
1445 let rows = render_chart(&config);
1446
1447 for row in rows {
1448 self.interaction_count += 1;
1449 self.commands.push(Command::BeginContainer {
1450 direction: Direction::Row,
1451 gap: 0,
1452 align: Align::Start,
1453 justify: Justify::Start,
1454 border: None,
1455 border_sides: BorderSides::all(),
1456 border_style: Style::new().fg(self.theme.border),
1457 bg_color: None,
1458 padding: Padding::default(),
1459 margin: Margin::default(),
1460 constraints: Constraints::default(),
1461 title: None,
1462 grow: 0,
1463 group_name: None,
1464 });
1465 for (text, style) in row.segments {
1466 self.styled(text, style);
1467 }
1468 self.commands.push(Command::EndContainer);
1469 self.last_text_idx = None;
1470 }
1471
1472 Response::none()
1473 }
1474}