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 let mut label_text = String::with_capacity(label.len() + label_padding.len());
92 label_text.push_str(label);
93 label_text.push_str(&label_padding);
94 self.styled(label_text, Style::new().fg(self.theme.text));
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 let mut label_text = String::with_capacity(bar.label.len() + label_padding.len());
267 label_text.push_str(&bar.label);
268 label_text.push_str(&label_padding);
269 self.styled(label_text, Style::new().fg(self.theme.text));
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 let mut label_text =
618 String::with_capacity(2 + bar.label.len() + label_padding.len());
619 label_text.push_str(" ");
620 label_text.push_str(&bar.label);
621 label_text.push_str(&label_padding);
622 self.styled(label_text, Style::new().fg(self.theme.text));
623 self.styled(
624 bar_text,
625 Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
626 );
627 self.styled(
628 Self::bar_display_value(bar),
629 bar.value_style
630 .unwrap_or(Style::new().fg(self.theme.text_dim)),
631 );
632 self.commands.push(Command::EndContainer);
633 self.last_text_idx = None;
634 }
635
636 self.commands.push(Command::EndContainer);
637 self.last_text_idx = None;
638 }
639
640 self.commands.push(Command::EndContainer);
641 self.last_text_idx = None;
642 }
643
644 fn render_grouped_vertical_bars(
645 &mut self,
646 groups: &[BarGroup],
647 max_height: u32,
648 denom: f64,
649 config: &BarChartConfig,
650 ) {
651 self.interaction_count += 1;
652 self.commands.push(Command::BeginContainer {
653 direction: Direction::Column,
654 gap: config.group_gap as u32,
655 align: Align::Start,
656 align_self: None,
657 justify: Justify::Start,
658 border: None,
659 border_sides: BorderSides::all(),
660 border_style: Style::new().fg(self.theme.border),
661 bg_color: None,
662 padding: Padding::default(),
663 margin: Margin::default(),
664 constraints: Constraints::default(),
665 title: None,
666 grow: 0,
667 group_name: None,
668 });
669
670 for group in groups {
671 self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
672 if !group.bars.is_empty() {
673 self.render_vertical_styled_bars(
674 &group.bars,
675 max_height,
676 denom,
677 config.bar_width,
678 config.bar_gap,
679 );
680 }
681 }
682
683 self.commands.push(Command::EndContainer);
684 self.last_text_idx = None;
685 }
686
687 fn horizontal_bar_text(normalized: f64, max_width: u32) -> String {
688 let filled = (normalized.clamp(0.0, 1.0) * max_width as f64).round() as usize;
689 "█".repeat(filled)
690 }
691
692 fn bar_display_value(bar: &Bar) -> String {
693 bar.text_value
694 .clone()
695 .unwrap_or_else(|| format_compact_number(bar.value))
696 }
697
698 fn center_and_truncate_text(text: &str, width: usize) -> String {
699 if width == 0 {
700 return String::new();
701 }
702
703 let mut out = String::new();
704 let mut used = 0usize;
705 for ch in text.chars() {
706 let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
707 if used + cw > width {
708 break;
709 }
710 out.push(ch);
711 used += cw;
712 }
713 center_text(&out, width)
714 }
715
716 pub fn sparkline(&mut self, data: &[f64], width: u32) -> Response {
732 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
733
734 let w = width as usize;
735 if data.is_empty() || w == 0 {
736 return Response::none();
737 }
738
739 let points: Vec<f64> = if data.len() >= w {
740 data[data.len() - w..].to_vec()
741 } else if data.len() == 1 {
742 vec![data[0]; w]
743 } else {
744 (0..w)
745 .map(|i| {
746 let t = i as f64 * (data.len() - 1) as f64 / (w - 1) as f64;
747 let idx = t.floor() as usize;
748 let frac = t - idx as f64;
749 if idx + 1 < data.len() {
750 data[idx] * (1.0 - frac) + data[idx + 1] * frac
751 } else {
752 data[idx.min(data.len() - 1)]
753 }
754 })
755 .collect()
756 };
757
758 let min = points.iter().copied().fold(f64::INFINITY, f64::min);
759 let max = points.iter().copied().fold(f64::NEG_INFINITY, f64::max);
760 let range = max - min;
761
762 let line: String = points
763 .iter()
764 .map(|&value| {
765 let normalized = if range == 0.0 {
766 0.5
767 } else {
768 (value - min) / range
769 };
770 let idx = (normalized * 7.0).round() as usize;
771 BLOCKS[idx.min(7)]
772 })
773 .collect();
774
775 self.styled(line, Style::new().fg(self.theme.primary));
776 Response::none()
777 }
778
779 pub fn sparkline_styled(&mut self, data: &[(f64, Option<Color>)], width: u32) -> Response {
799 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
800
801 let w = width as usize;
802 if data.is_empty() || w == 0 {
803 return Response::none();
804 }
805
806 let window: Vec<(f64, Option<Color>)> = if data.len() >= w {
807 data[data.len() - w..].to_vec()
808 } else if data.len() == 1 {
809 vec![data[0]; w]
810 } else {
811 (0..w)
812 .map(|i| {
813 let t = i as f64 * (data.len() - 1) as f64 / (w - 1) as f64;
814 let idx = t.floor() as usize;
815 let frac = t - idx as f64;
816 let nearest = if frac < 0.5 {
817 idx
818 } else {
819 (idx + 1).min(data.len() - 1)
820 };
821 let color = data[nearest].1;
822 let (v1, _) = data[idx];
823 let (v2, _) = data[(idx + 1).min(data.len() - 1)];
824 let value = if v1.is_nan() || v2.is_nan() {
825 if frac < 0.5 {
826 v1
827 } else {
828 v2
829 }
830 } else {
831 v1 * (1.0 - frac) + v2 * frac
832 };
833 (value, color)
834 })
835 .collect()
836 };
837
838 let mut finite_values = window
839 .iter()
840 .map(|(value, _)| *value)
841 .filter(|value| !value.is_nan());
842 let Some(first) = finite_values.next() else {
843 self.styled(
844 " ".repeat(window.len()),
845 Style::new().fg(self.theme.text_dim),
846 );
847 return Response::none();
848 };
849
850 let mut min = first;
851 let mut max = first;
852 for value in finite_values {
853 min = f64::min(min, value);
854 max = f64::max(max, value);
855 }
856 let range = max - min;
857
858 let mut cells: Vec<(char, Color)> = Vec::with_capacity(window.len());
859 for (value, color) in &window {
860 if value.is_nan() {
861 cells.push((' ', self.theme.text_dim));
862 continue;
863 }
864
865 let normalized = if range == 0.0 {
866 0.5
867 } else {
868 ((*value - min) / range).clamp(0.0, 1.0)
869 };
870 let idx = (normalized * 7.0).round() as usize;
871 cells.push((BLOCKS[idx.min(7)], color.unwrap_or(self.theme.primary)));
872 }
873
874 self.interaction_count += 1;
875 self.commands.push(Command::BeginContainer {
876 direction: Direction::Row,
877 gap: 0,
878 align: Align::Start,
879 align_self: None,
880 justify: Justify::Start,
881 border: None,
882 border_sides: BorderSides::all(),
883 border_style: Style::new().fg(self.theme.border),
884 bg_color: None,
885 padding: Padding::default(),
886 margin: Margin::default(),
887 constraints: Constraints::default(),
888 title: None,
889 grow: 0,
890 group_name: None,
891 });
892
893 if cells.is_empty() {
894 self.commands.push(Command::EndContainer);
895 self.last_text_idx = None;
896 return Response::none();
897 }
898
899 let mut seg = String::new();
900 let mut seg_color = cells[0].1;
901 for (ch, color) in cells {
902 if color != seg_color {
903 self.styled(seg, Style::new().fg(seg_color));
904 seg = String::new();
905 seg_color = color;
906 }
907 seg.push(ch);
908 }
909 if !seg.is_empty() {
910 self.styled(seg, Style::new().fg(seg_color));
911 }
912
913 self.commands.push(Command::EndContainer);
914 self.last_text_idx = None;
915
916 Response::none()
917 }
918
919 pub fn line_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
933 self.line_chart_colored(data, width, height, self.theme.primary)
934 }
935
936 pub fn line_chart_colored(
938 &mut self,
939 data: &[f64],
940 width: u32,
941 height: u32,
942 color: Color,
943 ) -> Response {
944 self.render_line_chart_internal(data, width, height, color, false)
945 }
946
947 pub fn area_chart(&mut self, data: &[f64], width: u32, height: u32) -> Response {
949 self.area_chart_colored(data, width, height, self.theme.primary)
950 }
951
952 pub fn area_chart_colored(
954 &mut self,
955 data: &[f64],
956 width: u32,
957 height: u32,
958 color: Color,
959 ) -> Response {
960 self.render_line_chart_internal(data, width, height, color, true)
961 }
962
963 fn render_line_chart_internal(
964 &mut self,
965 data: &[f64],
966 width: u32,
967 height: u32,
968 color: Color,
969 fill: bool,
970 ) -> Response {
971 if data.is_empty() || width == 0 || height == 0 {
972 return Response::none();
973 }
974
975 let cols = width as usize;
976 let rows = height as usize;
977 let px_w = cols * 2;
978 let px_h = rows * 4;
979
980 let min = data.iter().copied().fold(f64::INFINITY, f64::min);
981 let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
982 let range = if (max - min).abs() < f64::EPSILON {
983 1.0
984 } else {
985 max - min
986 };
987
988 let points: Vec<usize> = (0..px_w)
989 .map(|px| {
990 let data_idx = if px_w <= 1 {
991 0.0
992 } else {
993 px as f64 * (data.len() - 1) as f64 / (px_w - 1) as f64
994 };
995 let idx = data_idx.floor() as usize;
996 let frac = data_idx - idx as f64;
997 let value = if idx + 1 < data.len() {
998 data[idx] * (1.0 - frac) + data[idx + 1] * frac
999 } else {
1000 data[idx.min(data.len() - 1)]
1001 };
1002
1003 let normalized = (value - min) / range;
1004 let py = ((1.0 - normalized) * (px_h - 1) as f64).round() as usize;
1005 py.min(px_h - 1)
1006 })
1007 .collect();
1008
1009 const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
1010 const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
1011
1012 let mut grid = vec![vec![0u32; cols]; rows];
1013
1014 for i in 0..points.len() {
1015 let px = i;
1016 let py = points[i];
1017 let char_col = px / 2;
1018 let char_row = py / 4;
1019 let sub_col = px % 2;
1020 let sub_row = py % 4;
1021
1022 if char_col < cols && char_row < rows {
1023 grid[char_row][char_col] |= if sub_col == 0 {
1024 LEFT_BITS[sub_row]
1025 } else {
1026 RIGHT_BITS[sub_row]
1027 };
1028 }
1029
1030 if i + 1 < points.len() {
1031 let py_next = points[i + 1];
1032 let (y_start, y_end) = if py <= py_next {
1033 (py, py_next)
1034 } else {
1035 (py_next, py)
1036 };
1037 for y in y_start..=y_end {
1038 let cell_row = y / 4;
1039 let sub_y = y % 4;
1040 if char_col < cols && cell_row < rows {
1041 grid[cell_row][char_col] |= if sub_col == 0 {
1042 LEFT_BITS[sub_y]
1043 } else {
1044 RIGHT_BITS[sub_y]
1045 };
1046 }
1047 }
1048 }
1049
1050 if fill {
1051 for y in py..px_h {
1052 let cell_row = y / 4;
1053 let sub_y = y % 4;
1054 if char_col < cols && cell_row < rows {
1055 grid[cell_row][char_col] |= if sub_col == 0 {
1056 LEFT_BITS[sub_y]
1057 } else {
1058 RIGHT_BITS[sub_y]
1059 };
1060 }
1061 }
1062 }
1063 }
1064
1065 let style = Style::new().fg(color);
1066 for row in grid {
1067 let line: String = row
1068 .iter()
1069 .map(|&bits| char::from_u32(0x2800 + bits).unwrap_or(' '))
1070 .collect();
1071 self.styled(line, style);
1072 }
1073
1074 Response::none()
1075 }
1076
1077 pub fn candlestick(
1079 &mut self,
1080 candles: &[Candle],
1081 up_color: Color,
1082 down_color: Color,
1083 ) -> Response {
1084 if candles.is_empty() {
1085 return Response::none();
1086 }
1087
1088 let candles = candles.to_vec();
1089 self.container().grow(1).draw(move |buf, rect| {
1090 let w = rect.width as usize;
1091 let h = rect.height as usize;
1092 if w < 2 || h < 2 {
1093 return;
1094 }
1095
1096 let mut lo = f64::INFINITY;
1097 let mut hi = f64::NEG_INFINITY;
1098 for c in &candles {
1099 if c.low.is_finite() {
1100 lo = lo.min(c.low);
1101 }
1102 if c.high.is_finite() {
1103 hi = hi.max(c.high);
1104 }
1105 }
1106
1107 if !lo.is_finite() || !hi.is_finite() {
1108 return;
1109 }
1110
1111 let range = if (hi - lo).abs() < 0.01 { 1.0 } else { hi - lo };
1112 let map_y = |v: f64| -> usize {
1113 let t = ((v - lo) / range).clamp(0.0, 1.0);
1114 ((1.0 - t) * (h.saturating_sub(1)) as f64).round() as usize
1115 };
1116
1117 for (i, c) in candles.iter().enumerate() {
1118 if !c.open.is_finite()
1119 || !c.high.is_finite()
1120 || !c.low.is_finite()
1121 || !c.close.is_finite()
1122 {
1123 continue;
1124 }
1125
1126 let x0 = i * w / candles.len();
1127 let x1 = ((i + 1) * w / candles.len()).saturating_sub(1).max(x0);
1128 if x0 >= w {
1129 continue;
1130 }
1131 let xm = (x0 + x1) / 2;
1132 let color = if c.close >= c.open {
1133 up_color
1134 } else {
1135 down_color
1136 };
1137
1138 let wt = map_y(c.high);
1139 let wb = map_y(c.low);
1140 for row in wt..=wb.min(h - 1) {
1141 buf.set_char(
1142 rect.x + xm as u32,
1143 rect.y + row as u32,
1144 '│',
1145 Style::new().fg(color),
1146 );
1147 }
1148
1149 let bt = map_y(c.open.max(c.close));
1150 let bb = map_y(c.open.min(c.close));
1151 for row in bt..=bb.min(h - 1) {
1152 for col in x0..=x1.min(w - 1) {
1153 buf.set_char(
1154 rect.x + col as u32,
1155 rect.y + row as u32,
1156 '█',
1157 Style::new().fg(color),
1158 );
1159 }
1160 }
1161 }
1162 });
1163
1164 Response::none()
1165 }
1166
1167 pub fn heatmap(
1179 &mut self,
1180 data: &[Vec<f64>],
1181 width: u32,
1182 height: u32,
1183 low_color: Color,
1184 high_color: Color,
1185 ) -> Response {
1186 fn blend_color(a: Color, b: Color, t: f64) -> Color {
1187 let t = t.clamp(0.0, 1.0);
1188 match (a, b) {
1189 (Color::Rgb(r1, g1, b1), Color::Rgb(r2, g2, b2)) => Color::Rgb(
1190 (r1 as f64 * (1.0 - t) + r2 as f64 * t).round() as u8,
1191 (g1 as f64 * (1.0 - t) + g2 as f64 * t).round() as u8,
1192 (b1 as f64 * (1.0 - t) + b2 as f64 * t).round() as u8,
1193 ),
1194 _ => {
1195 if t > 0.5 {
1196 b
1197 } else {
1198 a
1199 }
1200 }
1201 }
1202 }
1203
1204 if data.is_empty() || width == 0 || height == 0 {
1205 return Response::none();
1206 }
1207
1208 let data_rows = data.len();
1209 let max_data_cols = data.iter().map(Vec::len).max().unwrap_or(0);
1210 if max_data_cols == 0 {
1211 return Response::none();
1212 }
1213
1214 let mut min_value = f64::INFINITY;
1215 let mut max_value = f64::NEG_INFINITY;
1216 for row in data {
1217 for value in row {
1218 if value.is_finite() {
1219 min_value = min_value.min(*value);
1220 max_value = max_value.max(*value);
1221 }
1222 }
1223 }
1224
1225 if !min_value.is_finite() || !max_value.is_finite() {
1226 return Response::none();
1227 }
1228
1229 let range = max_value - min_value;
1230 let zero_range = range.abs() < f64::EPSILON;
1231 let cols = width as usize;
1232 let rows = height as usize;
1233
1234 for row_idx in 0..rows {
1235 let data_row_idx = (row_idx * data_rows / rows).min(data_rows.saturating_sub(1));
1236 let source_row = &data[data_row_idx];
1237 let source_cols = source_row.len();
1238
1239 self.interaction_count += 1;
1240 self.commands.push(Command::BeginContainer {
1241 direction: Direction::Row,
1242 gap: 0,
1243 align: Align::Start,
1244 align_self: None,
1245 justify: Justify::Start,
1246 border: None,
1247 border_sides: BorderSides::all(),
1248 border_style: Style::new().fg(self.theme.border),
1249 bg_color: None,
1250 padding: Padding::default(),
1251 margin: Margin::default(),
1252 constraints: Constraints::default(),
1253 title: None,
1254 grow: 0,
1255 group_name: None,
1256 });
1257
1258 let mut segment = String::new();
1259 let mut segment_color: Option<Color> = None;
1260
1261 for col_idx in 0..cols {
1262 let normalized = if source_cols == 0 {
1263 0.0
1264 } else {
1265 let data_col_idx = (col_idx * source_cols / cols).min(source_cols - 1);
1266 let value = source_row[data_col_idx];
1267
1268 if !value.is_finite() {
1269 0.0
1270 } else if zero_range {
1271 0.5
1272 } else {
1273 ((value - min_value) / range).clamp(0.0, 1.0)
1274 }
1275 };
1276
1277 let color = blend_color(low_color, high_color, normalized);
1278
1279 match segment_color {
1280 Some(current) if current == color => {
1281 segment.push('█');
1282 }
1283 Some(current) => {
1284 self.styled(std::mem::take(&mut segment), Style::new().fg(current));
1285 segment.push('█');
1286 segment_color = Some(color);
1287 }
1288 None => {
1289 segment.push('█');
1290 segment_color = Some(color);
1291 }
1292 }
1293 }
1294
1295 if let Some(color) = segment_color {
1296 self.styled(segment, Style::new().fg(color));
1297 }
1298
1299 self.commands.push(Command::EndContainer);
1300 self.last_text_idx = None;
1301 }
1302
1303 Response::none()
1304 }
1305
1306 pub fn canvas(
1323 &mut self,
1324 width: u32,
1325 height: u32,
1326 draw: impl FnOnce(&mut CanvasContext),
1327 ) -> Response {
1328 if width == 0 || height == 0 {
1329 return Response::none();
1330 }
1331
1332 let mut canvas = CanvasContext::new(width as usize, height as usize);
1333 draw(&mut canvas);
1334
1335 for segments in canvas.render() {
1336 self.interaction_count += 1;
1337 self.commands.push(Command::BeginContainer {
1338 direction: Direction::Row,
1339 gap: 0,
1340 align: Align::Start,
1341 align_self: None,
1342 justify: Justify::Start,
1343 border: None,
1344 border_sides: BorderSides::all(),
1345 border_style: Style::new(),
1346 bg_color: None,
1347 padding: Padding::default(),
1348 margin: Margin::default(),
1349 constraints: Constraints::default(),
1350 title: None,
1351 grow: 0,
1352 group_name: None,
1353 });
1354 for (text, color) in segments {
1355 let c = if color == Color::Reset {
1356 self.theme.primary
1357 } else {
1358 color
1359 };
1360 self.styled(text, Style::new().fg(c));
1361 }
1362 self.commands.push(Command::EndContainer);
1363 self.last_text_idx = None;
1364 }
1365
1366 Response::none()
1367 }
1368
1369 pub fn chart(
1375 &mut self,
1376 configure: impl FnOnce(&mut ChartBuilder),
1377 width: u32,
1378 height: u32,
1379 ) -> Response {
1380 if width == 0 || height == 0 {
1381 return Response::none();
1382 }
1383
1384 let axis_style = Style::new().fg(self.theme.text_dim);
1385 let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
1386 configure(&mut builder);
1387
1388 let config = builder.build();
1389 let rows = render_chart(&config);
1390
1391 for row in rows {
1392 self.interaction_count += 1;
1393 self.commands.push(Command::BeginContainer {
1394 direction: Direction::Row,
1395 gap: 0,
1396 align: Align::Start,
1397 align_self: None,
1398 justify: Justify::Start,
1399 border: None,
1400 border_sides: BorderSides::all(),
1401 border_style: Style::new().fg(self.theme.border),
1402 bg_color: None,
1403 padding: Padding::default(),
1404 margin: Margin::default(),
1405 constraints: Constraints::default(),
1406 title: None,
1407 grow: 0,
1408 group_name: None,
1409 });
1410 for (text, style) in row.segments {
1411 self.styled(text, style);
1412 }
1413 self.commands.push(Command::EndContainer);
1414 self.last_text_idx = None;
1415 }
1416
1417 Response::none()
1418 }
1419
1420 pub fn scatter(&mut self, data: &[(f64, f64)], width: u32, height: u32) -> Response {
1424 self.chart(
1425 |c| {
1426 c.scatter(data);
1427 c.grid(true);
1428 },
1429 width,
1430 height,
1431 )
1432 }
1433
1434 pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> Response {
1436 self.histogram_with(data, |_| {}, width, height)
1437 }
1438
1439 pub fn histogram_with(
1441 &mut self,
1442 data: &[f64],
1443 configure: impl FnOnce(&mut HistogramBuilder),
1444 width: u32,
1445 height: u32,
1446 ) -> Response {
1447 if width == 0 || height == 0 {
1448 return Response::none();
1449 }
1450
1451 let mut options = HistogramBuilder::default();
1452 configure(&mut options);
1453 let axis_style = Style::new().fg(self.theme.text_dim);
1454 let config = build_histogram_config(data, &options, width, height, axis_style);
1455 let rows = render_chart(&config);
1456
1457 for row in rows {
1458 self.interaction_count += 1;
1459 self.commands.push(Command::BeginContainer {
1460 direction: Direction::Row,
1461 gap: 0,
1462 align: Align::Start,
1463 align_self: None,
1464 justify: Justify::Start,
1465 border: None,
1466 border_sides: BorderSides::all(),
1467 border_style: Style::new().fg(self.theme.border),
1468 bg_color: None,
1469 padding: Padding::default(),
1470 margin: Margin::default(),
1471 constraints: Constraints::default(),
1472 title: None,
1473 grow: 0,
1474 group_name: None,
1475 });
1476 for (text, style) in row.segments {
1477 self.styled(text, style);
1478 }
1479 self.commands.push(Command::EndContainer);
1480 self.last_text_idx = None;
1481 }
1482
1483 Response::none()
1484 }
1485}