Skip to main content

guise/chart/
line.rs

1//! `LineChart` — one or more line series with optional axis, legend, area
2//! fill, and hover value readouts.
3//!
4//! ```ignore
5//! use guise::chart::LineChart;
6//!
7//! LineChart::new([12.0, 18.0, 9.0, 24.0]).fill().height(180.0)
8//!
9//! LineChart::series("Revenue", [12.0, 18.0, 24.0])
10//!     .add_series("Costs", [8.0, 11.0, 13.0])
11//!     .axis()
12//!     .labels(["Q1", "Q2", "Q3"])
13//!     .hover()
14//! ```
15
16use gpui::prelude::*;
17use gpui::{
18  canvas, div, fill, point, px, size, App, Bounds, Hsla, IntoElement, SharedString, Window,
19};
20
21use crate::style::ColorValue;
22use crate::theme::theme;
23
24use super::axis::nice_ticks;
25use super::frame::{hover_slots, legend_row, x_label_row, y_axis_column};
26use super::{
27  min_max, normalize_between, paint_polyline, paint_polyline_ys, resolve_color, series_color,
28  tick_label,
29};
30use crate::devtools::Probed;
31
32/// How many horizontal gridlines an axis-free `LineChart` paints.
33const GRIDLINES: usize = 4;
34
35/// A line chart. One series (`new`) or several (`series`, chained); values
36/// are min/max normalized, or scaled to nice axis ticks with [`LineChart::axis`].
37#[derive(IntoElement)]
38pub struct LineChart {
39  series: Vec<(Option<SharedString>, Vec<f32>)>,
40  colors: Vec<ColorValue>,
41  stroke: f32,
42  fill: bool,
43  axis: bool,
44  hover: bool,
45  labels: Vec<SharedString>,
46  width: Option<f32>,
47  height: f32,
48}
49
50impl LineChart {
51  pub fn new(values: impl IntoIterator<Item = f32>) -> Self {
52    LineChart {
53      series: vec![(None, values.into_iter().collect())],
54      colors: Vec::new(),
55      stroke: 2.0,
56      fill: false,
57      axis: false,
58      hover: false,
59      labels: Vec::new(),
60      width: None,
61      height: 140.0,
62    }
63  }
64
65  /// Start a named multi-series chart; extend with
66  /// [`add_series`](Self::add_series). Named series show in the legend and
67  /// every series shares the y scale.
68  pub fn series(label: impl Into<SharedString>, values: impl IntoIterator<Item = f32>) -> Self {
69    let mut chart = LineChart::new(values);
70    chart.series[0].0 = Some(label.into());
71    chart
72  }
73
74  /// Add another named series.
75  pub fn add_series(
76    mut self,
77    label: impl Into<SharedString>,
78    values: impl IntoIterator<Item = f32>,
79  ) -> Self {
80    self
81      .series
82      .push((Some(label.into()), values.into_iter().collect()));
83    self
84  }
85
86  /// One color for every line (single-series) — defaults to theme primary.
87  pub fn color(mut self, color: impl Into<ColorValue>) -> Self {
88    self.colors = vec![color.into()];
89    self
90  }
91
92  /// Per-series colors, cycled when shorter than the series list.
93  pub fn colors(mut self, colors: impl IntoIterator<Item = impl Into<ColorValue>>) -> Self {
94    self.colors = colors.into_iter().map(Into::into).collect();
95    self
96  }
97
98  /// Stroke width in px (default 2).
99  pub fn stroke(mut self, width: f32) -> Self {
100    self.stroke = width.max(0.5);
101    self
102  }
103
104  /// Fill the area under each line (line color at 0.15 alpha).
105  pub fn fill(mut self) -> Self {
106    self.fill = true;
107    self
108  }
109
110  /// Show a y-axis: nice-number tick labels on the left, gridlines aligned
111  /// to them, and the lines scaled against the tick range.
112  pub fn axis(mut self) -> Self {
113    self.axis = true;
114    self
115  }
116
117  /// Show per-point values in a tooltip as the pointer moves across.
118  pub fn hover(mut self) -> Self {
119    self.hover = true;
120    self
121  }
122
123  /// Category labels under the plot, one per data point.
124  pub fn labels(mut self, labels: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
125    self.labels = labels.into_iter().map(Into::into).collect();
126    self
127  }
128
129  /// Fixed width in px. Defaults to the parent's full width.
130  pub fn width(mut self, width: f32) -> Self {
131    self.width = Some(width);
132    self
133  }
134
135  /// Plot height in px (default 140), excluding labels and legend.
136  pub fn height(mut self, height: f32) -> Self {
137    self.height = height;
138    self
139  }
140}
141
142impl RenderOnce for LineChart {
143  fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
144    let t = theme(cx);
145    let multi = self.series.len() > 1;
146    let line_colors: Vec<Hsla> = (0..self.series.len())
147      .map(|i| {
148        if !multi && self.colors.is_empty() {
149          t.primary().hsla()
150        } else if self.colors.len() == 1 && !multi {
151          resolve_color(t, self.colors[0])
152        } else {
153          series_color(t, &self.colors, i)
154        }
155      })
156      .collect();
157    let grid = t.border().alpha(0.5);
158    let stroke = self.stroke;
159    let filled = self.fill;
160
161    // Shared scale across every series.
162    let all: Vec<f32> = self
163      .series
164      .iter()
165      .flat_map(|(_, values)| values.iter().copied())
166      .collect();
167    let ticks = if self.axis {
168      let (lo, hi) = min_max(&all).unwrap_or((0.0, 1.0));
169      nice_ticks(lo, hi, 4)
170    } else {
171      Vec::new()
172    };
173    let scale = (!ticks.is_empty()).then(|| (*ticks.first().unwrap(), *ticks.last().unwrap()));
174
175    let point_count = self.series.iter().map(|(_, v)| v.len()).max().unwrap_or(0);
176    let gridline_count = if self.axis {
177      ticks.len().max(2)
178    } else {
179      GRIDLINES
180    };
181
182    let series = self.series.clone();
183    let paint_colors = line_colors.clone();
184    let plot = canvas(
185      |_, _, _| (),
186      move |bounds, _, window, _cx| {
187        let w = f32::from(bounds.size.width);
188        let h = f32::from(bounds.size.height);
189        if w <= 0.0 || h <= 0.0 {
190          return;
191        }
192        for i in 0..gridline_count {
193          let y = (h - 1.0) * (i as f32 / (gridline_count - 1) as f32);
194          window.paint_quad(fill(
195            Bounds::new(bounds.origin + point(px(0.0), px(y)), size(px(w), px(1.0))),
196            grid,
197          ));
198        }
199        for (i, (_, values)) in series.iter().enumerate() {
200          let color = paint_colors[i];
201          let area = filled.then_some(Hsla { a: 0.15, ..color });
202          match scale {
203            Some((lo, hi)) => paint_polyline_ys(
204              window,
205              bounds,
206              &normalize_between(values, lo, hi),
207              stroke,
208              color,
209              area,
210            ),
211            None => paint_polyline(window, bounds, values, stroke, color, area),
212          }
213        }
214      },
215    )
216    .w_full()
217    .h(px(self.height));
218
219    // Plot area, with optional hover readout slots layered above.
220    let mut plot_wrap = div().relative().flex_1().child(plot);
221    if self.hover && point_count > 0 {
222      let texts: Vec<SharedString> = (0..point_count)
223        .map(|i| {
224          let parts: Vec<String> = self
225            .series
226            .iter()
227            .map(|(label, values)| {
228              let value = values
229                .get(i)
230                .map(|v| tick_label(*v))
231                .unwrap_or_else(|| "–".into());
232              match label {
233                Some(name) => format!("{name}: {value}"),
234                None => value,
235              }
236            })
237            .collect();
238          let text = match self.labels.get(i) {
239            Some(cat) => format!("{cat} — {}", parts.join("  ")),
240            None => parts.join("  "),
241          };
242          text.into()
243        })
244        .collect();
245      plot_wrap = plot_wrap.child(hover_slots("guise-linechart-hover".into(), texts));
246    }
247
248    let mut body = div().flex().flex_row().w_full();
249    if self.axis {
250      body = body.child(y_axis_column(t, &ticks, self.height));
251    }
252    let mut plot_column = div()
253      .flex_1()
254      .flex()
255      .flex_col()
256      .gap(px(4.0))
257      .child(plot_wrap);
258    if !self.labels.is_empty() {
259      plot_column = plot_column.child(x_label_row(t, &self.labels));
260    }
261    body = body.child(plot_column);
262
263    let legend: Vec<(SharedString, Hsla)> = self
264      .series
265      .iter()
266      .enumerate()
267      .filter_map(|(i, (label, _))| label.clone().map(|l| (l, line_colors[i])))
268      .collect();
269
270    let mut root = div().flex().flex_col();
271    root = match self.width {
272      Some(w) => root.w(px(w)),
273      None => root.w_full(),
274    };
275    root = root.child(body);
276    if !legend.is_empty() {
277      root = root.child(legend_row(t, &legend));
278    }
279    root.probe("LineChart")
280  }
281}