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