widgetry/widgets/
fan_chart.rs

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