widgetry/widgets/
scatter_plot.rs

1use geom::{Angle, Circle, Distance, Duration, PolyLine, Pt2D, Time, UnitFmt};
2
3use crate::widgets::plots::{make_legend, Axis, PlotOptions, Series};
4use crate::{
5    Color, Drawable, EventCtx, GeomBatch, GfxCtx, ScreenDims, ScreenPt, Text, TextExt, Widget,
6    WidgetImpl, WidgetOutput,
7};
8
9// The X is always time
10pub struct ScatterPlot {
11    draw: Drawable,
12
13    top_left: ScreenPt,
14    dims: ScreenDims,
15}
16
17impl ScatterPlot {
18    pub fn new_widget<Y: Axis<Y> + std::ops::AddAssign + std::ops::Div<f64, Output = Y>>(
19        ctx: &EventCtx,
20        mut series: Vec<Series<Time, Y>>,
21        opts: PlotOptions<Time, Y>,
22        unit_fmt: UnitFmt,
23    ) -> Widget {
24        let legend = make_legend(ctx, &series, &opts);
25        series.retain(|s| !opts.disabled.contains(&s.label));
26
27        // TODO Refactor this part with LinePlot too
28        // Assume min_x is Time::START_OF_DAY and min_y is Y::zero()
29        let max_x = opts.max_x.unwrap_or_else(|| {
30            series
31                .iter()
32                .map(|s| {
33                    s.pts
34                        .iter()
35                        .map(|(t, _)| *t)
36                        .max()
37                        .unwrap_or(Time::START_OF_DAY)
38                })
39                .max()
40                .unwrap_or(Time::START_OF_DAY)
41        });
42        let max_y = opts.max_y.unwrap_or_else(|| {
43            series
44                .iter()
45                .map(|s| {
46                    s.pts
47                        .iter()
48                        .map(|(_, value)| *value)
49                        .max()
50                        .unwrap_or_else(Y::zero)
51                })
52                .max()
53                .unwrap_or_else(Y::zero)
54        });
55
56        // TODO Tuned to fit the info panel. Instead these should somehow stretch to fill their
57        // container.
58        let width = 0.22 * ctx.canvas.window_width;
59        let height = 0.2 * ctx.canvas.window_height;
60
61        let mut batch = GeomBatch::new();
62        // Grid lines for the Y scale. Draw up to 10 lines max to cover the order of magnitude of
63        // the range.
64        // TODO This caps correctly, but if the max is 105, then suddenly we just have 2 grid
65        // lines.
66        {
67            let order_of_mag = 10.0_f64.powf(max_y.to_f64().log10().ceil());
68            for i in 0..10 {
69                let y = max_y.from_f64(order_of_mag / 10.0 * (i as f64));
70                let pct = y.to_percent(max_y);
71                if pct > 1.0 {
72                    break;
73                }
74                batch.push(
75                    Color::hex("#7C7C7C"),
76                    PolyLine::must_new(vec![
77                        Pt2D::new(0.0, (1.0 - pct) * height),
78                        Pt2D::new(width, (1.0 - pct) * height),
79                    ])
80                    .make_polygons(Distance::meters(1.0)),
81                );
82            }
83        }
84        // X axis grid
85        if max_x != Time::START_OF_DAY {
86            let order_of_mag = 10.0_f64.powf(max_x.inner_seconds().log10().ceil());
87            for i in 0..10 {
88                let x = Time::START_OF_DAY + Duration::seconds(order_of_mag / 10.0 * (i as f64));
89                let pct = x.to_percent(max_x);
90                if pct > 1.0 {
91                    break;
92                }
93                batch.push(
94                    Color::hex("#7C7C7C"),
95                    PolyLine::must_new(vec![
96                        Pt2D::new(pct * width, 0.0),
97                        Pt2D::new(pct * width, height),
98                    ])
99                    .make_polygons(Distance::meters(1.0)),
100                );
101            }
102        }
103
104        let circle = Circle::new(Pt2D::new(0.0, 0.0), Distance::meters(4.0)).to_polygon();
105        let mut sum = Y::zero();
106        let mut cnt = 0;
107        for s in series {
108            for (t, y) in s.pts {
109                cnt += 1;
110                sum += y;
111                let percent_x = t.to_percent(max_x);
112                let percent_y = y.to_percent(max_y);
113                // Y inversion
114                batch.push(
115                    s.color,
116                    circle.translate(percent_x * width, (1.0 - percent_y) * height),
117                );
118            }
119        }
120
121        if sum != Y::zero() {
122            let avg = (sum / (cnt as f64)).to_percent(max_y);
123            batch.extend(
124                Color::hex("#F2F2F2"),
125                PolyLine::must_new(vec![
126                    Pt2D::new(0.0, (1.0 - avg) * height),
127                    Pt2D::new(width, (1.0 - avg) * height),
128                ])
129                .exact_dashed_polygons(
130                    Distance::meters(1.0),
131                    Distance::meters(10.0),
132                    Distance::meters(4.0),
133                ),
134            );
135
136            let txt = Text::from("avg").render(ctx).autocrop();
137            let width = txt.get_dims().width;
138            batch.append(txt.centered_on(Pt2D::new(-width / 2.0, (1.0 - avg) * height)));
139        }
140
141        let plot = ScatterPlot {
142            draw: ctx.upload(batch),
143
144            top_left: ScreenPt::new(0.0, 0.0),
145            dims: ScreenDims::new(width, height),
146        };
147
148        let num_x_labels = 3;
149        let mut row = Vec::new();
150        for i in 0..num_x_labels {
151            let percent_x = (i as f64) / ((num_x_labels - 1) as f64);
152            let t = max_x.percent_of(percent_x);
153            // TODO Need ticks now to actually see where this goes
154            let batch = Text::from(t.to_string())
155                .render(ctx)
156                .rotate(Angle::degrees(-15.0))
157                .autocrop();
158            row.push(batch.into_widget(ctx));
159        }
160        let x_axis = Widget::custom_row(row).padding(10).evenly_spaced();
161
162        let num_y_labels = 4;
163        let mut col = Vec::new();
164        for i in 0..num_y_labels {
165            let percent_y = (i as f64) / ((num_y_labels - 1) as f64);
166            col.push(
167                max_y
168                    .from_percent(percent_y)
169                    .prettyprint(&unit_fmt)
170                    .text_widget(ctx),
171            );
172        }
173        col.reverse();
174        let y_axis = Widget::custom_col(col).padding(10).evenly_spaced();
175
176        // Don't let the x-axis fill the parent container
177        Widget::custom_col(vec![
178            legend.margin_below(10),
179            Widget::custom_row(vec![y_axis, Widget::new(Box::new(plot))]),
180            x_axis,
181        ])
182        .container()
183    }
184}
185
186impl WidgetImpl for ScatterPlot {
187    fn get_dims(&self) -> ScreenDims {
188        self.dims
189    }
190
191    fn set_pos(&mut self, top_left: ScreenPt) {
192        self.top_left = top_left;
193    }
194
195    fn event(&mut self, _: &mut EventCtx, _: &mut WidgetOutput) {}
196
197    fn draw(&self, g: &mut GfxCtx) {
198        g.redraw_at(self.top_left, &self.draw);
199    }
200}