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 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 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 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 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 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 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 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 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 let y =
517 max_bar_height * (1.0 - interval.inner_seconds() / intervals.0.inner_seconds());
518 (interval, y)
519 })
520 .chain(
521 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}