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};
8
9mod axis;
10mod bar;
11mod braille;
12mod grid;
13mod render;
14
15pub(crate) use bar::build_histogram_config;
16pub(crate) use render::render_chart;
17
18use axis::{build_tui_ticks, format_number, resolve_bounds, TickSpec};
19use bar::draw_bar_dataset;
20use braille::draw_braille_dataset;
21use grid::{
22    apply_grid, build_legend_items, build_x_tick_col_map, build_y_tick_row_map, center_text,
23    map_value_to_cell, marker_char, overlay_legend_on_plot, sturges_bin_count, GridSpec,
24};
25
26const BRAILLE_BASE: u32 = 0x2800;
27const BRAILLE_LEFT_BITS: [u32; 4] = [0x01, 0x02, 0x04, 0x40];
28const BRAILLE_RIGHT_BITS: [u32; 4] = [0x08, 0x10, 0x20, 0x80];
29const PALETTE: [Color; 8] = [
30    Color::Cyan,
31    Color::Yellow,
32    Color::Green,
33    Color::Magenta,
34    Color::Red,
35    Color::Blue,
36    Color::White,
37    Color::Indexed(208),
38];
39const BLOCK_FRACTIONS: [char; 9] = [' ', '▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'];
40
41/// Colored character range `(start, end, color)`.
42pub type ColorSpan = (usize, usize, Color);
43
44/// Rendered chart line with color ranges.
45pub type RenderedLine = (String, Vec<ColorSpan>);
46
47/// Marker type for data points.
48#[non_exhaustive]
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum Marker {
51    /// Braille marker (2x4 sub-cell dots).
52    Braille,
53    /// Dot marker.
54    Dot,
55    /// Full block marker.
56    Block,
57    /// Half block marker.
58    HalfBlock,
59    /// Cross marker.
60    Cross,
61    /// Circle marker.
62    Circle,
63}
64
65/// Graph rendering style.
66#[non_exhaustive]
67#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub enum GraphType {
69    /// Connected points.
70    Line,
71    /// Connected points with filled area to baseline.
72    Area,
73    /// Unconnected points.
74    Scatter,
75    /// Vertical bars from the x-axis baseline.
76    Bar,
77}
78
79/// Legend placement.
80#[non_exhaustive]
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
82pub enum LegendPosition {
83    /// Top-left corner.
84    TopLeft,
85    /// Top-right corner.
86    TopRight,
87    /// Bottom-left corner.
88    BottomLeft,
89    /// Bottom-right corner.
90    BottomRight,
91    /// Disable legend.
92    None,
93}
94
95/// Axis configuration.
96#[derive(Debug, Clone)]
97pub struct Axis {
98    /// Optional axis title.
99    pub title: Option<String>,
100    /// Manual axis bounds `(min, max)`. Uses auto-scaling when `None`.
101    pub bounds: Option<(f64, f64)>,
102    /// Optional manual tick labels.
103    pub labels: Option<Vec<String>>,
104    /// Optional manual tick positions.
105    pub ticks: Option<Vec<f64>>,
106    /// Optional axis title style override.
107    pub title_style: Option<Style>,
108    /// Axis text and tick style.
109    pub style: Style,
110}
111
112impl Default for Axis {
113    fn default() -> Self {
114        Self {
115            title: None,
116            bounds: None,
117            labels: None,
118            ticks: None,
119            title_style: None,
120            style: Style::new(),
121        }
122    }
123}
124
125/// Dataset for one chart series.
126#[derive(Debug, Clone)]
127pub struct Dataset {
128    /// Dataset label shown in legend.
129    pub name: String,
130    /// Data points as `(x, y)` pairs.
131    pub data: Vec<(f64, f64)>,
132    /// Series color.
133    pub color: Color,
134    /// Marker used for points.
135    pub marker: Marker,
136    /// Rendering mode for this dataset.
137    pub graph_type: GraphType,
138    /// Upward segment color override.
139    pub up_color: Option<Color>,
140    /// Downward (or flat) segment color override.
141    pub down_color: Option<Color>,
142}
143
144/// OHLC candle datum.
145#[derive(Debug, Clone, Copy)]
146pub struct Candle {
147    /// Open price.
148    pub open: f64,
149    /// High price.
150    pub high: f64,
151    /// Low price.
152    pub low: f64,
153    /// Close price.
154    pub close: f64,
155}
156
157/// Chart configuration.
158#[derive(Debug, Clone)]
159pub struct ChartConfig {
160    /// Optional chart title.
161    pub title: Option<String>,
162    /// Optional chart title style override.
163    pub title_style: Option<Style>,
164    /// X axis configuration.
165    pub x_axis: Axis,
166    /// Y axis configuration.
167    pub y_axis: Axis,
168    /// Chart datasets.
169    pub datasets: Vec<Dataset>,
170    /// Legend position.
171    pub legend: LegendPosition,
172    /// Whether to render grid lines.
173    pub grid: bool,
174    /// Optional grid line style override.
175    pub grid_style: Option<Style>,
176    /// Horizontal reference lines as `(y, style)`.
177    pub hlines: Vec<(f64, Style)>,
178    /// Vertical reference lines as `(x, style)`.
179    pub vlines: Vec<(f64, Style)>,
180    /// Whether to render the outer frame.
181    pub frame_visible: bool,
182    /// Whether to render x-axis line/labels/title rows.
183    pub x_axis_visible: bool,
184    /// Whether to render y-axis labels/divider column.
185    pub y_axis_visible: bool,
186    /// Total chart width in terminal cells.
187    pub width: u32,
188    /// Total chart height in terminal cells.
189    pub height: u32,
190}
191
192/// One row of styled chart output.
193#[derive(Debug, Clone)]
194pub(crate) struct ChartRow {
195    /// Styled text segments for this row.
196    pub segments: Vec<(String, Style)>,
197}
198
199/// Histogram configuration builder.
200#[derive(Debug, Clone)]
201#[must_use = "configure histogram before rendering"]
202pub struct HistogramBuilder {
203    /// Optional explicit bin count.
204    pub bins: Option<usize>,
205    /// Histogram bar color.
206    pub color: Color,
207    /// Optional x-axis title.
208    pub x_title: Option<String>,
209    /// Optional y-axis title.
210    pub y_title: Option<String>,
211}
212
213impl Default for HistogramBuilder {
214    fn default() -> Self {
215        Self {
216            bins: None,
217            color: Color::Cyan,
218            x_title: None,
219            y_title: None,
220        }
221    }
222}
223
224impl HistogramBuilder {
225    /// Set explicit histogram bin count.
226    pub fn bins(&mut self, bins: usize) -> &mut Self {
227        self.bins = Some(bins.max(1));
228        self
229    }
230
231    /// Set histogram bar color.
232    pub fn color(&mut self, color: Color) -> &mut Self {
233        self.color = color;
234        self
235    }
236
237    /// Set x-axis title.
238    pub fn xlabel(&mut self, title: &str) -> &mut Self {
239        self.x_title = Some(title.to_string());
240        self
241    }
242
243    /// Set y-axis title.
244    pub fn ylabel(&mut self, title: &str) -> &mut Self {
245        self.y_title = Some(title.to_string());
246        self
247    }
248}
249
250/// Builder entry for one dataset in [`ChartBuilder`].
251#[derive(Debug, Clone)]
252pub struct DatasetEntry {
253    dataset: Dataset,
254    color_overridden: bool,
255}
256
257impl DatasetEntry {
258    /// Set dataset label for legend.
259    pub fn label(&mut self, name: &str) -> &mut Self {
260        self.dataset.name = name.to_string();
261        self
262    }
263
264    /// Set dataset color.
265    pub fn color(&mut self, color: Color) -> &mut Self {
266        self.dataset.color = color;
267        self.color_overridden = true;
268        self
269    }
270
271    /// Set marker style.
272    pub fn marker(&mut self, marker: Marker) -> &mut Self {
273        self.dataset.marker = marker;
274        self
275    }
276
277    /// Color line/area segments by direction.
278    pub fn color_by_direction(&mut self, up: Color, down: Color) -> &mut Self {
279        self.dataset.up_color = Some(up);
280        self.dataset.down_color = Some(down);
281        self
282    }
283}
284
285/// Immediate-mode builder for charts.
286#[derive(Debug, Clone)]
287#[must_use = "configure chart before rendering"]
288pub struct ChartBuilder {
289    config: ChartConfig,
290    entries: Vec<DatasetEntry>,
291}
292
293impl ChartBuilder {
294    /// Create a chart builder with widget dimensions.
295    pub fn new(width: u32, height: u32, x_style: Style, y_style: Style) -> Self {
296        Self {
297            config: ChartConfig {
298                title: None,
299                title_style: None,
300                x_axis: Axis {
301                    style: x_style,
302                    ..Axis::default()
303                },
304                y_axis: Axis {
305                    style: y_style,
306                    ..Axis::default()
307                },
308                datasets: Vec::new(),
309                legend: LegendPosition::TopRight,
310                grid: true,
311                grid_style: None,
312                hlines: Vec::new(),
313                vlines: Vec::new(),
314                frame_visible: false,
315                x_axis_visible: true,
316                y_axis_visible: true,
317                width,
318                height,
319            },
320            entries: Vec::new(),
321        }
322    }
323
324    /// Set chart title.
325    pub fn title(&mut self, title: &str) -> &mut Self {
326        self.config.title = Some(title.to_string());
327        self
328    }
329
330    /// Set x-axis title.
331    pub fn xlabel(&mut self, label: &str) -> &mut Self {
332        self.config.x_axis.title = Some(label.to_string());
333        self
334    }
335
336    /// Set y-axis title.
337    pub fn ylabel(&mut self, label: &str) -> &mut Self {
338        self.config.y_axis.title = Some(label.to_string());
339        self
340    }
341
342    /// Set manual x-axis bounds.
343    pub fn xlim(&mut self, min: f64, max: f64) -> &mut Self {
344        self.config.x_axis.bounds = Some((min, max));
345        self
346    }
347
348    /// Set manual y-axis bounds.
349    pub fn ylim(&mut self, min: f64, max: f64) -> &mut Self {
350        self.config.y_axis.bounds = Some((min, max));
351        self
352    }
353
354    /// Set manual x-axis tick positions.
355    pub fn xticks(&mut self, values: &[f64]) -> &mut Self {
356        self.config.x_axis.ticks = Some(values.to_vec());
357        self
358    }
359
360    /// Set manual y-axis tick positions.
361    pub fn yticks(&mut self, values: &[f64]) -> &mut Self {
362        self.config.y_axis.ticks = Some(values.to_vec());
363        self
364    }
365
366    /// Set manual x-axis ticks and labels.
367    pub fn xtick_labels(&mut self, values: &[f64], labels: &[&str]) -> &mut Self {
368        self.config.x_axis.ticks = Some(values.to_vec());
369        self.config.x_axis.labels = Some(labels.iter().map(|label| (*label).to_string()).collect());
370        self
371    }
372
373    /// Set manual y-axis ticks and labels.
374    pub fn ytick_labels(&mut self, values: &[f64], labels: &[&str]) -> &mut Self {
375        self.config.y_axis.ticks = Some(values.to_vec());
376        self.config.y_axis.labels = Some(labels.iter().map(|label| (*label).to_string()).collect());
377        self
378    }
379
380    /// Set chart title style.
381    pub fn title_style(&mut self, style: Style) -> &mut Self {
382        self.config.title_style = Some(style);
383        self
384    }
385
386    /// Set grid line style.
387    pub fn grid_style(&mut self, style: Style) -> &mut Self {
388        self.config.grid_style = Some(style);
389        self
390    }
391
392    /// Set x-axis style.
393    pub fn x_axis_style(&mut self, style: Style) -> &mut Self {
394        self.config.x_axis.style = style;
395        self
396    }
397
398    /// Set y-axis style.
399    pub fn y_axis_style(&mut self, style: Style) -> &mut Self {
400        self.config.y_axis.style = style;
401        self
402    }
403
404    /// Add a horizontal reference line.
405    pub fn axhline(&mut self, y: f64, style: Style) -> &mut Self {
406        self.config.hlines.push((y, style));
407        self
408    }
409
410    /// Add a vertical reference line.
411    pub fn axvline(&mut self, x: f64, style: Style) -> &mut Self {
412        self.config.vlines.push((x, style));
413        self
414    }
415
416    /// Enable or disable grid lines.
417    pub fn grid(&mut self, on: bool) -> &mut Self {
418        self.config.grid = on;
419        self
420    }
421
422    /// Enable or disable chart frame.
423    pub fn frame(&mut self, on: bool) -> &mut Self {
424        self.config.frame_visible = on;
425        self
426    }
427
428    /// Enable or disable x-axis line/labels/title rows.
429    pub fn x_axis_visible(&mut self, on: bool) -> &mut Self {
430        self.config.x_axis_visible = on;
431        self
432    }
433
434    /// Enable or disable y-axis labels and divider.
435    pub fn y_axis_visible(&mut self, on: bool) -> &mut Self {
436        self.config.y_axis_visible = on;
437        self
438    }
439
440    /// Set legend position.
441    pub fn legend(&mut self, position: LegendPosition) -> &mut Self {
442        self.config.legend = position;
443        self
444    }
445
446    /// Add a line dataset.
447    pub fn line(&mut self, data: &[(f64, f64)]) -> &mut DatasetEntry {
448        self.push_dataset(data, GraphType::Line, Marker::Braille)
449    }
450
451    /// Add an area dataset.
452    pub fn area(&mut self, data: &[(f64, f64)]) -> &mut DatasetEntry {
453        self.push_dataset(data, GraphType::Area, Marker::Braille)
454    }
455
456    /// Add a scatter dataset.
457    pub fn scatter(&mut self, data: &[(f64, f64)]) -> &mut DatasetEntry {
458        self.push_dataset(data, GraphType::Scatter, Marker::Braille)
459    }
460
461    /// Add a bar dataset.
462    pub fn bar(&mut self, data: &[(f64, f64)]) -> &mut DatasetEntry {
463        self.push_dataset(data, GraphType::Bar, Marker::Block)
464    }
465
466    /// Build the final chart config.
467    pub fn build(mut self) -> ChartConfig {
468        for (index, mut entry) in self.entries.drain(..).enumerate() {
469            if !entry.color_overridden {
470                entry.dataset.color = PALETTE[index % PALETTE.len()];
471            }
472            self.config.datasets.push(entry.dataset);
473        }
474        self.config
475    }
476
477    fn push_dataset(
478        &mut self,
479        data: &[(f64, f64)],
480        graph_type: GraphType,
481        marker: Marker,
482    ) -> &mut DatasetEntry {
483        let series_name = format!("Series {}", self.entries.len() + 1);
484        self.entries.push(DatasetEntry {
485            dataset: Dataset {
486                name: series_name,
487                data: data.to_vec(),
488                color: Color::Reset,
489                marker,
490                graph_type,
491                up_color: None,
492                down_color: None,
493            },
494            color_overridden: false,
495        });
496        let last_index = self.entries.len().saturating_sub(1);
497        &mut self.entries[last_index]
498    }
499}
500
501/// Renderer that emits text rows with per-character color ranges.
502#[derive(Debug, Clone)]
503pub struct ChartRenderer {
504    config: ChartConfig,
505}
506
507impl ChartRenderer {
508    /// Create a renderer from a chart config.
509    pub fn new(config: ChartConfig) -> Self {
510        Self { config }
511    }
512
513    /// Render chart as lines plus color spans `(start, end, color)`.
514    pub fn render(&self) -> Vec<RenderedLine> {
515        let rows = render_chart(&self.config);
516        rows.into_iter()
517            .map(|row| {
518                let mut line = String::new();
519                let mut spans: Vec<(usize, usize, Color)> = Vec::new();
520                let mut cursor = 0usize;
521
522                for (segment, style) in row.segments {
523                    let width = unicode_width::UnicodeWidthStr::width(segment.as_str());
524                    line.push_str(&segment);
525                    if let Some(color) = style.fg {
526                        spans.push((cursor, cursor + width, color));
527                    }
528                    cursor += width;
529                }
530
531                (line, spans)
532            })
533            .collect()
534    }
535}