widgetry/widgets/
line_plot.rs

1use geom::{Angle, Circle, Distance, FindClosest, PolyLine, Pt2D, UnitFmt};
2
3use crate::widgets::plots::{make_legend, thick_lineseries, Axis, PlotOptions, Series};
4use crate::{
5    Color, Drawable, EdgeInsets, EventCtx, GeomBatch, GfxCtx, ScreenDims, ScreenPt,
6    ScreenRectangle, Text, TextExt, Widget, WidgetImpl, WidgetOutput,
7};
8
9pub struct LinePlot<X: Axis<X>, Y: Axis<Y>> {
10    draw: Drawable,
11
12    // The geometry here is in screen-space.
13    max_x: X,
14    max_y: Y,
15    closest: FindClosest<String>,
16
17    hovering: Option<Hovering<X, Y>>,
18
19    top_left: ScreenPt,
20    dims: ScreenDims,
21    unit_fmt: UnitFmt,
22}
23
24impl<X: Axis<X>, Y: Axis<Y>> LinePlot<X, Y> {
25    /// `label` is used to name the actual LinePlot widget. The result of this call can't be
26    /// usefully `named`, since the plot is wrapped in some containers for formatting.
27    pub fn new_widget(
28        ctx: &EventCtx,
29        label: &str,
30        mut series: Vec<Series<X, Y>>,
31        opts: PlotOptions<X, Y>,
32        unit_fmt: UnitFmt,
33    ) -> Widget {
34        let legend = make_legend(ctx, &series, &opts);
35        series.retain(|s| !opts.disabled.contains(&s.label));
36
37        // Assume min_x is X::zero() and min_y is Y::zero()
38        let max_x = opts.max_x.unwrap_or_else(|| {
39            series
40                .iter()
41                .map(|s| s.pts.iter().map(|(x, _)| *x).max().unwrap_or_else(X::zero))
42                .max()
43                .unwrap_or_else(X::zero)
44        });
45        let max_y = opts.max_y.unwrap_or_else(|| {
46            series
47                .iter()
48                .map(|s| {
49                    s.pts
50                        .iter()
51                        .map(|(_, value)| *value)
52                        .max()
53                        .unwrap_or_else(Y::zero)
54                })
55                .max()
56                .unwrap_or_else(Y::zero)
57        });
58
59        // TODO: somehow stretch to fill their container.
60        let default_dims = {
61            let width = 0.23 * ctx.canvas.window_width;
62            let height = 0.2 * ctx.canvas.window_height;
63            ScreenDims { width, height }
64        };
65
66        let dims = opts.dims.unwrap_or(default_dims);
67        let width = dims.width;
68        let height = dims.height;
69
70        let mut batch = GeomBatch::new();
71        // Grid lines for the Y scale. Draw up to 10 lines max to cover the order of magnitude of
72        // the range.
73        // TODO This caps correctly, but if the max is 105, then suddenly we just have 2 grid
74        // lines.
75        {
76            let order_of_mag = 10.0_f64.powf(max_y.to_f64().log10().ceil());
77            for i in 0..10 {
78                let y = max_y.from_f64(order_of_mag / 10.0 * (i as f64));
79                let pct = y.to_percent(max_y);
80                if pct > 1.0 {
81                    break;
82                }
83                batch.push(
84                    Color::hex("#7C7C7C"),
85                    PolyLine::must_new(vec![
86                        Pt2D::new(0.0, (1.0 - pct) * height),
87                        Pt2D::new(width, (1.0 - pct) * height),
88                    ])
89                    .make_polygons(Distance::meters(1.0)),
90                );
91            }
92        }
93        // X axis grid
94        if max_x != X::zero() {
95            let order_of_mag = 10.0_f64.powf(max_x.to_f64().log10().ceil());
96            for i in 0..10 {
97                let x = max_x.from_f64(order_of_mag / 10.0 * (i as f64));
98                let pct = x.to_percent(max_x);
99                if pct > 1.0 {
100                    break;
101                }
102                batch.push(
103                    Color::hex("#7C7C7C"),
104                    PolyLine::must_new(vec![
105                        Pt2D::new(pct * width, 0.0),
106                        Pt2D::new(pct * width, height),
107                    ])
108                    .make_polygons(Distance::meters(1.0)),
109                );
110            }
111        }
112
113        let mut closest = FindClosest::new();
114        for s in series {
115            if max_x == X::zero() {
116                continue;
117            }
118
119            let mut pts = Vec::new();
120            for (t, y) in s.pts {
121                let percent_x = t.to_percent(max_x);
122                let percent_y = y.to_percent(max_y);
123                pts.push(Pt2D::new(
124                    percent_x * width,
125                    // Y inversion! :D
126                    (1.0 - percent_y) * height,
127                ));
128            }
129            // Downsample to avoid creating polygons with a huge number of points. 1m is untuned,
130            // and here "meters" is really pixels.
131            pts = Pt2D::approx_dedupe(pts, Distance::meters(1.0));
132            if pts.len() >= 2 {
133                closest.add(s.label.clone(), &pts);
134                batch.push(s.color, thick_lineseries(pts, Distance::meters(5.0)));
135            }
136        }
137
138        let num_x_labels = 3;
139        let mut row = Vec::new();
140        for i in 0..num_x_labels {
141            let percent_x = (i as f64) / ((num_x_labels - 1) as f64);
142            let x = max_x.from_percent(percent_x);
143            // TODO Need ticks now to actually see where this goes
144            let batch = Text::from(x.prettyprint(&unit_fmt))
145                .render(ctx)
146                .rotate(Angle::degrees(-15.0))
147                .autocrop();
148            row.push(batch.into_widget(ctx));
149        }
150        let x_axis = Widget::custom_row(row)
151            .padding(EdgeInsets {
152                top: 10.0,
153                left: 60.0,
154                right: 10.0,
155                bottom: 10.0,
156            })
157            .evenly_spaced();
158
159        let num_y_labels = 3;
160        let mut col = Vec::new();
161        for i in 0..num_y_labels {
162            let percent_y = (i as f64) / ((num_y_labels - 1) as f64);
163            col.push(
164                max_y
165                    .from_percent(percent_y)
166                    .prettyprint(&unit_fmt)
167                    .text_widget(ctx),
168            );
169        }
170        col.reverse();
171        let y_axis = Widget::custom_col(col).padding(10).evenly_spaced();
172
173        let plot = LinePlot {
174            draw: ctx.upload(batch),
175            closest,
176            max_x,
177            max_y,
178            hovering: None,
179
180            top_left: ScreenPt::new(0.0, 0.0),
181            dims: ScreenDims::new(width, height),
182            unit_fmt,
183        };
184
185        // Don't let the x-axis fill the parent container
186        Widget::custom_col(vec![
187            legend.margin_below(10),
188            Widget::custom_row(vec![y_axis, Widget::new(Box::new(plot)).named(label)]),
189            x_axis,
190        ])
191        .container()
192    }
193
194    pub fn get_hovering(&self) -> Vec<(X, Y)> {
195        if let Some(ref h) = self.hovering {
196            h.hits.clone()
197        } else {
198            Vec::new()
199        }
200    }
201
202    /// Programmatically show a tooltip at the given x/y.
203    ///
204    /// Useful (e.g.) when points on the line plot corresponds to a point on the map - as the user
205    /// hovers on the map you can highlight the corresponding details on the plot.
206    pub fn set_hovering(&mut self, ctx: &mut EventCtx, label: &str, x: X, y: Y) {
207        // mimic the tooltip style
208        let txt = Text::from(crate::Line(format!(
209            "{}: at {}, {}",
210            label,
211            x.prettyprint(&self.unit_fmt),
212            y.prettyprint(&self.unit_fmt)
213        )))
214        .bg(Color::BLACK)
215        .change_fg(ctx.style().text_tooltip_color);
216
217        // Find this point in screen-space
218        let pt = Pt2D::new(
219            self.top_left.x + x.to_percent(self.max_x) * self.dims.width,
220            self.top_left.y + (1.0 - y.to_percent(self.max_y)) * self.dims.height,
221        );
222
223        self.hovering = Some(Hovering {
224            hits: Vec::new(),
225            tooltip: Text::new(),
226            draw_cursor: txt.render(ctx).centered_on(pt).upload(ctx),
227        });
228    }
229}
230
231impl<X: Axis<X>, Y: Axis<Y>> WidgetImpl for LinePlot<X, Y> {
232    fn get_dims(&self) -> ScreenDims {
233        self.dims
234    }
235
236    fn set_pos(&mut self, top_left: ScreenPt) {
237        self.top_left = top_left;
238    }
239
240    fn event(&mut self, ctx: &mut EventCtx, _: &mut WidgetOutput) {
241        if ctx.redo_mouseover() {
242            self.hovering = None;
243            if let Some(cursor) = ctx.canvas.get_cursor_in_screen_space() {
244                if ScreenRectangle::top_left(self.top_left, self.dims).contains(cursor) {
245                    let radius = Distance::meters(15.0);
246                    let mut txt = Text::new();
247                    let mut hits = Vec::new();
248                    for (label, pt, _) in self.closest.all_close_pts(
249                        Pt2D::new(cursor.x - self.top_left.x, cursor.y - self.top_left.y),
250                        radius,
251                    ) {
252                        // TODO If some/all of the matches have the same x, write it once?
253                        let x = self.max_x.from_percent(pt.x() / self.dims.width);
254                        let y_percent = 1.0 - (pt.y() / self.dims.height);
255                        let y = self.max_y.from_percent(y_percent);
256
257                        // TODO Draw this info in the ColorLegend
258                        txt.add_line(format!(
259                            "{}: at {}, {}",
260                            label,
261                            x.prettyprint(&self.unit_fmt),
262                            y.prettyprint(&self.unit_fmt)
263                        ));
264                        hits.push((x, y));
265                    }
266                    if !hits.is_empty() {
267                        self.hovering = Some(Hovering {
268                            hits,
269                            tooltip: txt,
270                            draw_cursor: GeomBatch::from(vec![(
271                                Color::RED,
272                                Circle::new(cursor.to_pt(), radius).to_polygon(),
273                            )])
274                            .upload(ctx),
275                        });
276                    }
277                }
278            }
279        }
280    }
281
282    fn draw(&self, g: &mut GfxCtx) {
283        g.redraw_at(self.top_left, &self.draw);
284
285        if let Some(ref hovering) = self.hovering {
286            g.fork_screenspace();
287            g.redraw(&hovering.draw_cursor);
288            g.draw_mouse_tooltip(hovering.tooltip.clone());
289            g.unfork();
290        }
291    }
292}
293
294struct Hovering<X: Axis<X>, Y: Axis<Y>> {
295    hits: Vec<(X, Y)>,
296    tooltip: Text,
297    draw_cursor: Drawable,
298}