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