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