1use super::*;
2
3impl Context {
4 pub fn bar_chart(&mut self, data: &[(&str, f64)], max_width: u32) -> &mut Self {
25 if data.is_empty() {
26 return self;
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_len = (normalized * max_width as f64).round() as usize;
63 let bar = "█".repeat(bar_len);
64
65 self.interaction_count += 1;
66 self.commands.push(Command::BeginContainer {
67 direction: Direction::Row,
68 gap: 1,
69 align: Align::Start,
70 justify: Justify::Start,
71 border: None,
72 border_sides: BorderSides::all(),
73 border_style: Style::new().fg(self.theme.border),
74 bg_color: None,
75 padding: Padding::default(),
76 margin: Margin::default(),
77 constraints: Constraints::default(),
78 title: None,
79 grow: 0,
80 group_name: None,
81 });
82 self.styled(
83 format!("{label}{label_padding}"),
84 Style::new().fg(self.theme.text),
85 );
86 self.styled(bar, Style::new().fg(self.theme.primary));
87 self.styled(
88 format_compact_number(*value),
89 Style::new().fg(self.theme.text_dim),
90 );
91 self.commands.push(Command::EndContainer);
92 self.last_text_idx = None;
93 }
94
95 self.commands.push(Command::EndContainer);
96 self.last_text_idx = None;
97
98 self
99 }
100
101 pub fn bar_chart_styled(
117 &mut self,
118 bars: &[Bar],
119 max_width: u32,
120 direction: BarDirection,
121 ) -> &mut Self {
122 if bars.is_empty() {
123 return self;
124 }
125
126 let max_value = bars
127 .iter()
128 .map(|bar| bar.value)
129 .fold(f64::NEG_INFINITY, f64::max);
130 let denom = if max_value > 0.0 { max_value } else { 1.0 };
131
132 match direction {
133 BarDirection::Horizontal => {
134 let max_label_width = bars
135 .iter()
136 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
137 .max()
138 .unwrap_or(0);
139
140 self.interaction_count += 1;
141 self.commands.push(Command::BeginContainer {
142 direction: Direction::Column,
143 gap: 0,
144 align: Align::Start,
145 justify: Justify::Start,
146 border: None,
147 border_sides: BorderSides::all(),
148 border_style: Style::new().fg(self.theme.border),
149 bg_color: None,
150 padding: Padding::default(),
151 margin: Margin::default(),
152 constraints: Constraints::default(),
153 title: None,
154 grow: 0,
155 group_name: None,
156 });
157
158 for bar in bars {
159 let label_width = UnicodeWidthStr::width(bar.label.as_str());
160 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
161 let normalized = (bar.value / denom).clamp(0.0, 1.0);
162 let bar_len = (normalized * max_width as f64).round() as usize;
163 let bar_text = "█".repeat(bar_len);
164 let color = bar.color.unwrap_or(self.theme.primary);
165
166 self.interaction_count += 1;
167 self.commands.push(Command::BeginContainer {
168 direction: Direction::Row,
169 gap: 1,
170 align: Align::Start,
171 justify: Justify::Start,
172 border: None,
173 border_sides: BorderSides::all(),
174 border_style: Style::new().fg(self.theme.border),
175 bg_color: None,
176 padding: Padding::default(),
177 margin: Margin::default(),
178 constraints: Constraints::default(),
179 title: None,
180 grow: 0,
181 group_name: None,
182 });
183 self.styled(
184 format!("{}{label_padding}", bar.label),
185 Style::new().fg(self.theme.text),
186 );
187 self.styled(bar_text, Style::new().fg(color));
188 self.styled(
189 format_compact_number(bar.value),
190 Style::new().fg(self.theme.text_dim),
191 );
192 self.commands.push(Command::EndContainer);
193 self.last_text_idx = None;
194 }
195
196 self.commands.push(Command::EndContainer);
197 self.last_text_idx = None;
198 }
199 BarDirection::Vertical => {
200 const FRACTION_BLOCKS: [char; 8] = [' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇'];
201
202 let chart_height = max_width.max(1) as usize;
203 let value_labels: Vec<String> = bars
204 .iter()
205 .map(|bar| format_compact_number(bar.value))
206 .collect();
207 let col_width = bars
208 .iter()
209 .zip(value_labels.iter())
210 .map(|(bar, value)| {
211 UnicodeWidthStr::width(bar.label.as_str())
212 .max(UnicodeWidthStr::width(value.as_str()))
213 .max(1)
214 })
215 .max()
216 .unwrap_or(1);
217
218 let bar_units: Vec<usize> = bars
219 .iter()
220 .map(|bar| {
221 let normalized = (bar.value / denom).clamp(0.0, 1.0);
222 (normalized * chart_height as f64 * 8.0).round() as usize
223 })
224 .collect();
225
226 self.interaction_count += 1;
227 self.commands.push(Command::BeginContainer {
228 direction: Direction::Column,
229 gap: 0,
230 align: Align::Start,
231 justify: Justify::Start,
232 border: None,
233 border_sides: BorderSides::all(),
234 border_style: Style::new().fg(self.theme.border),
235 bg_color: None,
236 padding: Padding::default(),
237 margin: Margin::default(),
238 constraints: Constraints::default(),
239 title: None,
240 grow: 0,
241 group_name: None,
242 });
243
244 self.interaction_count += 1;
245 self.commands.push(Command::BeginContainer {
246 direction: Direction::Row,
247 gap: 1,
248 align: Align::Start,
249 justify: Justify::Start,
250 border: None,
251 border_sides: BorderSides::all(),
252 border_style: Style::new().fg(self.theme.border),
253 bg_color: None,
254 padding: Padding::default(),
255 margin: Margin::default(),
256 constraints: Constraints::default(),
257 title: None,
258 grow: 0,
259 group_name: None,
260 });
261 for value in &value_labels {
262 self.styled(
263 center_text(value, col_width),
264 Style::new().fg(self.theme.text_dim),
265 );
266 }
267 self.commands.push(Command::EndContainer);
268 self.last_text_idx = None;
269
270 for row in (0..chart_height).rev() {
271 self.interaction_count += 1;
272 self.commands.push(Command::BeginContainer {
273 direction: Direction::Row,
274 gap: 1,
275 align: Align::Start,
276 justify: Justify::Start,
277 border: None,
278 border_sides: BorderSides::all(),
279 border_style: Style::new().fg(self.theme.border),
280 bg_color: None,
281 padding: Padding::default(),
282 margin: Margin::default(),
283 constraints: Constraints::default(),
284 title: None,
285 grow: 0,
286 group_name: None,
287 });
288
289 let row_base = row * 8;
290 for (bar, units) in bars.iter().zip(bar_units.iter()) {
291 let fill = if *units <= row_base {
292 ' '
293 } else {
294 let delta = *units - row_base;
295 if delta >= 8 {
296 '█'
297 } else {
298 FRACTION_BLOCKS[delta]
299 }
300 };
301
302 self.styled(
303 center_text(&fill.to_string(), col_width),
304 Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
305 );
306 }
307
308 self.commands.push(Command::EndContainer);
309 self.last_text_idx = None;
310 }
311
312 self.interaction_count += 1;
313 self.commands.push(Command::BeginContainer {
314 direction: Direction::Row,
315 gap: 1,
316 align: Align::Start,
317 justify: Justify::Start,
318 border: None,
319 border_sides: BorderSides::all(),
320 border_style: Style::new().fg(self.theme.border),
321 bg_color: None,
322 padding: Padding::default(),
323 margin: Margin::default(),
324 constraints: Constraints::default(),
325 title: None,
326 grow: 0,
327 group_name: None,
328 });
329 for bar in bars {
330 self.styled(
331 center_text(&bar.label, col_width),
332 Style::new().fg(self.theme.text),
333 );
334 }
335 self.commands.push(Command::EndContainer);
336 self.last_text_idx = None;
337
338 self.commands.push(Command::EndContainer);
339 self.last_text_idx = None;
340 }
341 }
342
343 self
344 }
345
346 pub fn bar_chart_grouped(&mut self, groups: &[BarGroup], max_width: u32) -> &mut Self {
363 if groups.is_empty() {
364 return self;
365 }
366
367 let all_bars: Vec<&Bar> = groups.iter().flat_map(|group| group.bars.iter()).collect();
368 if all_bars.is_empty() {
369 return self;
370 }
371
372 let max_label_width = all_bars
373 .iter()
374 .map(|bar| UnicodeWidthStr::width(bar.label.as_str()))
375 .max()
376 .unwrap_or(0);
377 let max_value = all_bars
378 .iter()
379 .map(|bar| bar.value)
380 .fold(f64::NEG_INFINITY, f64::max);
381 let denom = if max_value > 0.0 { max_value } else { 1.0 };
382
383 self.interaction_count += 1;
384 self.commands.push(Command::BeginContainer {
385 direction: Direction::Column,
386 gap: 1,
387 align: Align::Start,
388 justify: Justify::Start,
389 border: None,
390 border_sides: BorderSides::all(),
391 border_style: Style::new().fg(self.theme.border),
392 bg_color: None,
393 padding: Padding::default(),
394 margin: Margin::default(),
395 constraints: Constraints::default(),
396 title: None,
397 grow: 0,
398 group_name: None,
399 });
400
401 for group in groups {
402 self.styled(group.label.clone(), Style::new().bold().fg(self.theme.text));
403
404 for bar in &group.bars {
405 let label_width = UnicodeWidthStr::width(bar.label.as_str());
406 let label_padding = " ".repeat(max_label_width.saturating_sub(label_width));
407 let normalized = (bar.value / denom).clamp(0.0, 1.0);
408 let bar_len = (normalized * max_width as f64).round() as usize;
409 let bar_text = "█".repeat(bar_len);
410
411 self.interaction_count += 1;
412 self.commands.push(Command::BeginContainer {
413 direction: Direction::Row,
414 gap: 1,
415 align: Align::Start,
416 justify: Justify::Start,
417 border: None,
418 border_sides: BorderSides::all(),
419 border_style: Style::new().fg(self.theme.border),
420 bg_color: None,
421 padding: Padding::default(),
422 margin: Margin::default(),
423 constraints: Constraints::default(),
424 title: None,
425 grow: 0,
426 group_name: None,
427 });
428 self.styled(
429 format!(" {}{label_padding}", bar.label),
430 Style::new().fg(self.theme.text),
431 );
432 self.styled(
433 bar_text,
434 Style::new().fg(bar.color.unwrap_or(self.theme.primary)),
435 );
436 self.styled(
437 format_compact_number(bar.value),
438 Style::new().fg(self.theme.text_dim),
439 );
440 self.commands.push(Command::EndContainer);
441 self.last_text_idx = None;
442 }
443 }
444
445 self.commands.push(Command::EndContainer);
446 self.last_text_idx = None;
447
448 self
449 }
450
451 pub fn sparkline(&mut self, data: &[f64], width: u32) -> &mut Self {
467 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
468
469 let w = width as usize;
470 let window = if data.len() > w {
471 &data[data.len() - w..]
472 } else {
473 data
474 };
475
476 if window.is_empty() {
477 return self;
478 }
479
480 let min = window.iter().copied().fold(f64::INFINITY, f64::min);
481 let max = window.iter().copied().fold(f64::NEG_INFINITY, f64::max);
482 let range = max - min;
483
484 let line: String = window
485 .iter()
486 .map(|&value| {
487 let normalized = if range == 0.0 {
488 0.5
489 } else {
490 (value - min) / range
491 };
492 let idx = (normalized * 7.0).round() as usize;
493 BLOCKS[idx.min(7)]
494 })
495 .collect();
496
497 self.styled(line, Style::new().fg(self.theme.primary))
498 }
499
500 pub fn sparkline_styled(&mut self, data: &[(f64, Option<Color>)], width: u32) -> &mut Self {
520 const BLOCKS: [char; 8] = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
521
522 let w = width as usize;
523 let window = if data.len() > w {
524 &data[data.len() - w..]
525 } else {
526 data
527 };
528
529 if window.is_empty() {
530 return self;
531 }
532
533 let mut finite_values = window
534 .iter()
535 .map(|(value, _)| *value)
536 .filter(|value| !value.is_nan());
537 let Some(first) = finite_values.next() else {
538 return self.styled(
539 " ".repeat(window.len()),
540 Style::new().fg(self.theme.text_dim),
541 );
542 };
543
544 let mut min = first;
545 let mut max = first;
546 for value in finite_values {
547 min = f64::min(min, value);
548 max = f64::max(max, value);
549 }
550 let range = max - min;
551
552 let mut cells: Vec<(char, Color)> = Vec::with_capacity(window.len());
553 for (value, color) in window {
554 if value.is_nan() {
555 cells.push((' ', self.theme.text_dim));
556 continue;
557 }
558
559 let normalized = if range == 0.0 {
560 0.5
561 } else {
562 ((*value - min) / range).clamp(0.0, 1.0)
563 };
564 let idx = (normalized * 7.0).round() as usize;
565 cells.push((BLOCKS[idx.min(7)], color.unwrap_or(self.theme.primary)));
566 }
567
568 self.interaction_count += 1;
569 self.commands.push(Command::BeginContainer {
570 direction: Direction::Row,
571 gap: 0,
572 align: Align::Start,
573 justify: Justify::Start,
574 border: None,
575 border_sides: BorderSides::all(),
576 border_style: Style::new().fg(self.theme.border),
577 bg_color: None,
578 padding: Padding::default(),
579 margin: Margin::default(),
580 constraints: Constraints::default(),
581 title: None,
582 grow: 0,
583 group_name: None,
584 });
585
586 let mut seg = String::new();
587 let mut seg_color = cells[0].1;
588 for (ch, color) in cells {
589 if color != seg_color {
590 self.styled(seg, Style::new().fg(seg_color));
591 seg = String::new();
592 seg_color = color;
593 }
594 seg.push(ch);
595 }
596 if !seg.is_empty() {
597 self.styled(seg, Style::new().fg(seg_color));
598 }
599
600 self.commands.push(Command::EndContainer);
601 self.last_text_idx = None;
602
603 self
604 }
605
606 pub fn line_chart(&mut self, data: &[f64], width: u32, height: u32) -> &mut Self {
620 if data.is_empty() || width == 0 || height == 0 {
621 return self;
622 }
623
624 let cols = width as usize;
625 let rows = height as usize;
626 let px_w = cols * 2;
627 let px_h = rows * 4;
628
629 let min = data.iter().copied().fold(f64::INFINITY, f64::min);
630 let max = data.iter().copied().fold(f64::NEG_INFINITY, f64::max);
631 let range = if (max - min).abs() < f64::EPSILON {
632 1.0
633 } else {
634 max - min
635 };
636
637 let points: Vec<usize> = (0..px_w)
638 .map(|px| {
639 let data_idx = if px_w <= 1 {
640 0.0
641 } else {
642 px as f64 * (data.len() - 1) as f64 / (px_w - 1) as f64
643 };
644 let idx = data_idx.floor() as usize;
645 let frac = data_idx - idx as f64;
646 let value = if idx + 1 < data.len() {
647 data[idx] * (1.0 - frac) + data[idx + 1] * frac
648 } else {
649 data[idx.min(data.len() - 1)]
650 };
651
652 let normalized = (value - min) / range;
653 let py = ((1.0 - normalized) * (px_h - 1) as f64).round() as usize;
654 py.min(px_h - 1)
655 })
656 .collect();
657
658 const LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
659 const RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
660
661 let mut grid = vec![vec![0u32; cols]; rows];
662
663 for i in 0..points.len() {
664 let px = i;
665 let py = points[i];
666 let char_col = px / 2;
667 let char_row = py / 4;
668 let sub_col = px % 2;
669 let sub_row = py % 4;
670
671 if char_col < cols && char_row < rows {
672 grid[char_row][char_col] |= if sub_col == 0 {
673 LEFT_BITS[sub_row]
674 } else {
675 RIGHT_BITS[sub_row]
676 };
677 }
678
679 if i + 1 < points.len() {
680 let py_next = points[i + 1];
681 let (y_start, y_end) = if py <= py_next {
682 (py, py_next)
683 } else {
684 (py_next, py)
685 };
686 for y in y_start..=y_end {
687 let cell_row = y / 4;
688 let sub_y = y % 4;
689 if char_col < cols && cell_row < rows {
690 grid[cell_row][char_col] |= if sub_col == 0 {
691 LEFT_BITS[sub_y]
692 } else {
693 RIGHT_BITS[sub_y]
694 };
695 }
696 }
697 }
698 }
699
700 let style = Style::new().fg(self.theme.primary);
701 for row in grid {
702 let line: String = row
703 .iter()
704 .map(|&bits| char::from_u32(0x2800 + bits).unwrap_or(' '))
705 .collect();
706 self.styled(line, style);
707 }
708
709 self
710 }
711
712 pub fn canvas(
729 &mut self,
730 width: u32,
731 height: u32,
732 draw: impl FnOnce(&mut CanvasContext),
733 ) -> &mut Self {
734 if width == 0 || height == 0 {
735 return self;
736 }
737
738 let mut canvas = CanvasContext::new(width as usize, height as usize);
739 draw(&mut canvas);
740
741 for segments in canvas.render() {
742 self.interaction_count += 1;
743 self.commands.push(Command::BeginContainer {
744 direction: Direction::Row,
745 gap: 0,
746 align: Align::Start,
747 justify: Justify::Start,
748 border: None,
749 border_sides: BorderSides::all(),
750 border_style: Style::new(),
751 bg_color: None,
752 padding: Padding::default(),
753 margin: Margin::default(),
754 constraints: Constraints::default(),
755 title: None,
756 grow: 0,
757 group_name: None,
758 });
759 for (text, color) in segments {
760 let c = if color == Color::Reset {
761 self.theme.primary
762 } else {
763 color
764 };
765 self.styled(text, Style::new().fg(c));
766 }
767 self.commands.push(Command::EndContainer);
768 self.last_text_idx = None;
769 }
770
771 self
772 }
773
774 pub fn chart(
776 &mut self,
777 configure: impl FnOnce(&mut ChartBuilder),
778 width: u32,
779 height: u32,
780 ) -> &mut Self {
781 if width == 0 || height == 0 {
782 return self;
783 }
784
785 let axis_style = Style::new().fg(self.theme.text_dim);
786 let mut builder = ChartBuilder::new(width, height, axis_style, axis_style);
787 configure(&mut builder);
788
789 let config = builder.build();
790 let rows = render_chart(&config);
791
792 for row in rows {
793 self.interaction_count += 1;
794 self.commands.push(Command::BeginContainer {
795 direction: Direction::Row,
796 gap: 0,
797 align: Align::Start,
798 justify: Justify::Start,
799 border: None,
800 border_sides: BorderSides::all(),
801 border_style: Style::new().fg(self.theme.border),
802 bg_color: None,
803 padding: Padding::default(),
804 margin: Margin::default(),
805 constraints: Constraints::default(),
806 title: None,
807 grow: 0,
808 group_name: None,
809 });
810 for (text, style) in row.segments {
811 self.styled(text, style);
812 }
813 self.commands.push(Command::EndContainer);
814 self.last_text_idx = None;
815 }
816
817 self
818 }
819
820 pub fn scatter(&mut self, data: &[(f64, f64)], width: u32, height: u32) -> &mut Self {
824 self.chart(
825 |c| {
826 c.scatter(data);
827 c.grid(true);
828 },
829 width,
830 height,
831 )
832 }
833
834 pub fn histogram(&mut self, data: &[f64], width: u32, height: u32) -> &mut Self {
836 self.histogram_with(data, |_| {}, width, height)
837 }
838
839 pub fn histogram_with(
841 &mut self,
842 data: &[f64],
843 configure: impl FnOnce(&mut HistogramBuilder),
844 width: u32,
845 height: u32,
846 ) -> &mut Self {
847 if width == 0 || height == 0 {
848 return self;
849 }
850
851 let mut options = HistogramBuilder::default();
852 configure(&mut options);
853 let axis_style = Style::new().fg(self.theme.text_dim);
854 let config = build_histogram_config(data, &options, width, height, axis_style);
855 let rows = render_chart(&config);
856
857 for row in rows {
858 self.interaction_count += 1;
859 self.commands.push(Command::BeginContainer {
860 direction: Direction::Row,
861 gap: 0,
862 align: Align::Start,
863 justify: Justify::Start,
864 border: None,
865 border_sides: BorderSides::all(),
866 border_style: Style::new().fg(self.theme.border),
867 bg_color: None,
868 padding: Padding::default(),
869 margin: Margin::default(),
870 constraints: Constraints::default(),
871 title: None,
872 grow: 0,
873 group_name: None,
874 });
875 for (text, style) in row.segments {
876 self.styled(text, style);
877 }
878 self.commands.push(Command::EndContainer);
879 self.last_text_idx = None;
880 }
881
882 self
883 }
884}