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