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