game/sandbox/dashboards/
travel_times.rs

1use std::collections::BTreeSet;
2use std::fmt::Write;
3
4use anyhow::Result;
5
6use abstutil::prettyprint_usize;
7use geom::{Distance, Duration, Polygon, Pt2D};
8use map_gui::tools::color_for_mode;
9use sim::{ProblemType, TripID};
10use synthpop::TripMode;
11use widgetry::tools::PopupMsg;
12use widgetry::{
13    Choice, Color, CompareTimes, DrawWithTooltips, EventCtx, GeomBatch, GfxCtx, Line, Outcome,
14    Panel, State, Text, TextExt, Toggle, Widget,
15};
16
17use super::trip_problems::{problem_matrix, TripProblemFilter};
18use crate::app::{App, Transition};
19use crate::sandbox::dashboards::generic_trip_table::open_trip_transition;
20use crate::sandbox::dashboards::DashTab;
21
22pub struct TravelTimes {
23    panel: Panel,
24}
25
26impl TravelTimes {
27    pub fn new_state(ctx: &mut EventCtx, app: &App, filter: Filter) -> Box<dyn State<App>> {
28        Box::new(TravelTimes {
29            panel: TravelTimes::make_panel(ctx, app, filter),
30        })
31    }
32
33    fn make_panel(ctx: &mut EventCtx, app: &App, filter: Filter) -> Panel {
34        let mut filters = vec!["Filters".text_widget(ctx)];
35        for mode in TripMode::all() {
36            filters.push(Toggle::colored_checkbox(
37                ctx,
38                mode.ongoing_verb(),
39                color_for_mode(app, mode),
40                filter.modes.contains(&mode),
41            ));
42        }
43
44        filters.push(
45            ctx.style()
46                .btn_plain
47                .text("Export to CSV")
48                .build_def(ctx)
49                .align_bottom(),
50        );
51
52        Panel::new_builder(Widget::col(vec![
53            DashTab::TravelTimes.picker(ctx, app),
54            Widget::row(vec![
55                Widget::col(filters).section(ctx),
56                Widget::col(vec![
57                    summary_boxes(ctx, app, &filter),
58                    Widget::col(vec![
59                        Text::from(Line("Travel Times").small_heading()).into_widget(ctx),
60                        Widget::row(vec![
61                            "filter:".text_widget(ctx).centered_vert(),
62                            Widget::dropdown(
63                                ctx,
64                                "filter",
65                                filter.changes_pct,
66                                vec![
67                                    Choice::new("any change", None),
68                                    Choice::new("at least 1% change", Some(0.01)),
69                                    Choice::new("at least 10% change", Some(0.1)),
70                                    Choice::new("at least 50% change", Some(0.5)),
71                                ],
72                            ),
73                        ])
74                        .margin_above(8),
75                        Widget::horiz_separator(ctx, 1.0),
76                        Widget::row(vec![
77                            contingency_table(ctx, app, &filter).bg(ctx.style().section_bg),
78                            scatter_plot(ctx, app, &filter)
79                                .bg(ctx.style().section_bg)
80                                .margin_left(32),
81                        ]),
82                    ])
83                    .section(ctx)
84                    .evenly_spaced(),
85                    Widget::row(vec![
86                        Widget::col(vec![
87                            Text::from(Line("Intersection Delays").small_heading())
88                                .into_widget(ctx),
89                            Toggle::checkbox(
90                                ctx,
91                                "include trips without any changes",
92                                None,
93                                filter.include_no_changes(),
94                            ),
95                        ]),
96                        problem_matrix(
97                            ctx,
98                            app,
99                            filter.trip_problems(app, ProblemType::IntersectionDelay),
100                        )
101                        .margin_left(32),
102                    ])
103                    .section(ctx),
104                ]),
105            ]),
106        ]))
107        .exact_size_percent(90, 90)
108        .build(ctx)
109    }
110}
111
112impl State<App> for TravelTimes {
113    fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Transition {
114        match self.panel.event(ctx) {
115            Outcome::Clicked(x) => match x.as_ref() {
116                "Export to CSV" => {
117                    return Transition::Push(match export_times(app) {
118                        Ok(path) => PopupMsg::new_state(
119                            ctx,
120                            "Data exported",
121                            vec![format!("Data exported to {}", path)],
122                        ),
123                        Err(err) => {
124                            PopupMsg::new_state(ctx, "Export failed", vec![err.to_string()])
125                        }
126                    });
127                }
128                "close" => Transition::Pop,
129                _ => unreachable!(),
130            },
131            Outcome::ClickCustom(data) => {
132                let trips = data.as_any().downcast_ref::<Vec<TripID>>().unwrap();
133                // TODO Handle browsing multiple trips
134                open_trip_transition(app, trips[0].0)
135            }
136            Outcome::Changed(_) => {
137                if let Some(t) = DashTab::TravelTimes.transition(ctx, app, &self.panel) {
138                    return t;
139                }
140
141                let mut filter = Filter {
142                    changes_pct: self.panel.dropdown_value("filter"),
143                    modes: BTreeSet::new(),
144                    include_no_changes: self.panel.is_checked("include trips without any changes"),
145                };
146                for m in TripMode::all() {
147                    if self.panel.is_checked(m.ongoing_verb()) {
148                        filter.modes.insert(m);
149                    }
150                }
151                let mut new_panel = TravelTimes::make_panel(ctx, app, filter);
152                new_panel.restore(ctx, &self.panel);
153                self.panel = new_panel;
154                Transition::Keep
155            }
156            _ => Transition::Keep,
157        }
158    }
159
160    fn draw(&self, g: &mut GfxCtx, _app: &App) {
161        self.panel.draw(g);
162    }
163}
164
165fn summary_boxes(ctx: &mut EventCtx, app: &App, filter: &Filter) -> Widget {
166    let mut num_same = 0;
167    let mut num_faster = 0;
168    let mut num_slower = 0;
169    let mut sum_faster = Duration::ZERO;
170    let mut sum_slower = Duration::ZERO;
171    for (_, b, a, mode) in app
172        .primary
173        .sim
174        .get_analytics()
175        .both_finished_trips(app.primary.sim.time(), app.prebaked())
176    {
177        if !filter.modes.contains(&mode) {
178            continue;
179        }
180        let same = if let Some(pct) = filter.changes_pct {
181            pct_diff(a, b) <= pct
182        } else {
183            a == b
184        };
185
186        if same {
187            num_same += 1;
188        } else if a < b {
189            num_faster += 1;
190            sum_faster += b - a;
191        } else {
192            num_slower += 1;
193            sum_slower += a - b;
194        }
195    }
196    let num_total = (num_faster + num_slower + num_same) as f64;
197
198    Widget::row(vec![
199        Text::from_multiline(vec![
200            Line(format!("Faster Trips: {}", prettyprint_usize(num_faster))).big_heading_plain(),
201            Line(format!(
202                "{:.2}% of finished trips",
203                100.0 * (num_faster as f64) / num_total
204            ))
205            .small(),
206            Line(format!(
207                "Average {} faster per trip",
208                if num_faster == 0 {
209                    Duration::ZERO
210                } else {
211                    sum_faster / (num_faster as f64)
212                }
213            ))
214            .small(),
215            Line(format!(
216                "Saved {} in total",
217                sum_faster.to_rounded_string(1)
218            ))
219            .small(),
220        ])
221        .into_widget(ctx)
222        .container()
223        .padding(20)
224        .bg(Color::hex("#72CE36").alpha(0.5))
225        .outline(ctx.style().section_outline),
226        Text::from_multiline(vec![
227            Line(format!("Slower Trips: {}", prettyprint_usize(num_slower))).big_heading_plain(),
228            Line(format!(
229                "{:.2}% of finished trips",
230                100.0 * (num_slower as f64) / num_total
231            ))
232            .small(),
233            Line(format!(
234                "Average {} slower per trip",
235                if num_slower == 0 {
236                    Duration::ZERO
237                } else {
238                    sum_slower / (num_slower as f64)
239                }
240            ))
241            .small(),
242            Line(format!("Lost {} in total", sum_slower.to_rounded_string(1))).small(),
243        ])
244        .into_widget(ctx)
245        .container()
246        .padding(20)
247        .bg(app.cs.signal_banned_turn.alpha(0.5))
248        .outline(ctx.style().section_outline),
249        Text::from_multiline(vec![
250            Line(format!("Unchanged: {}", prettyprint_usize(num_same))).big_heading_plain(),
251            Line(format!(
252                "{:.2}% of finished trips",
253                100.0 * (num_same as f64) / num_total
254            ))
255            .small(),
256        ])
257        .into_widget(ctx)
258        .container()
259        .padding(20)
260        .bg(Color::hex("#F4DA22").alpha(0.5))
261        .outline(ctx.style().section_outline),
262    ])
263    .evenly_spaced()
264}
265
266fn scatter_plot(ctx: &mut EventCtx, app: &App, filter: &Filter) -> Widget {
267    let points = filter.get_trips(app);
268    if points.is_empty() {
269        return Widget::nothing();
270    }
271
272    Widget::col(vec![
273        Line("Trip time before vs. after")
274            .small_heading()
275            .into_widget(ctx),
276        CompareTimes::new_widget(
277            ctx,
278            format!(
279                "Trip time before \"{}\"",
280                app.primary.map.get_edits().edits_name
281            ),
282            format!(
283                "Trip time after \"{}\"",
284                app.primary.map.get_edits().edits_name
285            ),
286            points,
287        ),
288    ])
289}
290
291fn contingency_table(ctx: &mut EventCtx, app: &App, filter: &Filter) -> Widget {
292    let total_width = 500.0;
293    let total_height = 300.0;
294
295    let points = filter.get_trips(app);
296    if points.is_empty() {
297        return Widget::nothing();
298    }
299
300    // bucket by trip duration _before_ changes
301    let duration_buckets = vec![
302        Duration::ZERO,
303        Duration::minutes(5),
304        Duration::minutes(15),
305        Duration::minutes(30),
306        Duration::hours(1),
307    ];
308    let num_buckets = duration_buckets.len();
309
310    let mut batch = GeomBatch::new();
311    batch.autocrop_dims = false;
312
313    // Draw the X axis
314    let text_height = ctx.default_line_height();
315    let text_v_padding = 12.0;
316    let x_axis_height = text_height + text_v_padding;
317    let line_thickness = Distance::meters(1.5);
318    for (idx, mins) in duration_buckets.iter().skip(1).enumerate() {
319        let x = (idx as f64 + 1.0) / (num_buckets as f64) * total_width;
320        let y = total_height / 2.0;
321
322        {
323            let bottom_of_top_bar = (total_height - x_axis_height) / 2.0;
324            let line_top = bottom_of_top_bar;
325            let line_bottom = bottom_of_top_bar + text_v_padding / 2.0 + 2.0;
326            batch.push(
327                ctx.style().text_secondary_color.shade(0.2),
328                geom::Line::must_new(Pt2D::new(x, line_top), Pt2D::new(x, line_bottom))
329                    .make_polygons(line_thickness),
330            );
331        }
332        {
333            let top_of_bottom_bar = (total_height - x_axis_height) / 2.0 + x_axis_height;
334            let line_bottom = top_of_bottom_bar;
335            let line_top = line_bottom - text_v_padding / 2.0 - 2.0;
336            batch.push(
337                ctx.style().text_secondary_color.shade(0.2),
338                geom::Line::must_new(Pt2D::new(x, line_top), Pt2D::new(x, line_bottom))
339                    .make_polygons(line_thickness),
340            );
341        }
342        batch.append(
343            Text::from(Line(mins.to_string()).secondary())
344                .render(ctx)
345                .centered_on(Pt2D::new(x, y - 4.0)),
346        );
347    }
348    // TODO Position this better
349    if false {
350        batch.append(
351            Text::from_multiline(vec![
352                Line("trip").secondary(),
353                Line("time").secondary(),
354                Line("after").secondary(),
355            ])
356            .render(ctx)
357            .translate(total_width, total_height / 2.0),
358        );
359    }
360
361    #[derive(Clone)]
362    struct Changes {
363        trip_count: usize,
364        accumulated_duration: Duration,
365    }
366
367    // Now measure savings and losses per bucket.
368    let mut savings_per_bucket = vec![
369        Changes {
370            trip_count: 0,
371            accumulated_duration: Duration::ZERO
372        };
373        num_buckets
374    ];
375    let mut losses_per_bucket = vec![
376        Changes {
377            trip_count: 0,
378            accumulated_duration: Duration::ZERO
379        };
380        num_buckets
381    ];
382
383    for (b, a) in points {
384        // bucket by trip duration _before_ changes
385        let idx = duration_buckets
386            .iter()
387            .position(|min| *min > b)
388            .unwrap_or_else(|| duration_buckets.len())
389            - 1;
390        match a.cmp(&b) {
391            std::cmp::Ordering::Greater => {
392                losses_per_bucket[idx].accumulated_duration += a - b;
393                losses_per_bucket[idx].trip_count += 1;
394            }
395            std::cmp::Ordering::Less => {
396                savings_per_bucket[idx].accumulated_duration += b - a;
397                savings_per_bucket[idx].trip_count += 1;
398            }
399            std::cmp::Ordering::Equal => {}
400        }
401    }
402    let max_y = losses_per_bucket
403        .iter()
404        .chain(savings_per_bucket.iter())
405        .map(|c| c.accumulated_duration.abs())
406        .max()
407        .unwrap();
408
409    let intervals = max_y.make_intervals_for_max(2);
410
411    // Draw the bars!
412    let bar_width = total_width / (num_buckets as f64);
413    let max_bar_height = (total_height - x_axis_height) / 2.0;
414    let min_bar_height = 8.0;
415    let mut bar_outlines = Vec::new();
416    let mut tooltips = Vec::new();
417    let mut x1 = 0.0;
418    for (
419        idx,
420        (
421            Changes {
422                accumulated_duration: total_savings,
423                trip_count: num_savings,
424            },
425            Changes {
426                accumulated_duration: total_loss,
427                trip_count: num_loss,
428            },
429        ),
430    ) in savings_per_bucket
431        .into_iter()
432        .zip(losses_per_bucket.into_iter())
433        .enumerate()
434    {
435        if num_savings > 0 {
436            let height = ((total_savings / intervals.0) * max_bar_height).max(min_bar_height);
437            let rect = Polygon::rectangle(bar_width, height).translate(x1, max_bar_height - height);
438            bar_outlines.push(rect.to_outline(line_thickness));
439            batch.push(Color::GREEN, rect.clone());
440            tooltips.push((
441                rect,
442                Text::from_multiline(vec![
443                    Line(match idx {
444                        0 => format!(
445                            "{} trips shorter than {}",
446                            prettyprint_usize(num_savings),
447                            duration_buckets[idx + 1]
448                        ),
449                        i if i + 1 == duration_buckets.len() => format!(
450                            "{} trips longer than {}",
451                            prettyprint_usize(num_savings),
452                            duration_buckets[idx]
453                        ),
454                        _ => format!(
455                            "{} trips between {} and {}",
456                            prettyprint_usize(num_savings),
457                            duration_buckets[idx],
458                            duration_buckets[idx + 1]
459                        ),
460                    }),
461                    Line(format!(
462                        "Saved {} in total",
463                        total_savings.to_rounded_string(1)
464                    ))
465                    .fg(Color::hex("#72CE36")),
466                ]),
467                None,
468            ));
469        }
470        if num_loss > 0 {
471            let height = ((total_loss / intervals.0) * max_bar_height).max(min_bar_height);
472            let rect =
473                Polygon::rectangle(bar_width, height).translate(x1, total_height - max_bar_height);
474            bar_outlines.push(rect.to_outline(line_thickness));
475            batch.push(Color::RED, rect.clone());
476            tooltips.push((
477                rect,
478                Text::from_multiline(vec![
479                    Line(match idx {
480                        0 => format!(
481                            "{} trips shorter than {}",
482                            prettyprint_usize(num_loss),
483                            duration_buckets[idx + 1]
484                        ),
485                        i if i + 1 == duration_buckets.len() => format!(
486                            "{} trips longer than {}",
487                            prettyprint_usize(num_loss),
488                            duration_buckets[idx]
489                        ),
490                        _ => format!(
491                            "{} trips between {} and {}",
492                            prettyprint_usize(num_loss),
493                            duration_buckets[idx],
494                            duration_buckets[idx + 1]
495                        ),
496                    }),
497                    Line(format!("Lost {} in total", total_loss.to_rounded_string(1)))
498                        .fg(app.cs.signal_banned_turn),
499                ]),
500                None,
501            ));
502        }
503        x1 += bar_width;
504    }
505    // Draw the y-axis
506    let mut y_axis_ticks = GeomBatch::new();
507    let mut y_axis_labels = GeomBatch::new();
508    {
509        let line_length = 8.0;
510        let line_thickness = 2.0;
511
512        intervals.1[1..]
513            .iter()
514            .map(|interval| {
515                // positive ticks
516                let y =
517                    max_bar_height * (1.0 - interval.inner_seconds() / intervals.0.inner_seconds());
518                (interval, y)
519            })
520            .chain(
521                // negative ticks
522                intervals.1[1..].iter().map(|interval| {
523                    let y = total_height
524                        - max_bar_height
525                            * (1.0 - interval.abs().inner_seconds() / intervals.0.inner_seconds());
526                    (interval, y)
527                }),
528            )
529            .for_each(|(interval, y)| {
530                let start = Pt2D::new(0.0, y);
531                let line = geom::Line::must_new(start, start.offset(line_length, 0.0));
532                let poly = line.make_polygons(Distance::meters(line_thickness));
533                y_axis_ticks.push(ctx.style().text_secondary_color, poly);
534
535                let text = Text::from(Line(interval.abs().to_rounded_string(0)).secondary())
536                    .render(ctx)
537                    .centered_on(start.offset(0.0, -4.0));
538                y_axis_labels.append(text);
539            });
540    }
541    y_axis_labels.autocrop_dims = true;
542    y_axis_labels = y_axis_labels.autocrop();
543
544    batch.extend(Color::BLACK, bar_outlines);
545
546    Widget::col(vec![
547        Text::from_multiline(vec![
548            Line("Aggregate difference by trip duration").small_heading(),
549            Line(format!(
550                "Grouped by the duration of the trip before\n\"{}\" changes.",
551                app.primary.map.get_edits().edits_name
552            )),
553        ])
554        .into_widget(ctx)
555        .container(),
556        Line("Total Time Saved (faster)")
557            .secondary()
558            .into_widget(ctx)
559            .centered_horiz(),
560        Widget::custom_row(vec![
561            y_axis_labels
562                .into_widget(ctx)
563                .margin_right(8)
564                .centered_vert(),
565            y_axis_ticks
566                .into_widget(ctx)
567                .margin_right(8)
568                .centered_vert(),
569            DrawWithTooltips::new_widget(ctx, batch, tooltips, Box::new(|_| GeomBatch::new())),
570        ])
571        .centered_horiz(),
572        Line("Total Time Lost (slower)")
573            .secondary()
574            .into_widget(ctx)
575            .centered_horiz(),
576    ])
577    .centered()
578}
579
580pub struct Filter {
581    changes_pct: Option<f64>,
582    modes: BTreeSet<TripMode>,
583    include_no_changes: bool,
584}
585
586impl TripProblemFilter for Filter {
587    fn includes_mode(&self, mode: &TripMode) -> bool {
588        self.modes.contains(mode)
589    }
590
591    fn include_no_changes(&self) -> bool {
592        self.include_no_changes
593    }
594}
595
596impl Filter {
597    pub fn new() -> Filter {
598        Filter {
599            changes_pct: None,
600            modes: TripMode::all().into_iter().collect(),
601            include_no_changes: false,
602        }
603    }
604
605    fn get_trips(&self, app: &App) -> Vec<(Duration, Duration)> {
606        let mut points = Vec::new();
607        for (_, b, a, mode) in app
608            .primary
609            .sim
610            .get_analytics()
611            .both_finished_trips(app.primary.sim.time(), app.prebaked())
612        {
613            if self.modes.contains(&mode)
614                && self
615                    .changes_pct
616                    .map(|pct| pct_diff(a, b) > pct)
617                    .unwrap_or(true)
618            {
619                points.push((b, a));
620            }
621        }
622        points
623    }
624}
625
626fn pct_diff(a: Duration, b: Duration) -> f64 {
627    if a >= b {
628        (a / b) - 1.0
629    } else {
630        (b / a) - 1.0
631    }
632}
633
634fn export_times(app: &App) -> Result<String> {
635    let path = format!(
636        "trip_times_{}_{}.csv",
637        app.primary.map.get_name().as_filename(),
638        app.primary.sim.time().as_filename()
639    );
640    let mut out = String::new();
641    writeln!(out, "id,mode,seconds_before,seconds_after")?;
642    for (id, b, a, mode) in app
643        .primary
644        .sim
645        .get_analytics()
646        .both_finished_trips(app.primary.sim.time(), app.prebaked())
647    {
648        writeln!(
649            out,
650            "{},{:?},{},{}",
651            id.0,
652            mode,
653            b.inner_seconds(),
654            a.inner_seconds()
655        )?;
656    }
657    abstio::write_file(path, out)
658}