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