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