Skip to main content

slt/
chart.rs

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
19/// Colored character range `(start, end, color)`.
20pub type ColorSpan = (usize, usize, Color);
21
22/// Rendered chart line with color ranges.
23pub type RenderedLine = (String, Vec<ColorSpan>);
24
25/// Marker type for data points.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum Marker {
28    /// Braille marker (2x4 sub-cell dots).
29    Braille,
30    /// Dot marker.
31    Dot,
32    /// Full block marker.
33    Block,
34    /// Half block marker.
35    HalfBlock,
36    /// Cross marker.
37    Cross,
38    /// Circle marker.
39    Circle,
40}
41
42/// Graph rendering style.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum GraphType {
45    /// Connected points.
46    Line,
47    /// Unconnected points.
48    Scatter,
49    /// Vertical bars from the x-axis baseline.
50    Bar,
51}
52
53/// Legend placement.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum LegendPosition {
56    /// Top-left corner.
57    TopLeft,
58    /// Top-right corner.
59    TopRight,
60    /// Bottom-left corner.
61    BottomLeft,
62    /// Bottom-right corner.
63    BottomRight,
64    /// Disable legend.
65    None,
66}
67
68/// Axis configuration.
69#[derive(Debug, Clone)]
70pub struct Axis {
71    /// Optional axis title.
72    pub title: Option<String>,
73    /// Manual axis bounds `(min, max)`. Uses auto-scaling when `None`.
74    pub bounds: Option<(f64, f64)>,
75    /// Optional manual tick labels.
76    pub labels: Option<Vec<String>>,
77    /// Axis text and tick style.
78    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/// Dataset for one chart series.
93#[derive(Debug, Clone)]
94pub struct Dataset {
95    /// Dataset label shown in legend.
96    pub name: String,
97    /// Data points as `(x, y)` pairs.
98    pub data: Vec<(f64, f64)>,
99    /// Series color.
100    pub color: Color,
101    /// Marker used for points.
102    pub marker: Marker,
103    /// Rendering mode for this dataset.
104    pub graph_type: GraphType,
105}
106
107/// Chart configuration.
108#[derive(Debug, Clone)]
109pub struct ChartConfig {
110    /// Optional chart title.
111    pub title: Option<String>,
112    /// X axis configuration.
113    pub x_axis: Axis,
114    /// Y axis configuration.
115    pub y_axis: Axis,
116    /// Chart datasets.
117    pub datasets: Vec<Dataset>,
118    /// Legend position.
119    pub legend: LegendPosition,
120    /// Whether to render grid lines.
121    pub grid: bool,
122    /// Total chart width in terminal cells.
123    pub width: u32,
124    /// Total chart height in terminal cells.
125    pub height: u32,
126}
127
128/// One row of styled chart output.
129#[derive(Debug, Clone)]
130pub(crate) struct ChartRow {
131    /// Styled text segments for this row.
132    pub segments: Vec<(String, Style)>,
133}
134
135/// Histogram configuration builder.
136#[derive(Debug, Clone)]
137#[must_use = "configure histogram before rendering"]
138pub struct HistogramBuilder {
139    /// Optional explicit bin count.
140    pub bins: Option<usize>,
141    /// Histogram bar color.
142    pub color: Color,
143    /// Optional x-axis title.
144    pub x_title: Option<String>,
145    /// Optional y-axis title.
146    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    /// Set explicit histogram bin count.
162    pub fn bins(&mut self, bins: usize) -> &mut Self {
163        self.bins = Some(bins.max(1));
164        self
165    }
166
167    /// Set histogram bar color.
168    pub fn color(&mut self, color: Color) -> &mut Self {
169        self.color = color;
170        self
171    }
172
173    /// Set x-axis title.
174    pub fn xlabel(&mut self, title: &str) -> &mut Self {
175        self.x_title = Some(title.to_string());
176        self
177    }
178
179    /// Set y-axis title.
180    pub fn ylabel(&mut self, title: &str) -> &mut Self {
181        self.y_title = Some(title.to_string());
182        self
183    }
184}
185
186/// Builder entry for one dataset in [`ChartBuilder`].
187#[derive(Debug, Clone)]
188pub struct DatasetEntry {
189    dataset: Dataset,
190    color_overridden: bool,
191}
192
193impl DatasetEntry {
194    /// Set dataset label for legend.
195    pub fn label(&mut self, name: &str) -> &mut Self {
196        self.dataset.name = name.to_string();
197        self
198    }
199
200    /// Set dataset color.
201    pub fn color(&mut self, color: Color) -> &mut Self {
202        self.dataset.color = color;
203        self.color_overridden = true;
204        self
205    }
206
207    /// Set marker style.
208    pub fn marker(&mut self, marker: Marker) -> &mut Self {
209        self.dataset.marker = marker;
210        self
211    }
212}
213
214/// Immediate-mode builder for charts.
215#[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    /// Create a chart builder with widget dimensions.
224    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    /// Set chart title.
247    pub fn title(&mut self, title: &str) -> &mut Self {
248        self.config.title = Some(title.to_string());
249        self
250    }
251
252    /// Set x-axis title.
253    pub fn xlabel(&mut self, label: &str) -> &mut Self {
254        self.config.x_axis.title = Some(label.to_string());
255        self
256    }
257
258    /// Set y-axis title.
259    pub fn ylabel(&mut self, label: &str) -> &mut Self {
260        self.config.y_axis.title = Some(label.to_string());
261        self
262    }
263
264    /// Set manual x-axis bounds.
265    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    /// Set manual y-axis bounds.
271    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    /// Enable or disable grid lines.
277    pub fn grid(&mut self, on: bool) -> &mut Self {
278        self.config.grid = on;
279        self
280    }
281
282    /// Set legend position.
283    pub fn legend(&mut self, position: LegendPosition) -> &mut Self {
284        self.config.legend = position;
285        self
286    }
287
288    /// Add a line dataset.
289    pub fn line(&mut self, data: &[(f64, f64)]) -> &mut DatasetEntry {
290        self.push_dataset(data, GraphType::Line, Marker::Braille)
291    }
292
293    /// Add a scatter dataset.
294    pub fn scatter(&mut self, data: &[(f64, f64)]) -> &mut DatasetEntry {
295        self.push_dataset(data, GraphType::Scatter, Marker::Braille)
296    }
297
298    /// Add a bar dataset.
299    pub fn bar(&mut self, data: &[(f64, f64)]) -> &mut DatasetEntry {
300        self.push_dataset(data, GraphType::Bar, Marker::Block)
301    }
302
303    /// Build the final chart config.
304    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/// Renderer that emits text rows with per-character color ranges.
337#[derive(Debug, Clone)]
338pub struct ChartRenderer {
339    config: ChartConfig,
340}
341
342impl ChartRenderer {
343    /// Create a renderer from a chart config.
344    pub fn new(config: ChartConfig) -> Self {
345        Self { config }
346    }
347
348    /// Render chart as lines plus color spans `(start, end, color)`.
349    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
372/// Build a histogram chart configuration from raw values.
373pub(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
472/// Render a chart into styled row segments.
473pub(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    // Row budget: title + top_frame + plot + axis_line + x_labels + [x_title] + bottom_frame
492    //           = title_rows + 1 + plot_height + 1 + 1 + x_title_rows + 1
493    //           = title_rows + plot_height + 3 + x_title_rows
494    // Solve for plot_height:
495    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    // --- Title row ---
639    if let Some(title) = &config.title {
640        rows.push(ChartRow {
641            segments: vec![(center_text(title, width), title_style)],
642        });
643    }
644
645    // --- Top frame ---
646    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    // --- X-axis line ---
728    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    // --- Bottom frame ---
796    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
900/// TUI-aware tick generation: picks a nice step whose interval count
901/// divides `cell_count - 1` as evenly as possible, with 3-8 intervals
902/// and at least 2 rows per interval for readable spacing.
903fn 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}