game/sandbox/dashboards/
trip_problems.rs

1use std::cmp::Ordering;
2use std::fmt::Display;
3
4use abstutil::{abbreviated_format, prettyprint_usize, CloneableAny};
5use geom::{Angle, Distance, Duration, Line, Polygon, Pt2D};
6use sim::{ProblemType, TripID};
7use synthpop::TripMode;
8use widgetry::tools::ColorScale;
9use widgetry::{
10    ClickOutcome, Color, DrawWithTooltips, GeomBatch, GeomBatchStack, StackAlignment, Text, Widget,
11};
12
13use crate::{App, EventCtx};
14
15pub trait TripProblemFilter {
16    fn includes_mode(&self, mode: &TripMode) -> bool;
17    fn include_no_changes(&self) -> bool;
18
19    // Returns:
20    // 1) trip ID
21    // 2) trip duration after changes
22    // 3) difference in number of matching problems, where positive means MORE problems after
23    //    changes
24    fn trip_problems(
25        &self,
26        app: &App,
27        problem_type: ProblemType,
28    ) -> Vec<(TripID, Duration, isize)> {
29        let before = app.prebaked();
30        let after = app.primary.sim.get_analytics();
31        let empty = Vec::new();
32
33        let mut points = Vec::new();
34        for (id, _, time_after, mode) in after.both_finished_trips(app.primary.sim.time(), before) {
35            if self.includes_mode(&mode) {
36                let count_before = problem_type
37                    .count(before.problems_per_trip.get(&id).unwrap_or(&empty))
38                    as isize;
39                let count_after =
40                    problem_type.count(after.problems_per_trip.get(&id).unwrap_or(&empty)) as isize;
41                if !self.include_no_changes() && count_after == count_before {
42                    continue;
43                }
44                points.push((id, time_after, count_after - count_before));
45            }
46        }
47        points
48    }
49
50    fn finished_trip_count(&self, app: &App) -> usize {
51        let before = app.prebaked();
52        let after = app.primary.sim.get_analytics();
53
54        let mut count = 0;
55        for (_, _, _, mode) in after.both_finished_trips(app.primary.sim.time(), before) {
56            if self.includes_mode(&mode) {
57                count += 1;
58            }
59        }
60        count
61    }
62}
63
64lazy_static::lazy_static! {
65    static ref CLEAR_COLOR_SCALE: ColorScale = ColorScale(vec![Color::CLEAR, Color::CLEAR]);
66}
67
68/// The caller should handle Outcome::ClickCustom with Vec<TripID> for clicked cells.
69pub fn problem_matrix(
70    ctx: &mut EventCtx,
71    app: &App,
72    trips: Vec<(TripID, Duration, isize)>,
73) -> Widget {
74    let duration_buckets = vec![
75        Duration::ZERO,
76        Duration::minutes(5),
77        Duration::minutes(15),
78        Duration::minutes(30),
79        Duration::hours(1),
80        Duration::hours(2),
81    ];
82
83    let num_buckets = 7;
84    let mut matrix = Matrix::new(duration_buckets, bucketize_isizes(num_buckets, &trips));
85    for (id, x, y) in trips {
86        matrix.add_pt(id, x, y);
87    }
88    matrix.draw(
89        ctx,
90        app,
91        MatrixOptions {
92            total_width: 600.0,
93            total_height: 600.0,
94            color_scale_for_bucket: Box::new(|app, _, n| match n.cmp(&0) {
95                std::cmp::Ordering::Equal => &CLEAR_COLOR_SCALE,
96                std::cmp::Ordering::Less => &app.cs.good_to_bad_green,
97                std::cmp::Ordering::Greater => &app.cs.good_to_bad_red,
98            }),
99            fmt_y_axis: Box::new(|lower_bound: isize, upper_bound: isize| -> Text {
100                if lower_bound + 1 == upper_bound {
101                    Text::from(lower_bound.abs().to_string())
102                } else if lower_bound.is_negative() {
103                    Text::from(format!("{}-{}", upper_bound.abs() + 1, lower_bound.abs()))
104                } else {
105                    Text::from(format!("{}-{}", lower_bound.abs(), upper_bound.abs() - 1))
106                }
107            }),
108            tooltip_for_bucket: Box::new(|(t1, t2), (problems1, problems2), count| {
109                let trip_string = if count == 1 {
110                    "1 trip".to_string()
111                } else {
112                    format!("{} trips", prettyprint_usize(count))
113                };
114                let duration_string = match (t1, t2) {
115                    (None, Some(end)) => format!("shorter than {}", end),
116                    (Some(start), None) => format!("longer than {}", start),
117                    (Some(start), Some(end)) => format!("between {} and {}", start, end),
118                    (None, None) => {
119                        unreachable!("at least one end of the duration range must be specified")
120                    }
121                };
122                let mut txt = Text::from(format!("{} {}", trip_string, duration_string));
123                txt.add_line(match problems1.cmp(&0) {
124                    std::cmp::Ordering::Equal => {
125                        "had no change in the number of problems encountered.".to_string()
126                    }
127                    std::cmp::Ordering::Less => {
128                        if problems1.abs() == problems2.abs() + 1 {
129                            if problems1.abs() == 1 {
130                                "encountered 1 fewer problem.".to_string()
131                            } else {
132                                format!("encountered {} fewer problems.", problems1.abs())
133                            }
134                        } else {
135                            format!(
136                                "encountered {}-{} fewer problems.",
137                                problems2.abs() + 1,
138                                problems1.abs()
139                            )
140                        }
141                    }
142                    std::cmp::Ordering::Greater => {
143                        if problems1 == problems2 - 1 {
144                            if problems1 == 1 {
145                                "encountered 1 more problems.".to_string()
146                            } else {
147                                format!("encountered {} more problems.", problems1,)
148                            }
149                        } else {
150                            format!("encountered {}-{} more problems.", problems1, problems2 - 1)
151                        }
152                    }
153                });
154                txt
155            }),
156        },
157    )
158}
159
160/// Aka a 2D histogram. Tracks matching IDs in each cell.
161struct Matrix<ID, X, Y> {
162    entries: Vec<Vec<ID>>,
163    buckets_x: Vec<X>,
164    buckets_y: Vec<Y>,
165}
166
167impl<
168        ID: 'static + CloneableAny + Clone,
169        X: Copy + PartialOrd + Display,
170        Y: Copy + PartialOrd + Display,
171    > Matrix<ID, X, Y>
172{
173    fn new(buckets_x: Vec<X>, buckets_y: Vec<Y>) -> Matrix<ID, X, Y> {
174        Matrix {
175            entries: std::iter::repeat_with(Vec::new)
176                .take(buckets_x.len() * buckets_y.len())
177                .collect(),
178            buckets_x,
179            buckets_y,
180        }
181    }
182
183    fn add_pt(&mut self, id: ID, x: X, y: Y) {
184        // Find its bucket
185        // TODO Unit test this
186        let x_idx = self
187            .buckets_x
188            .iter()
189            .position(|min| *min > x)
190            .unwrap_or(self.buckets_x.len())
191            - 1;
192        let y_idx = self
193            .buckets_y
194            .iter()
195            .position(|min| *min > y)
196            .unwrap_or(self.buckets_y.len())
197            - 1;
198        let idx = self.idx(x_idx, y_idx);
199        self.entries[idx].push(id);
200    }
201
202    fn idx(&self, x: usize, y: usize) -> usize {
203        // Row-major
204        y * self.buckets_x.len() + x
205    }
206
207    fn draw(mut self, ctx: &mut EventCtx, app: &App, opts: MatrixOptions<X, Y>) -> Widget {
208        let mut grid_batch = GeomBatch::new();
209        let mut tooltips = Vec::new();
210        let cell_width = opts.total_width / (self.buckets_x.len() as f64);
211        let cell_height = opts.total_height / (self.buckets_y.len() as f64);
212        let cell = Polygon::rectangle(cell_width, cell_height);
213
214        let max_count = self.entries.iter().map(|list| list.len()).max().unwrap() as f64;
215
216        for x in 0..self.buckets_x.len() - 1 {
217            for y in 0..self.buckets_y.len() - 1 {
218                let is_first_xbucket = x == 0;
219                let is_last_xbucket = x == self.buckets_x.len() - 2;
220                let is_middle_ybucket = y + 1 == self.buckets_y.len() / 2;
221                let idx = self.idx(x, y);
222                let count = self.entries[idx].len();
223                let color = if count == 0 {
224                    widgetry::Color::CLEAR
225                } else {
226                    let density_pct = (count as f64) / max_count;
227                    (opts.color_scale_for_bucket)(app, self.buckets_x[x], self.buckets_y[y])
228                        .eval(density_pct)
229                };
230                let x1 = cell_width * (x as f64);
231                let y1 = cell_height * (y as f64);
232                let rect = cell.clone().translate(x1, y1);
233                grid_batch.push(color, rect.clone());
234                grid_batch.append(
235                    Text::from(if count == 0 && is_middle_ybucket {
236                        "-".to_string()
237                    } else {
238                        abbreviated_format(count)
239                    })
240                    .change_fg(if count == 0 || is_middle_ybucket {
241                        ctx.style().text_primary_color
242                    } else {
243                        Color::WHITE
244                    })
245                    .render(ctx)
246                    .centered_on(Pt2D::new(x1 + cell_width / 2.0, y1 + cell_height / 2.0)),
247                );
248
249                if count != 0 || !is_middle_ybucket {
250                    tooltips.push((
251                        rect,
252                        (opts.tooltip_for_bucket)(
253                            (
254                                if is_first_xbucket {
255                                    None
256                                } else {
257                                    Some(self.buckets_x[x])
258                                },
259                                if is_last_xbucket {
260                                    None
261                                } else {
262                                    Some(self.buckets_x[x + 1])
263                                },
264                            ),
265                            (self.buckets_y[y], self.buckets_y[y + 1]),
266                            count,
267                        ),
268                        if count != 0 {
269                            Some(ClickOutcome::Custom(Box::new(std::mem::take(
270                                &mut self.entries[idx],
271                            ))))
272                        } else {
273                            None
274                        },
275                    ));
276                }
277            }
278        }
279        {
280            let bottom = cell_height * (self.buckets_y.len() - 1) as f64;
281            let right = cell_width * (self.buckets_x.len() - 1) as f64;
282
283            let border_lines = vec![
284                Line::must_new(Pt2D::zero(), Pt2D::new(right, 0.0)),
285                Line::must_new(Pt2D::new(right, 0.0), Pt2D::new(right, bottom)),
286                Line::must_new(Pt2D::new(right, bottom), Pt2D::new(0.0, bottom)),
287                Line::must_new(Pt2D::new(0.0, bottom), Pt2D::zero()),
288            ];
289            for line in border_lines {
290                let border_poly = line.make_polygons(Distance::meters(3.0));
291                grid_batch.push(ctx.style().text_secondary_color, border_poly);
292            }
293        }
294
295        // Draw the axes
296        let y_axis_batch = {
297            let mut y_axis_scale = GeomBatch::new();
298            for y in 0..self.buckets_y.len() - 1 {
299                let x1 = 0.0;
300                let mut y1 = cell_height * y as f64;
301
302                let middle_bucket = self.buckets_y.len() / 2 - 1;
303                let y_offset = match y.cmp(&middle_bucket) {
304                    Ordering::Less => cell_height,
305                    Ordering::Greater => 0.0,
306                    Ordering::Equal => cell_height / 2.0,
307                };
308
309                let y_label = (opts.fmt_y_axis)(self.buckets_y[y], self.buckets_y[y + 1])
310                    .change_fg(ctx.style().text_secondary_color)
311                    .render(ctx)
312                    .centered_on(Pt2D::new(x1 + cell_width / 2.0, y1 + 0.5 * cell_height));
313                y_axis_scale.append(y_label);
314
315                if y != middle_bucket {
316                    y1 += y_offset;
317                    let tick_length = 8.0;
318                    let tick_thickness = 2.0;
319                    let start = Pt2D::new(x1 + cell_width - tick_length, y1 - tick_thickness / 2.0);
320                    let line = Line::must_new(start, start.offset(tick_length, 0.0))
321                        .make_polygons(Distance::meters(tick_thickness));
322                    y_axis_scale.push(ctx.style().text_secondary_color, line);
323                }
324            }
325            let mut y_axis_label = Text::from("More Problems <--------> Fewer Problems")
326                .change_fg(ctx.style().text_secondary_color)
327                .render(ctx)
328                .rotate(Angle::degrees(-90.0));
329
330            y_axis_label.autocrop_dims = true;
331            y_axis_label = y_axis_label.autocrop();
332
333            y_axis_label = y_axis_label.centered_on(Pt2D::new(
334                8.0,
335                cell_height * (self.buckets_y.len() as f64 / 2.0 - 1.0),
336            ));
337
338            GeomBatchStack::horizontal(vec![y_axis_label, y_axis_scale]).batch()
339        };
340
341        let x_axis_batch = {
342            let mut x_axis_scale = GeomBatch::new();
343            for x in 1..self.buckets_x.len() - 1 {
344                let x1 = cell_width * x as f64;
345                let y1 = 0.0;
346
347                x_axis_scale.append(
348                    Text::from(format!("{}", self.buckets_x[x]))
349                        .change_fg(ctx.style().text_secondary_color)
350                        .render(ctx)
351                        .centered_on(Pt2D::new(x1, y1 + cell_height / 2.0)),
352                );
353                let tick_length = 8.0;
354                let tick_thickness = 2.0;
355                let start = Pt2D::new(x1, y1 - 2.0);
356                let line = Line::must_new(start, start.offset(0.0, tick_length))
357                    .make_polygons(Distance::meters(tick_thickness));
358                x_axis_scale.push(ctx.style().text_secondary_color, line);
359            }
360            let x_axis_label = Text::from("Short Trips <--------> Long Trips")
361                .change_fg(ctx.style().text_secondary_color)
362                .render(ctx)
363                .centered_on(Pt2D::new(
364                    cell_width * ((self.buckets_x.len() as f64) / 2.0 - 0.5),
365                    cell_height,
366                ));
367
368            x_axis_scale.append(x_axis_label);
369
370            x_axis_scale
371        };
372
373        for (polygon, _, _) in &mut tooltips {
374            let mut translated = polygon.translate(y_axis_batch.get_bounds().width(), 0.0);
375            std::mem::swap(&mut translated, polygon);
376        }
377        let mut col = GeomBatchStack::vertical(vec![grid_batch, x_axis_batch]);
378        col.set_alignment(StackAlignment::Left);
379
380        let mut chart = GeomBatchStack::horizontal(vec![y_axis_batch, col.batch()]);
381        chart.set_alignment(StackAlignment::Top);
382
383        DrawWithTooltips::new_widget(ctx, chart.batch(), tooltips, Box::new(|_| GeomBatch::new()))
384    }
385}
386
387struct MatrixOptions<X, Y> {
388    total_width: f64,
389    total_height: f64,
390    // (lower_bound, upper_bound) -> Cell Label
391    fmt_y_axis: Box<dyn Fn(Y, Y) -> Text>,
392    color_scale_for_bucket: Box<dyn Fn(&App, X, Y) -> &ColorScale>,
393    tooltip_for_bucket: Box<dyn Fn((Option<X>, Option<X>), (Y, Y), usize) -> Text>,
394}
395
396fn bucketize_isizes(max_buckets: usize, pts: &[(TripID, Duration, isize)]) -> Vec<isize> {
397    debug_assert!(
398        max_buckets % 2 == 1,
399        "num_buckets must be odd to have a symmetrical number of buckets around axis"
400    );
401    debug_assert!(max_buckets >= 3, "num_buckets must be at least 3");
402
403    let positive_buckets = (max_buckets - 1) / 2;
404    // uniformly sized integer buckets
405    let max = match pts.iter().max_by_key(|(_, _, cnt)| cnt.abs()) {
406        Some(t) if (t.2.abs() as usize) >= positive_buckets => t.2.abs(),
407        _ => {
408            // Enforce a bucket width of at least 1.
409            let negative_buckets = -(positive_buckets as isize);
410            return (negative_buckets..=(positive_buckets as isize + 1)).collect();
411        }
412    };
413
414    let bucket_size = (max as f64 / positive_buckets as f64).ceil() as isize;
415
416    // we start with a 0-based bucket, and build the other buckets out from that.
417    let mut buckets = vec![0];
418
419    for i in 0..=positive_buckets {
420        // the first positive bucket starts at `1`, to ensure that the 0 bucket stands alone
421        buckets.push(1 + (i as isize) * bucket_size);
422    }
423    for i in 1..=positive_buckets {
424        buckets.push(-(i as isize) * bucket_size);
425    }
426    buckets.sort_unstable();
427    debug!("buckets: {:?}", buckets);
428
429    buckets
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    const TRIP: TripID = TripID(42);
437
438    #[test]
439    fn test_bucketize_isizes() {
440        let buckets = bucketize_isizes(
441            7,
442            &vec![
443                (TRIP, Duration::minutes(3), -3),
444                (TRIP, Duration::minutes(3), -3),
445                (TRIP, Duration::minutes(3), -1),
446                (TRIP, Duration::minutes(3), 2),
447                (TRIP, Duration::minutes(3), 5),
448            ],
449        );
450        // there should be an even number of buckets on either side of zero so as to center
451        // our x-axis.
452        //
453        // there should always be a 0-1 bucket, ensuring that only '0' falls into the zero-bucket.
454        //
455        // all other buckets edges should be evenly spaced from the zero bucket
456        assert_eq!(buckets, vec![-6, -4, -2, 0, 1, 3, 5, 7])
457    }
458
459    #[test]
460    fn test_bucketize_empty_isizes() {
461        let buckets = bucketize_isizes(7, &vec![]);
462        assert_eq!(buckets, vec![-3, -2, -1, 0, 1, 2, 3, 4])
463    }
464
465    #[test]
466    fn test_bucketize_small_isizes() {
467        let buckets = bucketize_isizes(
468            7,
469            &vec![
470                (TRIP, Duration::minutes(3), -1),
471                (TRIP, Duration::minutes(3), -1),
472                (TRIP, Duration::minutes(3), 0),
473                (TRIP, Duration::minutes(3), -1),
474                (TRIP, Duration::minutes(3), 0),
475            ],
476        );
477        assert_eq!(buckets, vec![-3, -2, -1, 0, 1, 2, 3, 4])
478    }
479}