Skip to main content

guise/chart/
scatter.rs

1//! `ScatterChart` — (x, y) points with axes and per-point hover readouts.
2//!
3//! ```ignore
4//! use guise::chart::ScatterChart;
5//!
6//! ScatterChart::series("Trial A", [(1.0, 3.2), (2.0, 4.1), (3.5, 2.8)])
7//!     .add_series("Trial B", [(1.5, 2.0), (2.5, 5.5)])
8//!     .hover()
9//! ```
10
11use gpui::prelude::*;
12use gpui::{
13  canvas, div, fill, point, px, relative, size, App, Bounds, Hsla, IntoElement, SharedString,
14  Window,
15};
16
17use crate::overlay::tooltip;
18use crate::style::ColorValue;
19use crate::theme::theme;
20
21use super::axis::nice_ticks;
22use super::frame::{legend_row, y_axis_column};
23use super::{min_max, series_color, tick_label};
24use crate::devtools::Probed;
25
26/// Side (px) of a painted point marker.
27const MARKER: f32 = 6.0;
28
29/// One named series of `(x, y)` points. The name is what the legend shows,
30/// and is absent for a chart with nothing to distinguish.
31type Series = (Option<SharedString>, Vec<(f32, f32)>);
32
33/// A scatter plot over `(x, y)` pairs, one or more series. Axes are always
34/// on (a scatter without a scale reads as noise).
35#[derive(IntoElement)]
36pub struct ScatterChart {
37  series: Vec<Series>,
38  colors: Vec<ColorValue>,
39  hover: bool,
40  width: Option<f32>,
41  height: f32,
42}
43
44impl ScatterChart {
45  pub fn new(points: impl IntoIterator<Item = (f32, f32)>) -> Self {
46    ScatterChart {
47      series: vec![(None, points.into_iter().collect())],
48      colors: Vec::new(),
49      hover: false,
50      width: None,
51      height: 180.0,
52    }
53  }
54
55  /// Start a named multi-series plot; extend with [`add_series`](Self::add_series).
56  pub fn series(
57    label: impl Into<SharedString>,
58    points: impl IntoIterator<Item = (f32, f32)>,
59  ) -> Self {
60    let mut chart = ScatterChart::new(points);
61    chart.series[0].0 = Some(label.into());
62    chart
63  }
64
65  /// Add another named series.
66  pub fn add_series(
67    mut self,
68    label: impl Into<SharedString>,
69    points: impl IntoIterator<Item = (f32, f32)>,
70  ) -> Self {
71    self
72      .series
73      .push((Some(label.into()), points.into_iter().collect()));
74    self
75  }
76
77  /// Per-series colors, cycled when shorter than the series list.
78  pub fn colors(mut self, colors: impl IntoIterator<Item = impl Into<ColorValue>>) -> Self {
79    self.colors = colors.into_iter().map(Into::into).collect();
80    self
81  }
82
83  /// Show each point's `(x, y)` in a tooltip on hover.
84  pub fn hover(mut self) -> Self {
85    self.hover = true;
86    self
87  }
88
89  /// Fixed width in px. Defaults to the parent's full width.
90  pub fn width(mut self, width: f32) -> Self {
91    self.width = Some(width);
92    self
93  }
94
95  /// Plot height in px (default 180), excluding the x-label row and legend.
96  pub fn height(mut self, height: f32) -> Self {
97    self.height = height;
98    self
99  }
100}
101
102impl RenderOnce for ScatterChart {
103  fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
104    let t = theme(cx);
105    let colors: Vec<Hsla> = (0..self.series.len())
106      .map(|i| series_color(t, &self.colors, i))
107      .collect();
108    let grid = t.border().alpha(0.5);
109    let dimmed = t.dimmed().hsla();
110    let font_xs = t.font_size(crate::theme::Size::Xs);
111
112    let xs: Vec<f32> = self
113      .series
114      .iter()
115      .flat_map(|(_, pts)| pts.iter().map(|p| p.0))
116      .collect();
117    let ys: Vec<f32> = self
118      .series
119      .iter()
120      .flat_map(|(_, pts)| pts.iter().map(|p| p.1))
121      .collect();
122    let (x_lo, x_hi) = min_max(&xs).unwrap_or((0.0, 1.0));
123    let (y_lo, y_hi) = min_max(&ys).unwrap_or((0.0, 1.0));
124    let x_ticks = nice_ticks(x_lo, x_hi, 5);
125    let y_ticks = nice_ticks(y_lo, y_hi, 4);
126    let (x_lo, x_hi) = (*x_ticks.first().unwrap(), *x_ticks.last().unwrap());
127    let (y_lo, y_hi) = (*y_ticks.first().unwrap(), *y_ticks.last().unwrap());
128
129    // Normalized 0..=1 position of a data point (y up).
130    let fx = move |x: f32| ((x - x_lo) / (x_hi - x_lo)).clamp(0.0, 1.0);
131    let fy = move |y: f32| ((y - y_lo) / (y_hi - y_lo)).clamp(0.0, 1.0);
132
133    let series = self.series.clone();
134    let paint_colors = colors.clone();
135    let x_grid = x_ticks.len().max(2);
136    let y_grid = y_ticks.len().max(2);
137    let plot = canvas(
138      |_, _, _| (),
139      move |bounds, _, window, _cx| {
140        let w = f32::from(bounds.size.width);
141        let h = f32::from(bounds.size.height);
142        if w <= 0.0 || h <= 0.0 {
143          return;
144        }
145        for i in 0..y_grid {
146          let y = (h - 1.0) * (i as f32 / (y_grid - 1) as f32);
147          window.paint_quad(fill(
148            Bounds::new(bounds.origin + point(px(0.0), px(y)), size(px(w), px(1.0))),
149            grid,
150          ));
151        }
152        for i in 0..x_grid {
153          let x = (w - 1.0) * (i as f32 / (x_grid - 1) as f32);
154          window.paint_quad(fill(
155            Bounds::new(bounds.origin + point(px(x), px(0.0)), size(px(1.0), px(h))),
156            grid,
157          ));
158        }
159        for (s, (_, pts)) in series.iter().enumerate() {
160          for &(x, y) in pts {
161            if !x.is_finite() || !y.is_finite() {
162              continue;
163            }
164            let cx0 = w * fx(x) - MARKER / 2.0;
165            let cy0 = h * (1.0 - fy(y)) - MARKER / 2.0;
166            window.paint_quad(
167              fill(
168                Bounds::new(
169                  bounds.origin + point(px(cx0), px(cy0)),
170                  size(px(MARKER), px(MARKER)),
171                ),
172                paint_colors[s],
173              )
174              .corner_radii(px(MARKER / 2.0)),
175            );
176          }
177        }
178      },
179    )
180    .w_full()
181    .h(px(self.height));
182
183    // Hover targets: small absolutely-positioned cells over each point.
184    let mut plot_wrap = div().relative().flex_1().child(plot);
185    if self.hover {
186      let mut n = 0usize;
187      for (label, pts) in &self.series {
188        for &(x, y) in pts {
189          if !x.is_finite() || !y.is_finite() {
190            continue;
191          }
192          let text = match label {
193            Some(name) => format!("{name}: ({}, {})", tick_label(x), tick_label(y)),
194            None => format!("({}, {})", tick_label(x), tick_label(y)),
195          };
196          plot_wrap = plot_wrap.child(
197            div()
198              .id(("guise-scatter-pt", n))
199              .absolute()
200              .left(relative(fx(x)))
201              .top(relative(1.0 - fy(y)))
202              .ml(px(-8.0))
203              .mt(px(-8.0))
204              .w(px(16.0))
205              .h(px(16.0))
206              .tooltip(tooltip(text)),
207          );
208          n += 1;
209        }
210      }
211    }
212
213    let x_labels = div()
214      .flex()
215      .justify_between()
216      .w_full()
217      .children(x_ticks.iter().map(|tick| {
218        div()
219          .text_size(px(font_xs))
220          .text_color(dimmed)
221          .child(SharedString::from(tick_label(*tick)))
222      }));
223
224    let legend: Vec<(SharedString, Hsla)> = self
225      .series
226      .iter()
227      .enumerate()
228      .filter_map(|(i, (label, _))| label.clone().map(|l| (l, colors[i])))
229      .collect();
230
231    let body = div()
232      .flex()
233      .flex_row()
234      .w_full()
235      .child(y_axis_column(t, &y_ticks, self.height))
236      .child(
237        div()
238          .flex_1()
239          .flex()
240          .flex_col()
241          .gap(px(4.0))
242          .child(plot_wrap)
243          .child(x_labels),
244      );
245
246    let mut root = div().flex().flex_col();
247    root = match self.width {
248      Some(w) => root.w(px(w)),
249      None => root.w_full(),
250    };
251    root = root.child(body);
252    if !legend.is_empty() {
253      root = root.child(legend_row(t, &legend));
254    }
255    root.probe("ScatterChart")
256  }
257}