1use crate::style::{Color, Style};
8use unicode_width::UnicodeWidthStr;
9
10const BRAILLE_BASE: u32 = 0x2800;
11const BRAILLE_LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
12const BRAILLE_RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
13const PALETTE: [Color; 8] = [
14 Color::Cyan,
15 Color::Yellow,
16 Color::Green,
17 Color::Magenta,
18 Color::Red,
19 Color::Blue,
20 Color::White,
21 Color::Indexed(208),
22];
23const BLOCK_FRACTIONS: [char; 9] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
24
25pub type ColorSpan = (usize, usize, Color);
27
28pub type RenderedLine = (String, Vec<ColorSpan>);
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Marker {
34 Braille,
36 Dot,
38 Block,
40 HalfBlock,
42 Cross,
44 Circle,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum GraphType {
51 Line,
53 Scatter,
55 Bar,
57 Area,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum LegendPosition {
64 TopLeft,
66 TopRight,
68 BottomLeft,
70 BottomRight,
72 None,
74}
75
76#[derive(Debug, Clone)]
78pub struct Axis {
79 pub title: Option<String>,
81 pub bounds: Option<(f64, f64)>,
83 pub labels: Option<Vec<String>>,
85 pub style: Style,
87}
88
89impl Default for Axis {
90 fn default() -> Self {
91 Self {
92 title: None,
93 bounds: None,
94 labels: None,
95 style: Style::new(),
96 }
97 }
98}
99
100#[derive(Debug, Clone)]
102pub struct Dataset {
103 pub name: String,
105 pub data: Vec<(f64, f64)>,
107 pub color: Color,
109 pub marker: Marker,
111 pub graph_type: GraphType,
113}
114
115#[derive(Debug, Clone)]
117pub struct ChartConfig {
118 pub title: Option<String>,
120 pub x_axis: Axis,
122 pub y_axis: Axis,
124 pub datasets: Vec<Dataset>,
126 pub legend: LegendPosition,
128 pub grid: bool,
130 pub width: u32,
132 pub height: u32,
134}
135
136#[derive(Debug, Clone)]
138pub(crate) struct ChartRow {
139 pub segments: Vec<(String, Style)>,
141}
142
143#[derive(Debug, Clone)]
145#[must_use = "configure histogram before rendering"]
146pub struct HistogramBuilder {
147 pub bins: Option<usize>,
149 pub color: Color,
151 pub x_title: Option<String>,
153 pub y_title: Option<String>,
155}
156
157impl Default for HistogramBuilder {
158 fn default() -> Self {
159 Self {
160 bins: None,
161 color: Color::Cyan,
162 x_title: None,
163 y_title: Some("Count".to_string()),
164 }
165 }
166}
167
168impl HistogramBuilder {
169 pub fn bins(&mut self, bins: usize) -> &mut Self {
171 self.bins = Some(bins.max(1));
172 self
173 }
174
175 pub fn color(&mut self, color: Color) -> &mut Self {
177 self.color = color;
178 self
179 }
180
181 pub fn xlabel(&mut self, title: &str) -> &mut Self {
183 self.x_title = Some(title.to_string());
184 self
185 }
186
187 pub fn ylabel(&mut self, title: &str) -> &mut Self {
189 self.y_title = Some(title.to_string());
190 self
191 }
192}
193
194#[derive(Debug, Clone)]
196pub struct DatasetEntry {
197 dataset: Dataset,
198 color_overridden: bool,
199}
200
201impl DatasetEntry {
202 pub fn label(&mut self, name: &str) -> &mut Self {
204 self.dataset.name = name.to_string();
205 self
206 }
207
208 pub fn color(&mut self, color: Color) -> &mut Self {
210 self.dataset.color = color;
211 self.color_overridden = true;
212 self
213 }
214
215 pub fn marker(&mut self, marker: Marker) -> &mut Self {
217 self.dataset.marker = marker;
218 self
219 }
220}
221
222#[derive(Debug, Clone)]
224#[must_use = "configure chart before rendering"]
225pub struct ChartBuilder {
226 config: ChartConfig,
227 entries: Vec<DatasetEntry>,
228}
229
230impl ChartBuilder {
231 pub fn new(width: u32, height: u32, x_style: Style, y_style: Style) -> Self {
233 Self {
234 config: ChartConfig {
235 title: None,
236 x_axis: Axis {
237 style: x_style,
238 ..Axis::default()
239 },
240 y_axis: Axis {
241 style: y_style,
242 ..Axis::default()
243 },
244 datasets: Vec::new(),
245 legend: LegendPosition::TopRight,
246 grid: true,
247 width,
248 height,
249 },
250 entries: Vec::new(),
251 }
252 }
253
254 pub fn title(&mut self, title: &str) -> &mut Self {
256 self.config.title = Some(title.to_string());
257 self
258 }
259
260 pub fn xlabel(&mut self, label: &str) -> &mut Self {
262 self.config.x_axis.title = Some(label.to_string());
263 self
264 }
265
266 pub fn ylabel(&mut self, label: &str) -> &mut Self {
268 self.config.y_axis.title = Some(label.to_string());
269 self
270 }
271
272 pub fn xlim(&mut self, min: f64, max: f64) -> &mut Self {
274 self.config.x_axis.bounds = Some((min, max));
275 self
276 }
277
278 pub fn ylim(&mut self, min: f64, max: f64) -> &mut Self {
280 self.config.y_axis.bounds = Some((min, max));
281 self
282 }
283
284 pub fn grid(&mut self, on: bool) -> &mut Self {
286 self.config.grid = on;
287 self
288 }
289
290 pub fn legend(&mut self, position: LegendPosition) -> &mut Self {
292 self.config.legend = position;
293 self
294 }
295
296 pub fn line(&mut self, data: &[(f64, f64)]) -> &mut DatasetEntry {
298 self.push_dataset(data, GraphType::Line, Marker::Braille)
299 }
300
301 pub fn scatter(&mut self, data: &[(f64, f64)]) -> &mut DatasetEntry {
303 self.push_dataset(data, GraphType::Scatter, Marker::Braille)
304 }
305
306 pub fn bar(&mut self, data: &[(f64, f64)]) -> &mut DatasetEntry {
308 self.push_dataset(data, GraphType::Bar, Marker::Block)
309 }
310
311 pub fn area(&mut self, data: &[(f64, f64)]) -> &mut DatasetEntry {
313 self.push_dataset(data, GraphType::Area, Marker::Braille)
314 }
315
316 pub fn build(mut self) -> ChartConfig {
318 for (index, mut entry) in self.entries.drain(..).enumerate() {
319 if !entry.color_overridden {
320 entry.dataset.color = PALETTE[index % PALETTE.len()];
321 }
322 self.config.datasets.push(entry.dataset);
323 }
324 self.config
325 }
326
327 fn push_dataset(
328 &mut self,
329 data: &[(f64, f64)],
330 graph_type: GraphType,
331 marker: Marker,
332 ) -> &mut DatasetEntry {
333 let series_name = format!("Series {}", self.entries.len() + 1);
334 self.entries.push(DatasetEntry {
335 dataset: Dataset {
336 name: series_name,
337 data: data.to_vec(),
338 color: Color::Reset,
339 marker,
340 graph_type,
341 },
342 color_overridden: false,
343 });
344 let last_index = self.entries.len().saturating_sub(1);
345 &mut self.entries[last_index]
346 }
347}
348
349#[derive(Debug, Clone)]
351pub struct ChartRenderer {
352 config: ChartConfig,
353}
354
355impl ChartRenderer {
356 pub fn new(config: ChartConfig) -> Self {
358 Self { config }
359 }
360
361 pub fn render(&self) -> Vec<RenderedLine> {
363 let rows = render_chart(&self.config);
364 rows.into_iter()
365 .map(|row| {
366 let mut line = String::new();
367 let mut spans: Vec<(usize, usize, Color)> = Vec::new();
368 let mut cursor = 0usize;
369
370 for (segment, style) in row.segments {
371 let width = UnicodeWidthStr::width(segment.as_str());
372 line.push_str(&segment);
373 if let Some(color) = style.fg {
374 spans.push((cursor, cursor + width, color));
375 }
376 cursor += width;
377 }
378
379 (line, spans)
380 })
381 .collect()
382 }
383}
384
385pub(crate) fn build_histogram_config(
387 data: &[f64],
388 options: &HistogramBuilder,
389 width: u32,
390 height: u32,
391 axis_style: Style,
392) -> ChartConfig {
393 let mut sorted: Vec<f64> = data.iter().copied().filter(|v| v.is_finite()).collect();
394 sorted.sort_by(f64::total_cmp);
395
396 if sorted.is_empty() {
397 return ChartConfig {
398 title: Some("Histogram".to_string()),
399 x_axis: Axis {
400 title: options.x_title.clone(),
401 bounds: Some((0.0, 1.0)),
402 labels: None,
403 style: axis_style,
404 },
405 y_axis: Axis {
406 title: options.y_title.clone(),
407 bounds: Some((0.0, 1.0)),
408 labels: None,
409 style: axis_style,
410 },
411 datasets: Vec::new(),
412 legend: LegendPosition::None,
413 grid: true,
414 width,
415 height,
416 };
417 }
418
419 let n = sorted.len();
420 let min = sorted[0];
421 let max = sorted[n.saturating_sub(1)];
422 let bin_count = options.bins.unwrap_or_else(|| sturges_bin_count(n));
423
424 let span = if (max - min).abs() < f64::EPSILON {
425 1.0
426 } else {
427 max - min
428 };
429 let bin_width = span / bin_count as f64;
430
431 let mut counts = vec![0usize; bin_count];
432 for value in sorted {
433 let raw = ((value - min) / bin_width).floor();
434 let mut idx = if raw.is_finite() { raw as isize } else { 0 };
435 if idx < 0 {
436 idx = 0;
437 }
438 if idx as usize >= bin_count {
439 idx = (bin_count.saturating_sub(1)) as isize;
440 }
441 counts[idx as usize] = counts[idx as usize].saturating_add(1);
442 }
443
444 let mut data_points = Vec::with_capacity(bin_count);
445 for (i, count) in counts.iter().enumerate() {
446 let center = min + (i as f64 + 0.5) * bin_width;
447 data_points.push((center, *count as f64));
448 }
449
450 let mut labels: Vec<String> = Vec::new();
451 let step = (bin_count / 4).max(1);
452 for i in (0..=bin_count).step_by(step) {
453 let edge = min + i as f64 * bin_width;
454 labels.push(format_number(edge, bin_width));
455 }
456
457 ChartConfig {
458 title: Some("Histogram".to_string()),
459 x_axis: Axis {
460 title: options.x_title.clone(),
461 bounds: Some((min, max.max(min + bin_width))),
462 labels: Some(labels),
463 style: axis_style,
464 },
465 y_axis: Axis {
466 title: options.y_title.clone(),
467 bounds: Some((0.0, counts.iter().copied().max().unwrap_or(1) as f64)),
468 labels: None,
469 style: axis_style,
470 },
471 datasets: vec![Dataset {
472 name: "Histogram".to_string(),
473 data: data_points,
474 color: options.color,
475 marker: Marker::Block,
476 graph_type: GraphType::Bar,
477 }],
478 legend: LegendPosition::None,
479 grid: true,
480 width,
481 height,
482 }
483}
484
485pub(crate) fn render_chart(config: &ChartConfig) -> Vec<ChartRow> {
487 let width = config.width as usize;
488 let height = config.height as usize;
489 if width == 0 || height == 0 {
490 return Vec::new();
491 }
492
493 let frame_style = config.x_axis.style;
494 let dim_style = Style::new().dim();
495 let axis_style = config.y_axis.style;
496 let title_style = Style::new()
497 .bold()
498 .fg(config.x_axis.style.fg.unwrap_or(Color::White));
499
500 let title_rows = usize::from(config.title.is_some());
501 let has_x_title = config.x_axis.title.is_some();
502 let x_title_rows = usize::from(has_x_title);
503
504 let overhead = title_rows + 3 + x_title_rows;
509 if height <= overhead + 1 || width < 6 {
510 return minimal_chart(config, width, frame_style, title_style);
511 }
512 let plot_height = height.saturating_sub(overhead + 1).max(1);
513
514 let (x_min, x_max) = resolve_bounds(
515 config
516 .datasets
517 .iter()
518 .flat_map(|d| d.data.iter().map(|p| p.0)),
519 config.x_axis.bounds,
520 );
521 let (y_min, y_max) = resolve_bounds(
522 config
523 .datasets
524 .iter()
525 .flat_map(|d| d.data.iter().map(|p| p.1)),
526 config.y_axis.bounds,
527 );
528
529 let y_label_chars: Vec<char> = config
530 .y_axis
531 .title
532 .as_deref()
533 .map(|t| t.chars().collect())
534 .unwrap_or_default();
535 let y_label_col_width = if y_label_chars.is_empty() { 0 } else { 2 };
536
537 let legend_items = build_legend_items(&config.datasets);
538 let legend_on_right = matches!(
539 config.legend,
540 LegendPosition::TopRight | LegendPosition::BottomRight
541 );
542 let legend_width = if legend_on_right && !legend_items.is_empty() {
543 legend_items
544 .iter()
545 .map(|(_, name, _)| 4 + UnicodeWidthStr::width(name.as_str()))
546 .max()
547 .unwrap_or(0)
548 } else {
549 0
550 };
551
552 let y_ticks = build_tui_ticks(y_min, y_max, plot_height);
553 let y_min = y_ticks.values.first().copied().unwrap_or(y_min).min(y_min);
554 let y_max = y_ticks.values.last().copied().unwrap_or(y_max).max(y_max);
555
556 let y_tick_labels: Vec<String> = y_ticks
557 .values
558 .iter()
559 .map(|v| format_number(*v, y_ticks.step))
560 .collect();
561 let y_tick_width = y_tick_labels
562 .iter()
563 .map(|s| UnicodeWidthStr::width(s.as_str()))
564 .max()
565 .unwrap_or(1);
566 let y_axis_width = y_tick_width + 2;
567
568 let inner_width = width.saturating_sub(2);
569 let plot_width = inner_width
570 .saturating_sub(y_label_col_width)
571 .saturating_sub(y_axis_width)
572 .saturating_sub(legend_width)
573 .max(1);
574 let content_width = y_label_col_width + y_axis_width + plot_width + legend_width;
575
576 let x_ticks = build_tui_ticks(x_min, x_max, plot_width);
577 let x_min = x_ticks.values.first().copied().unwrap_or(x_min).min(x_min);
578 let x_max = x_ticks.values.last().copied().unwrap_or(x_max).max(x_max);
579
580 let mut plot_chars = vec![vec![' '; plot_width]; plot_height];
581 let mut plot_styles = vec![vec![Style::new(); plot_width]; plot_height];
582
583 apply_grid(
584 config,
585 GridSpec {
586 x_ticks: &x_ticks.values,
587 y_ticks: &y_ticks.values,
588 x_min,
589 x_max,
590 y_min,
591 y_max,
592 },
593 &mut plot_chars,
594 &mut plot_styles,
595 dim_style,
596 );
597
598 for dataset in &config.datasets {
599 match dataset.graph_type {
600 GraphType::Line | GraphType::Scatter => {
601 draw_braille_dataset(
602 dataset,
603 x_min,
604 x_max,
605 y_min,
606 y_max,
607 &mut plot_chars,
608 &mut plot_styles,
609 );
610 }
611 GraphType::Area => {
612 draw_area_dataset(
613 dataset,
614 x_min,
615 x_max,
616 y_min,
617 y_max,
618 &mut plot_chars,
619 &mut plot_styles,
620 );
621 }
622 GraphType::Bar => {
623 draw_bar_dataset(
624 dataset,
625 x_min,
626 x_max,
627 y_min,
628 y_max,
629 &mut plot_chars,
630 &mut plot_styles,
631 );
632 }
633 }
634 }
635
636 if !legend_items.is_empty()
637 && matches!(
638 config.legend,
639 LegendPosition::TopLeft | LegendPosition::BottomLeft
640 )
641 {
642 overlay_legend_on_plot(
643 config.legend,
644 &legend_items,
645 &mut plot_chars,
646 &mut plot_styles,
647 axis_style,
648 );
649 }
650
651 let y_tick_rows = build_y_tick_row_map(&y_ticks.values, y_min, y_max, plot_height);
652 let x_tick_cols = build_x_tick_col_map(
653 &x_ticks.values,
654 config.x_axis.labels.as_deref(),
655 x_min,
656 x_max,
657 plot_width,
658 );
659
660 let mut rows: Vec<ChartRow> = Vec::with_capacity(height);
661
662 if let Some(title) = &config.title {
664 rows.push(ChartRow {
665 segments: vec![(center_text(title, width), title_style)],
666 });
667 }
668
669 rows.push(ChartRow {
671 segments: vec![(format!("┌{}┐", "─".repeat(content_width)), frame_style)],
672 });
673
674 let y_label_start = if y_label_chars.is_empty() {
675 0
676 } else {
677 plot_height.saturating_sub(y_label_chars.len()) / 2
678 };
679
680 let zero_label = format_number(0.0, y_ticks.step);
681 for row in 0..plot_height {
682 let mut segments: Vec<(String, Style)> = Vec::new();
683 segments.push(("│".to_string(), frame_style));
684
685 if y_label_col_width > 0 {
686 let label_idx = row.wrapping_sub(y_label_start);
687 if label_idx < y_label_chars.len() {
688 segments.push((format!("{} ", y_label_chars[label_idx]), axis_style));
689 } else {
690 segments.push((" ".to_string(), Style::new()));
691 }
692 }
693
694 let (label, divider) = if let Some(index) = y_tick_rows.iter().position(|(r, _)| *r == row)
695 {
696 let is_zero = y_tick_rows[index].1 == zero_label;
697 (
698 y_tick_rows[index].1.clone(),
699 if is_zero { '┼' } else { '┤' },
700 )
701 } else {
702 (String::new(), '│')
703 };
704 let padded = format!("{:>w$}", label, w = y_tick_width);
705 segments.push((padded, axis_style));
706 segments.push((format!("{divider} "), axis_style));
707
708 let mut current_style = Style::new();
709 let mut buffer = String::new();
710 for col in 0..plot_width {
711 let style = plot_styles[row][col];
712 if col == 0 {
713 current_style = style;
714 }
715 if style != current_style {
716 if !buffer.is_empty() {
717 segments.push((buffer.clone(), current_style));
718 buffer.clear();
719 }
720 current_style = style;
721 }
722 buffer.push(plot_chars[row][col]);
723 }
724 if !buffer.is_empty() {
725 segments.push((buffer, current_style));
726 }
727
728 if legend_on_right && legend_width > 0 {
729 let legend_row = match config.legend {
730 LegendPosition::TopRight => row,
731 LegendPosition::BottomRight => {
732 row.wrapping_add(legend_items.len().saturating_sub(plot_height))
733 }
734 _ => usize::MAX,
735 };
736 if let Some((symbol, name, color)) = legend_items.get(legend_row) {
737 let raw = format!(" {symbol} {name}");
738 let raw_w = UnicodeWidthStr::width(raw.as_str());
739 let pad = legend_width.saturating_sub(raw_w);
740 let text = format!("{raw}{}", " ".repeat(pad));
741 segments.push((text, Style::new().fg(*color)));
742 } else {
743 segments.push((" ".repeat(legend_width), Style::new()));
744 }
745 }
746
747 segments.push(("│".to_string(), frame_style));
748 rows.push(ChartRow { segments });
749 }
750
751 let mut axis_line = vec!['─'; plot_width];
753 for (col, _) in &x_tick_cols {
754 if *col < plot_width {
755 axis_line[*col] = '┬';
756 }
757 }
758 let footer_legend_pad = " ".repeat(legend_width);
759 let footer_ylabel_pad = " ".repeat(y_label_col_width);
760 rows.push(ChartRow {
761 segments: vec![
762 ("│".to_string(), frame_style),
763 (footer_ylabel_pad.clone(), Style::new()),
764 (" ".repeat(y_tick_width), axis_style),
765 ("┴─".to_string(), axis_style),
766 (axis_line.into_iter().collect(), axis_style),
767 (footer_legend_pad.clone(), Style::new()),
768 ("│".to_string(), frame_style),
769 ],
770 });
771
772 let mut x_label_line: Vec<char> = vec![' '; plot_width];
773 let mut occupied_until: usize = 0;
774 for (col, label) in &x_tick_cols {
775 if label.is_empty() {
776 continue;
777 }
778 let label_width = UnicodeWidthStr::width(label.as_str());
779 let start = col
780 .saturating_sub(label_width / 2)
781 .min(plot_width.saturating_sub(label_width));
782 if start < occupied_until {
783 continue;
784 }
785 for (offset, ch) in label.chars().enumerate() {
786 let idx = start + offset;
787 if idx < plot_width {
788 x_label_line[idx] = ch;
789 }
790 }
791 occupied_until = start + label_width + 1;
792 }
793 rows.push(ChartRow {
794 segments: vec![
795 ("│".to_string(), frame_style),
796 (footer_ylabel_pad.clone(), Style::new()),
797 (" ".repeat(y_axis_width), Style::new()),
798 (x_label_line.into_iter().collect(), axis_style),
799 (footer_legend_pad.clone(), Style::new()),
800 ("│".to_string(), frame_style),
801 ],
802 });
803
804 if has_x_title {
805 let x_title_text = config.x_axis.title.as_deref().unwrap_or_default();
806 let x_title = center_text(x_title_text, plot_width);
807 rows.push(ChartRow {
808 segments: vec![
809 ("│".to_string(), frame_style),
810 (footer_ylabel_pad, Style::new()),
811 (" ".repeat(y_axis_width), Style::new()),
812 (x_title, axis_style),
813 (footer_legend_pad, Style::new()),
814 ("│".to_string(), frame_style),
815 ],
816 });
817 }
818
819 rows.push(ChartRow {
821 segments: vec![(format!("└{}┘", "─".repeat(content_width)), frame_style)],
822 });
823
824 rows
825}
826
827fn minimal_chart(
828 config: &ChartConfig,
829 width: usize,
830 frame_style: Style,
831 title_style: Style,
832) -> Vec<ChartRow> {
833 let mut rows = Vec::new();
834 if let Some(title) = &config.title {
835 rows.push(ChartRow {
836 segments: vec![(center_text(title, width), title_style)],
837 });
838 }
839 let inner = width.saturating_sub(2);
840 rows.push(ChartRow {
841 segments: vec![(format!("┌{}┐", "─".repeat(inner)), frame_style)],
842 });
843 rows.push(ChartRow {
844 segments: vec![(format!("│{}│", " ".repeat(inner)), frame_style)],
845 });
846 rows.push(ChartRow {
847 segments: vec![(format!("└{}┘", "─".repeat(inner)), frame_style)],
848 });
849 rows
850}
851
852fn resolve_bounds<I>(values: I, manual: Option<(f64, f64)>) -> (f64, f64)
853where
854 I: Iterator<Item = f64>,
855{
856 if let Some((min, max)) = manual {
857 return normalize_bounds(min, max);
858 }
859
860 let mut min = f64::INFINITY;
861 let mut max = f64::NEG_INFINITY;
862 for value in values {
863 if !value.is_finite() {
864 continue;
865 }
866 min = min.min(value);
867 max = max.max(value);
868 }
869
870 if !min.is_finite() || !max.is_finite() {
871 return (0.0, 1.0);
872 }
873
874 normalize_bounds(min, max)
875}
876
877fn normalize_bounds(min: f64, max: f64) -> (f64, f64) {
878 if (max - min).abs() < f64::EPSILON {
879 let pad = if min.abs() < 1.0 {
880 1.0
881 } else {
882 min.abs() * 0.1
883 };
884 (min - pad, max + pad)
885 } else if min < max {
886 (min, max)
887 } else {
888 (max, min)
889 }
890}
891
892#[derive(Debug, Clone)]
893struct TickSpec {
894 values: Vec<f64>,
895 step: f64,
896}
897
898fn build_ticks(min: f64, max: f64, target: usize) -> TickSpec {
899 let span = (max - min).abs().max(f64::EPSILON);
900 let range = nice_number(span, false);
901 let raw_step = range / (target.max(2) as f64 - 1.0);
902 let step = nice_number(raw_step, true).max(f64::EPSILON);
903 let nice_min = (min / step).floor() * step;
904 let nice_max = (max / step).ceil() * step;
905
906 let mut values = Vec::new();
907 let mut value = nice_min;
908 let limit = nice_max + step * 0.5;
909 let mut guard = 0usize;
910 while value <= limit && guard < 128 {
911 values.push(value);
912 value += step;
913 guard = guard.saturating_add(1);
914 }
915
916 if values.is_empty() {
917 values.push(min);
918 values.push(max);
919 }
920
921 TickSpec { values, step }
922}
923
924fn build_tui_ticks(data_min: f64, data_max: f64, cell_count: usize) -> TickSpec {
928 let last = cell_count.saturating_sub(1).max(1);
929 let span = (data_max - data_min).abs().max(f64::EPSILON);
930 let log = span.log10().floor();
931
932 let mut candidates: Vec<(f64, f64, usize, usize)> = Vec::new();
933
934 for exp_off in -1..=1i32 {
935 let base = 10.0_f64.powf(log + f64::from(exp_off));
936 for &mult in &[1.0, 2.0, 2.5, 5.0] {
937 let step = base * mult;
938 if step <= 0.0 || !step.is_finite() {
939 continue;
940 }
941 let lo = (data_min / step).floor() * step;
942 let hi = (data_max / step).ceil() * step;
943 let n = ((hi - lo) / step + 0.5) as usize;
944 if (3..=8).contains(&n) && last / n >= 2 {
945 let rem = last % n;
946 candidates.push((step, lo, n, rem));
947 }
948 }
949 }
950
951 candidates.sort_by(|a, b| {
952 a.3.cmp(&b.3).then_with(|| {
953 let da = (a.2 as i32 - 5).unsigned_abs();
954 let db = (b.2 as i32 - 5).unsigned_abs();
955 da.cmp(&db)
956 })
957 });
958
959 if let Some(&(step, lo, n, _)) = candidates.first() {
960 let values: Vec<f64> = (0..=n).map(|i| lo + step * i as f64).collect();
961 return TickSpec { values, step };
962 }
963
964 build_ticks(data_min, data_max, 5)
965}
966
967fn nice_number(value: f64, round: bool) -> f64 {
968 if value <= 0.0 || !value.is_finite() {
969 return 1.0;
970 }
971 let exponent = value.log10().floor();
972 let power = 10.0_f64.powf(exponent);
973 let fraction = value / power;
974
975 let nice_fraction = if round {
976 if fraction < 1.5 {
977 1.0
978 } else if fraction < 3.0 {
979 2.0
980 } else if fraction < 7.0 {
981 5.0
982 } else {
983 10.0
984 }
985 } else if fraction <= 1.0 {
986 1.0
987 } else if fraction <= 2.0 {
988 2.0
989 } else if fraction <= 5.0 {
990 5.0
991 } else {
992 10.0
993 };
994
995 nice_fraction * power
996}
997
998fn format_number(value: f64, step: f64) -> String {
999 if !value.is_finite() {
1000 return "0".to_string();
1001 }
1002 let abs_step = step.abs().max(f64::EPSILON);
1003 let precision = if abs_step >= 1.0 {
1004 0
1005 } else {
1006 (-abs_step.log10().floor() as i32 + 1).clamp(0, 6) as usize
1007 };
1008 format!("{value:.precision$}")
1009}
1010
1011fn build_legend_items(datasets: &[Dataset]) -> Vec<(char, String, Color)> {
1012 datasets
1013 .iter()
1014 .filter(|d| !d.name.is_empty())
1015 .map(|d| {
1016 let symbol = match d.graph_type {
1017 GraphType::Line => '─',
1018 GraphType::Scatter => marker_char(d.marker),
1019 GraphType::Bar => '█',
1020 GraphType::Area => '▄',
1021 };
1022 (symbol, d.name.clone(), d.color)
1023 })
1024 .collect()
1025}
1026
1027fn draw_area_dataset(
1028 dataset: &Dataset,
1029 x_min: f64,
1030 x_max: f64,
1031 y_min: f64,
1032 y_max: f64,
1033 plot_chars: &mut [Vec<char>],
1034 plot_styles: &mut [Vec<Style>],
1035) {
1036 if dataset.data.is_empty() || plot_chars.is_empty() || plot_chars[0].is_empty() {
1037 return;
1038 }
1039
1040 let rows = plot_chars.len();
1041 let cols = plot_chars[0].len();
1042 let zero_row = map_value_to_cell(0.0, y_min, y_max, rows, true);
1043
1044 let points: Vec<(usize, usize)> = dataset
1045 .data
1046 .iter()
1047 .filter(|(x, y)| x.is_finite() && y.is_finite())
1048 .map(|(x, y)| {
1049 (
1050 map_value_to_cell(*x, x_min, x_max, cols, false),
1051 map_value_to_cell(*y, y_min, y_max, rows, true),
1052 )
1053 })
1054 .collect();
1055
1056 if points.is_empty() {
1057 return;
1058 }
1059
1060 let mut line_rows: Vec<Option<usize>> = vec![None; cols];
1061 if points.len() == 1 {
1062 line_rows[points[0].0] = Some(points[0].1);
1063 } else {
1064 for pair in points.windows(2) {
1065 if let [a, b] = pair {
1066 let (x0, y0) = (a.0 as isize, a.1 as isize);
1067 let (x1, y1) = (b.0 as isize, b.1 as isize);
1068
1069 if x0 == x1 {
1070 let col = x0.max(0) as usize;
1071 if col < cols {
1072 let row = y0.min(y1).max(0) as usize;
1073 line_rows[col] = Some(row.min(rows.saturating_sub(1)));
1074 }
1075 continue;
1076 }
1077
1078 let (start_x, end_x) = if x0 <= x1 { (x0, x1) } else { (x1, x0) };
1079 for x in start_x..=end_x {
1080 let t = (x - x0) as f64 / (x1 - x0) as f64;
1081 let y = (y0 as f64 + (y1 - y0) as f64 * t).round() as isize;
1082 if x < 0 || y < 0 {
1083 continue;
1084 }
1085 let col = x as usize;
1086 let row = (y as usize).min(rows.saturating_sub(1));
1087 if col < cols {
1088 line_rows[col] = Some(row);
1089 }
1090 }
1091 }
1092 }
1093 }
1094
1095 let fill_style = Style::new().fg(dataset.color).dim();
1096 for (col, line_row) in line_rows.into_iter().enumerate() {
1097 if let Some(row) = line_row {
1098 let start = row.min(zero_row).saturating_add(1);
1099 let end = row.max(zero_row);
1100 for fill_row in start..=end.min(rows.saturating_sub(1)) {
1101 if plot_chars[fill_row][col] == ' ' || plot_chars[fill_row][col] == '·' {
1102 plot_chars[fill_row][col] = '▄';
1103 plot_styles[fill_row][col] = fill_style;
1104 }
1105 }
1106 }
1107 }
1108
1109 let mut line_dataset = dataset.clone();
1110 line_dataset.graph_type = GraphType::Line;
1111 draw_braille_dataset(
1112 &line_dataset,
1113 x_min,
1114 x_max,
1115 y_min,
1116 y_max,
1117 plot_chars,
1118 plot_styles,
1119 );
1120}
1121
1122fn marker_char(marker: Marker) -> char {
1123 match marker {
1124 Marker::Braille => '⣿',
1125 Marker::Dot => '•',
1126 Marker::Block => '█',
1127 Marker::HalfBlock => '▀',
1128 Marker::Cross => '×',
1129 Marker::Circle => '○',
1130 }
1131}
1132
1133struct GridSpec<'a> {
1134 x_ticks: &'a [f64],
1135 y_ticks: &'a [f64],
1136 x_min: f64,
1137 x_max: f64,
1138 y_min: f64,
1139 y_max: f64,
1140}
1141
1142fn apply_grid(
1143 config: &ChartConfig,
1144 grid: GridSpec<'_>,
1145 plot_chars: &mut [Vec<char>],
1146 plot_styles: &mut [Vec<Style>],
1147 axis_style: Style,
1148) {
1149 if !config.grid || plot_chars.is_empty() || plot_chars[0].is_empty() {
1150 return;
1151 }
1152 let h = plot_chars.len();
1153 let w = plot_chars[0].len();
1154
1155 for tick in grid.y_ticks {
1156 let row = map_value_to_cell(*tick, grid.y_min, grid.y_max, h, true);
1157 if row < h {
1158 for col in 0..w {
1159 if plot_chars[row][col] == ' ' {
1160 plot_chars[row][col] = '·';
1161 plot_styles[row][col] = axis_style;
1162 }
1163 }
1164 }
1165 }
1166
1167 for tick in grid.x_ticks {
1168 let col = map_value_to_cell(*tick, grid.x_min, grid.x_max, w, false);
1169 if col < w {
1170 for row in 0..h {
1171 if plot_chars[row][col] == ' ' {
1172 plot_chars[row][col] = '·';
1173 plot_styles[row][col] = axis_style;
1174 }
1175 }
1176 }
1177 }
1178}
1179
1180fn draw_braille_dataset(
1181 dataset: &Dataset,
1182 x_min: f64,
1183 x_max: f64,
1184 y_min: f64,
1185 y_max: f64,
1186 plot_chars: &mut [Vec<char>],
1187 plot_styles: &mut [Vec<Style>],
1188) {
1189 if dataset.data.is_empty() || plot_chars.is_empty() || plot_chars[0].is_empty() {
1190 return;
1191 }
1192
1193 let cols = plot_chars[0].len();
1194 let rows = plot_chars.len();
1195 let px_w = cols * 2;
1196 let px_h = rows * 4;
1197 let mut bits = vec![vec![0u32; cols]; rows];
1198
1199 let points = dataset
1200 .data
1201 .iter()
1202 .filter(|(x, y)| x.is_finite() && y.is_finite())
1203 .map(|(x, y)| {
1204 (
1205 map_value_to_cell(*x, x_min, x_max, px_w, false),
1206 map_value_to_cell(*y, y_min, y_max, px_h, true),
1207 )
1208 })
1209 .collect::<Vec<_>>();
1210
1211 if points.is_empty() {
1212 return;
1213 }
1214
1215 if matches!(dataset.graph_type, GraphType::Line) {
1216 for pair in points.windows(2) {
1217 if let [a, b] = pair {
1218 plot_bresenham(
1219 a.0 as isize,
1220 a.1 as isize,
1221 b.0 as isize,
1222 b.1 as isize,
1223 |x, y| {
1224 set_braille_dot(x as usize, y as usize, &mut bits, cols, rows);
1225 },
1226 );
1227 }
1228 }
1229 } else {
1230 for (x, y) in &points {
1231 set_braille_dot(*x, *y, &mut bits, cols, rows);
1232 }
1233 }
1234
1235 for row in 0..rows {
1236 for col in 0..cols {
1237 if bits[row][col] != 0 {
1238 let ch = char::from_u32(BRAILLE_BASE + bits[row][col]).unwrap_or(' ');
1239 plot_chars[row][col] = ch;
1240 plot_styles[row][col] = Style::new().fg(dataset.color);
1241 }
1242 }
1243 }
1244
1245 if !matches!(dataset.marker, Marker::Braille) {
1246 let m = marker_char(dataset.marker);
1247 for (x, y) in dataset
1248 .data
1249 .iter()
1250 .filter(|(x, y)| x.is_finite() && y.is_finite())
1251 {
1252 let col = map_value_to_cell(*x, x_min, x_max, cols, false);
1253 let row = map_value_to_cell(*y, y_min, y_max, rows, true);
1254 if row < rows && col < cols {
1255 plot_chars[row][col] = m;
1256 plot_styles[row][col] = Style::new().fg(dataset.color);
1257 }
1258 }
1259 }
1260}
1261
1262fn draw_bar_dataset(
1263 dataset: &Dataset,
1264 _x_min: f64,
1265 _x_max: f64,
1266 y_min: f64,
1267 y_max: f64,
1268 plot_chars: &mut [Vec<char>],
1269 plot_styles: &mut [Vec<Style>],
1270) {
1271 if dataset.data.is_empty() || plot_chars.is_empty() || plot_chars[0].is_empty() {
1272 return;
1273 }
1274
1275 let rows = plot_chars.len();
1276 let cols = plot_chars[0].len();
1277 let n = dataset.data.len();
1278 let slot_width = cols as f64 / n as f64;
1279 let zero_row = map_value_to_cell(0.0, y_min, y_max, rows, true);
1280
1281 for (index, (_, value)) in dataset.data.iter().enumerate() {
1282 if !value.is_finite() {
1283 continue;
1284 }
1285
1286 let start_f = index as f64 * slot_width;
1287 let bar_width_f = (slot_width * 0.75).max(1.0);
1288 let full_w = bar_width_f.floor() as usize;
1289 let frac_w = ((bar_width_f - full_w as f64) * 8.0).round() as usize;
1290
1291 let x_start = start_f.floor() as usize;
1292 let x_end = (x_start + full_w).min(cols.saturating_sub(1));
1293 let frac_col = (x_end + 1).min(cols.saturating_sub(1));
1294
1295 let value_row = map_value_to_cell(*value, y_min, y_max, rows, true);
1296 let (top, bottom) = if value_row <= zero_row {
1297 (value_row, zero_row)
1298 } else {
1299 (zero_row, value_row)
1300 };
1301
1302 for row in top..=bottom.min(rows.saturating_sub(1)) {
1303 for col in x_start..=x_end {
1304 if col < cols {
1305 plot_chars[row][col] = '█';
1306 plot_styles[row][col] = Style::new().fg(dataset.color);
1307 }
1308 }
1309 if frac_w > 0 && frac_col < cols {
1310 plot_chars[row][frac_col] = BLOCK_FRACTIONS[frac_w.min(8)];
1311 plot_styles[row][frac_col] = Style::new().fg(dataset.color);
1312 }
1313 }
1314 }
1315}
1316
1317fn overlay_legend_on_plot(
1318 position: LegendPosition,
1319 items: &[(char, String, Color)],
1320 plot_chars: &mut [Vec<char>],
1321 plot_styles: &mut [Vec<Style>],
1322 axis_style: Style,
1323) {
1324 if plot_chars.is_empty() || plot_chars[0].is_empty() || items.is_empty() {
1325 return;
1326 }
1327
1328 let rows = plot_chars.len();
1329 let cols = plot_chars[0].len();
1330 let start_row = match position {
1331 LegendPosition::TopLeft => 0,
1332 LegendPosition::BottomLeft => rows.saturating_sub(items.len()),
1333 _ => 0,
1334 };
1335
1336 for (i, (symbol, name, color)) in items.iter().enumerate() {
1337 let row = start_row + i;
1338 if row >= rows {
1339 break;
1340 }
1341 let legend_text = format!("{symbol} {name}");
1342 for (col, ch) in legend_text.chars().enumerate() {
1343 if col >= cols {
1344 break;
1345 }
1346 plot_chars[row][col] = ch;
1347 plot_styles[row][col] = if col == 0 {
1348 Style::new().fg(*color)
1349 } else {
1350 axis_style
1351 };
1352 }
1353 }
1354}
1355
1356fn build_y_tick_row_map(
1357 ticks: &[f64],
1358 y_min: f64,
1359 y_max: f64,
1360 plot_height: usize,
1361) -> Vec<(usize, String)> {
1362 let step = if ticks.len() > 1 {
1363 (ticks[1] - ticks[0]).abs()
1364 } else {
1365 1.0
1366 };
1367 ticks
1368 .iter()
1369 .map(|v| {
1370 (
1371 map_value_to_cell(*v, y_min, y_max, plot_height, true),
1372 format_number(*v, step),
1373 )
1374 })
1375 .collect()
1376}
1377
1378fn build_x_tick_col_map(
1379 ticks: &[f64],
1380 labels: Option<&[String]>,
1381 x_min: f64,
1382 x_max: f64,
1383 plot_width: usize,
1384) -> Vec<(usize, String)> {
1385 if let Some(labels) = labels {
1386 if labels.is_empty() {
1387 return Vec::new();
1388 }
1389 let denom = labels.len().saturating_sub(1).max(1);
1390 return labels
1391 .iter()
1392 .enumerate()
1393 .map(|(i, label)| {
1394 let col = (i * plot_width.saturating_sub(1)) / denom;
1395 (col, label.clone())
1396 })
1397 .collect();
1398 }
1399
1400 let step = if ticks.len() > 1 {
1401 (ticks[1] - ticks[0]).abs()
1402 } else {
1403 1.0
1404 };
1405 ticks
1406 .iter()
1407 .map(|v| {
1408 (
1409 map_value_to_cell(*v, x_min, x_max, plot_width, false),
1410 format_number(*v, step),
1411 )
1412 })
1413 .collect()
1414}
1415
1416fn map_value_to_cell(value: f64, min: f64, max: f64, size: usize, invert: bool) -> usize {
1417 if size == 0 {
1418 return 0;
1419 }
1420 let span = (max - min).abs().max(f64::EPSILON);
1421 let mut t = ((value - min) / span).clamp(0.0, 1.0);
1422 if invert {
1423 t = 1.0 - t;
1424 }
1425 (t * (size.saturating_sub(1)) as f64).round() as usize
1426}
1427
1428fn set_braille_dot(px: usize, py: usize, bits: &mut [Vec<u32>], cols: usize, rows: usize) {
1429 if cols == 0 || rows == 0 {
1430 return;
1431 }
1432 let char_col = px / 2;
1433 let char_row = py / 4;
1434 if char_col >= cols || char_row >= rows {
1435 return;
1436 }
1437 let sub_col = px % 2;
1438 let sub_row = py % 4;
1439 bits[char_row][char_col] |= if sub_col == 0 {
1440 BRAILLE_LEFT_BITS[sub_row]
1441 } else {
1442 BRAILLE_RIGHT_BITS[sub_row]
1443 };
1444}
1445
1446fn plot_bresenham(x0: isize, y0: isize, x1: isize, y1: isize, mut plot: impl FnMut(isize, isize)) {
1447 let mut x = x0;
1448 let mut y = y0;
1449 let dx = (x1 - x0).abs();
1450 let sx = if x0 < x1 { 1 } else { -1 };
1451 let dy = -(y1 - y0).abs();
1452 let sy = if y0 < y1 { 1 } else { -1 };
1453 let mut err = dx + dy;
1454
1455 loop {
1456 plot(x, y);
1457 if x == x1 && y == y1 {
1458 break;
1459 }
1460 let e2 = 2 * err;
1461 if e2 >= dy {
1462 err += dy;
1463 x += sx;
1464 }
1465 if e2 <= dx {
1466 err += dx;
1467 y += sy;
1468 }
1469 }
1470}
1471
1472fn center_text(text: &str, width: usize) -> String {
1473 let text_width = UnicodeWidthStr::width(text);
1474 if text_width >= width {
1475 return text.chars().take(width).collect();
1476 }
1477 let left = (width - text_width) / 2;
1478 let right = width - text_width - left;
1479 format!("{}{}{}", " ".repeat(left), text, " ".repeat(right))
1480}
1481
1482fn sturges_bin_count(n: usize) -> usize {
1483 if n <= 1 {
1484 return 1;
1485 }
1486 (1.0 + (n as f64).log2()).ceil() as usize
1487}