Skip to main content

slt/
chart.rs

1//! Data visualization: line charts, scatter plots, bar charts, and histograms.
2//!
3//! Build a chart with [`ChartBuilder`], then pass it to
4//! [`Context::chart`](crate::Context::chart). Histograms use
5//! [`Context::histogram`](crate::Context::histogram) directly.
6
7use 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
25/// Colored character range `(start, end, color)`.
26pub type ColorSpan = (usize, usize, Color);
27
28/// Rendered chart line with color ranges.
29pub type RenderedLine = (String, Vec<ColorSpan>);
30
31/// Marker type for data points.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Marker {
34    /// Braille marker (2x4 sub-cell dots).
35    Braille,
36    /// Dot marker.
37    Dot,
38    /// Full block marker.
39    Block,
40    /// Half block marker.
41    HalfBlock,
42    /// Cross marker.
43    Cross,
44    /// Circle marker.
45    Circle,
46}
47
48/// Graph rendering style.
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum GraphType {
51    /// Connected points.
52    Line,
53    /// Unconnected points.
54    Scatter,
55    /// Vertical bars from the x-axis baseline.
56    Bar,
57}
58
59/// Legend placement.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum LegendPosition {
62    /// Top-left corner.
63    TopLeft,
64    /// Top-right corner.
65    TopRight,
66    /// Bottom-left corner.
67    BottomLeft,
68    /// Bottom-right corner.
69    BottomRight,
70    /// Disable legend.
71    None,
72}
73
74/// Axis configuration.
75#[derive(Debug, Clone)]
76pub struct Axis {
77    /// Optional axis title.
78    pub title: Option<String>,
79    /// Manual axis bounds `(min, max)`. Uses auto-scaling when `None`.
80    pub bounds: Option<(f64, f64)>,
81    /// Optional manual tick labels.
82    pub labels: Option<Vec<String>>,
83    /// Axis text and tick style.
84    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/// Dataset for one chart series.
99#[derive(Debug, Clone)]
100pub struct Dataset {
101    /// Dataset label shown in legend.
102    pub name: String,
103    /// Data points as `(x, y)` pairs.
104    pub data: Vec<(f64, f64)>,
105    /// Series color.
106    pub color: Color,
107    /// Marker used for points.
108    pub marker: Marker,
109    /// Rendering mode for this dataset.
110    pub graph_type: GraphType,
111}
112
113/// Chart configuration.
114#[derive(Debug, Clone)]
115pub struct ChartConfig {
116    /// Optional chart title.
117    pub title: Option<String>,
118    /// X axis configuration.
119    pub x_axis: Axis,
120    /// Y axis configuration.
121    pub y_axis: Axis,
122    /// Chart datasets.
123    pub datasets: Vec<Dataset>,
124    /// Legend position.
125    pub legend: LegendPosition,
126    /// Whether to render grid lines.
127    pub grid: bool,
128    /// Total chart width in terminal cells.
129    pub width: u32,
130    /// Total chart height in terminal cells.
131    pub height: u32,
132}
133
134/// One row of styled chart output.
135#[derive(Debug, Clone)]
136pub(crate) struct ChartRow {
137    /// Styled text segments for this row.
138    pub segments: Vec<(String, Style)>,
139}
140
141/// Histogram configuration builder.
142#[derive(Debug, Clone)]
143#[must_use = "configure histogram before rendering"]
144pub struct HistogramBuilder {
145    /// Optional explicit bin count.
146    pub bins: Option<usize>,
147    /// Histogram bar color.
148    pub color: Color,
149    /// Optional x-axis title.
150    pub x_title: Option<String>,
151    /// Optional y-axis title.
152    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    /// Set explicit histogram bin count.
168    pub fn bins(&mut self, bins: usize) -> &mut Self {
169        self.bins = Some(bins.max(1));
170        self
171    }
172
173    /// Set histogram bar color.
174    pub fn color(&mut self, color: Color) -> &mut Self {
175        self.color = color;
176        self
177    }
178
179    /// Set x-axis title.
180    pub fn xlabel(&mut self, title: &str) -> &mut Self {
181        self.x_title = Some(title.to_string());
182        self
183    }
184
185    /// Set y-axis title.
186    pub fn ylabel(&mut self, title: &str) -> &mut Self {
187        self.y_title = Some(title.to_string());
188        self
189    }
190}
191
192/// Builder entry for one dataset in [`ChartBuilder`].
193#[derive(Debug, Clone)]
194pub struct DatasetEntry {
195    dataset: Dataset,
196    color_overridden: bool,
197}
198
199impl DatasetEntry {
200    /// Set dataset label for legend.
201    pub fn label(&mut self, name: &str) -> &mut Self {
202        self.dataset.name = name.to_string();
203        self
204    }
205
206    /// Set dataset color.
207    pub fn color(&mut self, color: Color) -> &mut Self {
208        self.dataset.color = color;
209        self.color_overridden = true;
210        self
211    }
212
213    /// Set marker style.
214    pub fn marker(&mut self, marker: Marker) -> &mut Self {
215        self.dataset.marker = marker;
216        self
217    }
218}
219
220/// Immediate-mode builder for charts.
221#[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    /// Create a chart builder with widget dimensions.
230    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    /// Set chart title.
253    pub fn title(&mut self, title: &str) -> &mut Self {
254        self.config.title = Some(title.to_string());
255        self
256    }
257
258    /// Set x-axis title.
259    pub fn xlabel(&mut self, label: &str) -> &mut Self {
260        self.config.x_axis.title = Some(label.to_string());
261        self
262    }
263
264    /// Set y-axis title.
265    pub fn ylabel(&mut self, label: &str) -> &mut Self {
266        self.config.y_axis.title = Some(label.to_string());
267        self
268    }
269
270    /// Set manual x-axis bounds.
271    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    /// Set manual y-axis bounds.
277    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    /// Enable or disable grid lines.
283    pub fn grid(&mut self, on: bool) -> &mut Self {
284        self.config.grid = on;
285        self
286    }
287
288    /// Set legend position.
289    pub fn legend(&mut self, position: LegendPosition) -> &mut Self {
290        self.config.legend = position;
291        self
292    }
293
294    /// Add a line dataset.
295    pub fn line(&mut self, data: &[(f64, f64)]) -> &mut DatasetEntry {
296        self.push_dataset(data, GraphType::Line, Marker::Braille)
297    }
298
299    /// Add a scatter dataset.
300    pub fn scatter(&mut self, data: &[(f64, f64)]) -> &mut DatasetEntry {
301        self.push_dataset(data, GraphType::Scatter, Marker::Braille)
302    }
303
304    /// Add a bar dataset.
305    pub fn bar(&mut self, data: &[(f64, f64)]) -> &mut DatasetEntry {
306        self.push_dataset(data, GraphType::Bar, Marker::Block)
307    }
308
309    /// Build the final chart config.
310    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/// Renderer that emits text rows with per-character color ranges.
343#[derive(Debug, Clone)]
344pub struct ChartRenderer {
345    config: ChartConfig,
346}
347
348impl ChartRenderer {
349    /// Create a renderer from a chart config.
350    pub fn new(config: ChartConfig) -> Self {
351        Self { config }
352    }
353
354    /// Render chart as lines plus color spans `(start, end, color)`.
355    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
378/// Build a histogram chart configuration from raw values.
379pub(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
478/// Render a chart into styled row segments.
479pub(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    // Row budget: title + top_frame + plot + axis_line + x_labels + [x_title] + bottom_frame
498    //           = title_rows + 1 + plot_height + 1 + 1 + x_title_rows + 1
499    //           = title_rows + plot_height + 3 + x_title_rows
500    // Solve for plot_height:
501    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    // --- Title row ---
645    if let Some(title) = &config.title {
646        rows.push(ChartRow {
647            segments: vec![(center_text(title, width), title_style)],
648        });
649    }
650
651    // --- Top frame ---
652    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    // --- X-axis line ---
734    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    // --- Bottom frame ---
802    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
906/// TUI-aware tick generation: picks a nice step whose interval count
907/// divides `cell_count - 1` as evenly as possible, with 3-8 intervals
908/// and at least 2 rows per interval for readable spacing.
909fn 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}