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