game/info/
trip.rs

1use std::collections::BTreeMap;
2
3use maplit::btreemap;
4
5use crate::ID;
6use geom::{Distance, Duration, Percent, Polygon, Pt2D, UnitFmt};
7use map_model::{Map, Path, PathStep, Traversable};
8use sim::{AgentID, Analytics, PersonID, Problem, TripID, TripInfo, TripPhase, TripPhaseType};
9use synthpop::{TripEndpoint, TripMode};
10use widgetry::{
11    Color, ControlState, DrawWithTooltips, EventCtx, GeomBatch, Line, LinePlot, PlotOptions,
12    RewriteColor, Series, Text, TextExt, Widget,
13};
14
15use crate::app::App;
16use crate::common::color_for_trip_phase;
17use crate::info::{make_table, Details, Tab};
18
19#[derive(Clone)]
20pub struct OpenTrip {
21    pub show_after: bool,
22    // (unzoomed, zoomed). Indexed by order of TripPhase.
23    cached_routes: Vec<Option<(Polygon, Vec<Polygon>)>>,
24}
25
26// Ignore cached_routes
27impl std::cmp::PartialEq for OpenTrip {
28    fn eq(&self, other: &OpenTrip) -> bool {
29        self.show_after == other.show_after
30    }
31}
32
33impl OpenTrip {
34    pub fn single(id: TripID) -> BTreeMap<TripID, OpenTrip> {
35        btreemap! { id => OpenTrip::new() }
36    }
37
38    pub fn new() -> OpenTrip {
39        OpenTrip {
40            show_after: true,
41            cached_routes: Vec::new(),
42        }
43    }
44}
45
46pub fn ongoing(
47    ctx: &mut EventCtx,
48    app: &App,
49    id: TripID,
50    agent: AgentID,
51    open_trip: &mut OpenTrip,
52    details: &mut Details,
53) -> Widget {
54    let phases = app
55        .primary
56        .sim
57        .get_analytics()
58        .get_trip_phases(id, &app.primary.map);
59    let trip = app.primary.sim.trip_info(id);
60
61    let col_width = Percent::int(7);
62    let props = app.primary.sim.agent_properties(&app.primary.map, agent);
63    let activity = agent.to_type().ongoing_verb();
64    let time_so_far = app.primary.sim.time() - trip.departure;
65
66    let mut col = Vec::new();
67
68    {
69        col.push(Widget::custom_row(vec![
70            Widget::custom_row(vec![Line("Trip time").secondary().into_widget(ctx)])
71                .force_width_window_pct(ctx, col_width),
72            Text::from_all(vec![
73                Line(props.total_time.to_string(&app.opts.units)),
74                Line(format!(
75                    " {} / {} this trip",
76                    activity,
77                    time_so_far.to_string(&app.opts.units)
78                ))
79                .secondary(),
80            ])
81            .into_widget(ctx),
82        ]));
83    }
84    {
85        col.push(Widget::custom_row(vec![
86            Widget::custom_row(vec![Line("Distance").secondary().into_widget(ctx)])
87                .force_width_window_pct(ctx, col_width),
88            Text::from_all(vec![
89                Line(props.dist_crossed.to_string(&app.opts.units)),
90                Line(format!("/{}", props.total_dist.to_string(&app.opts.units))).secondary(),
91            ])
92            .into_widget(ctx),
93        ]));
94    }
95    {
96        col.push(Widget::custom_row(vec![
97            Line("Waiting")
98                .secondary()
99                .into_widget(ctx)
100                .container()
101                .force_width_window_pct(ctx, col_width),
102            Widget::col(vec![
103                format!("{} here", props.waiting_here.to_string(&app.opts.units)).text_widget(ctx),
104                Text::from_all(vec![
105                    if props.total_waiting != Duration::ZERO {
106                        Line(format!(
107                            "{}%",
108                            (100.0 * (props.total_waiting / time_so_far)) as usize
109                        ))
110                    } else {
111                        Line("0%")
112                    },
113                    Line(format!(" total of {} time spent waiting", activity)).secondary(),
114                ])
115                .into_widget(ctx),
116            ]),
117        ]));
118    }
119    col.push(describe_problems(
120        ctx,
121        app.primary.sim.get_analytics(),
122        id,
123        &trip,
124        col_width,
125    ));
126    {
127        col.push(Widget::custom_row(vec![
128            Widget::custom_row(vec![Line("Purpose").secondary().into_widget(ctx)])
129                .force_width_window_pct(ctx, col_width),
130            Line(trip.purpose.to_string()).secondary().into_widget(ctx),
131        ]));
132    }
133
134    col.push(make_trip_details(
135        ctx,
136        app,
137        id,
138        open_trip,
139        details,
140        phases,
141        &app.primary.map,
142        Some(props.dist_crossed.safe_percent(props.total_dist)),
143    ));
144    Widget::col(col)
145}
146
147pub fn future(
148    ctx: &mut EventCtx,
149    app: &App,
150    id: TripID,
151    open_trip: &mut OpenTrip,
152    details: &mut Details,
153) -> Widget {
154    let trip = app.primary.sim.trip_info(id);
155
156    let mut col = Vec::new();
157
158    let now = app.primary.sim.time();
159    if now > trip.departure {
160        col.extend(make_table(
161            ctx,
162            vec![(
163                "Start delayed",
164                (now - trip.departure).to_string(&app.opts.units),
165            )],
166        ));
167    }
168
169    if let Some(estimated_trip_time) = app
170        .has_prebaked()
171        .and_then(|_| app.prebaked().finished_trip_time(id))
172    {
173        col.extend(make_table(
174            ctx,
175            vec![
176                (
177                    "Estimated trip time",
178                    estimated_trip_time.to_string(&app.opts.units),
179                ),
180                ("Purpose", trip.purpose.to_string()),
181            ],
182        ));
183
184        let unedited_map = app
185            .primary
186            .unedited_map
187            .as_ref()
188            .unwrap_or(&app.primary.map);
189        let phases = app.prebaked().get_trip_phases(id, unedited_map);
190        col.push(make_trip_details(
191            ctx,
192            app,
193            id,
194            open_trip,
195            details,
196            phases,
197            unedited_map,
198            None,
199        ));
200    } else {
201        col.extend(make_table(ctx, vec![("Purpose", trip.purpose.to_string())]));
202
203        col.push(make_trip_details(
204            ctx,
205            app,
206            id,
207            open_trip,
208            details,
209            Vec::new(),
210            &app.primary.map,
211            None,
212        ));
213    }
214
215    Widget::col(col)
216}
217
218pub fn finished(
219    ctx: &mut EventCtx,
220    app: &App,
221    person: PersonID,
222    open_trips: &mut BTreeMap<TripID, OpenTrip>,
223    id: TripID,
224    details: &mut Details,
225) -> Widget {
226    let trip = app.primary.sim.trip_info(id);
227    let (phases, map_for_pathfinding) = if open_trips[&id].show_after {
228        (
229            app.primary
230                .sim
231                .get_analytics()
232                .get_trip_phases(id, &app.primary.map),
233            &app.primary.map,
234        )
235    } else {
236        let unedited_map = app
237            .primary
238            .unedited_map
239            .as_ref()
240            .unwrap_or(&app.primary.map);
241        (
242            app.prebaked().get_trip_phases(id, unedited_map),
243            unedited_map,
244        )
245    };
246
247    let mut col = Vec::new();
248
249    if open_trips[&id].show_after && app.has_prebaked().is_some() {
250        let mut open = open_trips.clone();
251        open.insert(
252            id,
253            OpenTrip {
254                show_after: false,
255                cached_routes: Vec::new(),
256            },
257        );
258        details.hyperlinks.insert(
259            format!("show before changes for {}", id),
260            Tab::PersonTrips(person, open),
261        );
262        col.push(
263            ctx.style()
264                .btn_outline
265                .btn()
266                .label_styled_text(
267                    Text::from_all(vec![
268                        Line("After / "),
269                        Line("Before").secondary(),
270                        Line(" "),
271                        Line(&app.primary.map.get_edits().edits_name).underlined(),
272                    ]),
273                    ControlState::Default,
274                )
275                .build_widget(ctx, format!("show before changes for {}", id)),
276        );
277    } else if app.has_prebaked().is_some() {
278        let mut open = open_trips.clone();
279        open.insert(id, OpenTrip::new());
280        details.hyperlinks.insert(
281            format!("show after changes for {}", id),
282            Tab::PersonTrips(person, open),
283        );
284        col.push(
285            ctx.style()
286                .btn_outline
287                .btn()
288                .label_styled_text(
289                    Text::from_all(vec![
290                        Line("After / ").secondary(),
291                        Line("Before"),
292                        Line(" "),
293                        Line(&app.primary.map.get_edits().edits_name).underlined(),
294                    ]),
295                    ControlState::Default,
296                )
297                .build_widget(ctx, format!("show after changes for {}", id)),
298        );
299    }
300
301    let col_width = Percent::int(15);
302    {
303        if let Some(end_time) = phases.last().as_ref().and_then(|p| p.end_time) {
304            col.push(Widget::custom_row(vec![
305                Widget::custom_row(vec![Line("Trip time").secondary().into_widget(ctx)])
306                    .force_width_window_pct(ctx, col_width),
307                (end_time - trip.departure)
308                    .to_string(&app.opts.units)
309                    .text_widget(ctx),
310            ]));
311        } else {
312            col.push(Widget::custom_row(vec![
313                Widget::custom_row(vec![Line("Trip time").secondary().into_widget(ctx)])
314                    .force_width_window_pct(ctx, col_width),
315                "Trip didn't complete before map changes".text_widget(ctx),
316            ]));
317        }
318
319        // TODO This is always the waiting time in the current simulation, even if we've chosen to
320        // look at the prebaked results! Misleading -- change the text.
321        let (_, waiting, _) = app.primary.sim.finished_trip_details(id).unwrap();
322        col.push(Widget::custom_row(vec![
323            Widget::custom_row(vec![Line("Total waiting time")
324                .secondary()
325                .into_widget(ctx)])
326            .force_width_window_pct(ctx, col_width),
327            waiting.to_string(&app.opts.units).text_widget(ctx),
328        ]));
329
330        col.push(Widget::custom_row(vec![
331            Widget::custom_row(vec![Line("Purpose").secondary().into_widget(ctx)])
332                .force_width_window_pct(ctx, col_width),
333            Line(trip.purpose.to_string()).secondary().into_widget(ctx),
334        ]));
335    }
336    col.push(describe_problems(
337        ctx,
338        if open_trips[&id].show_after {
339            app.primary.sim.get_analytics()
340        } else {
341            app.prebaked()
342        },
343        id,
344        &trip,
345        col_width,
346    ));
347
348    col.push(make_trip_details(
349        ctx,
350        app,
351        id,
352        open_trips.get_mut(&id).unwrap(),
353        details,
354        phases,
355        map_for_pathfinding,
356        None,
357    ));
358    Widget::col(col)
359}
360
361pub fn cancelled(
362    ctx: &mut EventCtx,
363    app: &App,
364    id: TripID,
365    open_trip: &mut OpenTrip,
366    details: &mut Details,
367) -> Widget {
368    let trip = app.primary.sim.trip_info(id);
369
370    let mut col = vec![Text::from(format!(
371        "Trip cancelled: {}",
372        trip.cancellation_reason.as_ref().unwrap()
373    ))
374    .wrap_to_pct(ctx, 20)
375    .into_widget(ctx)];
376
377    col.extend(make_table(ctx, vec![("Purpose", trip.purpose.to_string())]));
378
379    col.push(make_trip_details(
380        ctx,
381        app,
382        id,
383        open_trip,
384        details,
385        Vec::new(),
386        &app.primary.map,
387        None,
388    ));
389
390    Widget::col(col)
391}
392
393fn describe_problems(
394    ctx: &mut EventCtx,
395    analytics: &Analytics,
396    id: TripID,
397    trip: &TripInfo,
398    col_width: Percent,
399) -> Widget {
400    match trip.mode {
401        TripMode::Walk => {
402            let mut arterial_intersection_crossings = 0;
403            let mut overcrowding = 0;
404            let empty = Vec::new();
405            for (_, problem) in analytics.problems_per_trip.get(&id).unwrap_or(&empty) {
406                match problem {
407                    Problem::ArterialIntersectionCrossing(_) => {
408                        arterial_intersection_crossings += 1;
409                    }
410                    Problem::PedestrianOvercrowding(_) => {
411                        overcrowding += 1;
412                    }
413                    _ => {}
414                }
415            }
416            let mut txt = Text::new();
417            txt.add_appended(vec![
418                Line(arterial_intersection_crossings.to_string()),
419                if arterial_intersection_crossings == 1 {
420                    Line(" crossing at wide intersections")
421                } else {
422                    Line(" crossings at wide intersections")
423                }
424                .secondary(),
425            ]);
426            txt.add_line(Line(format!("{overcrowding} overcrowded sidewalks crossed")).secondary());
427
428            Widget::custom_row(vec![
429                Line("Risk Exposure")
430                    .secondary()
431                    .into_widget(ctx)
432                    .container()
433                    .force_width_window_pct(ctx, col_width),
434                txt.into_widget(ctx),
435            ])
436        }
437        TripMode::Bike => {
438            let mut count_complex_intersections = 0;
439            let mut count_overtakes = 0;
440            let empty = Vec::new();
441            for (_, problem) in analytics.problems_per_trip.get(&id).unwrap_or(&empty) {
442                match problem {
443                    Problem::ComplexIntersectionCrossing(_) => {
444                        count_complex_intersections += 1;
445                    }
446                    Problem::OvertakeDesired(_) => {
447                        count_overtakes += 1;
448                    }
449                    _ => {}
450                }
451            }
452            let mut txt = Text::new();
453            txt.add_appended(vec![
454                Line(count_complex_intersections.to_string()),
455                if count_complex_intersections == 1 {
456                    Line(" crossing at complex intersections")
457                } else {
458                    Line(" crossings at complex intersections")
459                }
460                .secondary(),
461            ]);
462            txt.add_appended(vec![
463                Line(count_overtakes.to_string()),
464                if count_overtakes == 1 {
465                    Line(" vehicle wanted to over-take")
466                } else {
467                    Line(" vehicles wanted to over-take")
468                }
469                .secondary(),
470            ]);
471
472            Widget::custom_row(vec![
473                Line("Risk Exposure")
474                    .secondary()
475                    .into_widget(ctx)
476                    .container()
477                    .force_width_window_pct(ctx, col_width),
478                txt.into_widget(ctx),
479            ])
480        }
481        _ => Widget::nothing(),
482    }
483}
484
485fn draw_problems(
486    ctx: &EventCtx,
487    app: &App,
488    analytics: &Analytics,
489    details: &mut Details,
490    id: TripID,
491    map: &Map,
492) {
493    let empty = Vec::new();
494    for (time, problem) in analytics.problems_per_trip.get(&id).unwrap_or(&empty) {
495        match problem {
496            Problem::IntersectionDelay(i, delay) => {
497                let i = map.get_i(*i);
498                // TODO These thresholds don't match what we use as thresholds in the simulation.
499                let (slow, slower) = if i.is_traffic_signal() {
500                    (Duration::seconds(30.0), Duration::minutes(2))
501                } else {
502                    (Duration::seconds(5.0), Duration::seconds(30.0))
503                };
504                let (fg_color, bg_color) = if *delay < slow {
505                    (Color::WHITE, app.cs.slow_intersection)
506                } else if *delay < slower {
507                    (Color::BLACK, app.cs.slower_intersection)
508                } else {
509                    (Color::WHITE, app.cs.slowest_intersection)
510                };
511                details.draw_extra.unzoomed.append(
512                    Text::from(Line(format!("{}", delay)).fg(fg_color))
513                        .bg(bg_color)
514                        .render(ctx)
515                        .centered_on(i.polygon.center()),
516                );
517                details.draw_extra.zoomed.append(
518                    Text::from(Line(format!("{}", delay)).fg(fg_color))
519                        .bg(bg_color)
520                        .render(ctx)
521                        .scale(0.4)
522                        .centered_on(i.polygon.center()),
523                );
524                details.tooltips.push((
525                    i.polygon.clone(),
526                    Text::from(Line(format!("{} delay here", delay))),
527                    // Rewind to just before the agent starts waiting
528                    (id, *time - *delay - Duration::seconds(5.0)),
529                ));
530            }
531            Problem::ComplexIntersectionCrossing(i) => {
532                let i = map.get_i(*i);
533                details.draw_extra.unzoomed.append(
534                    GeomBatch::load_svg(ctx, "system/assets/tools/alert.svg")
535                        .centered_on(i.polygon.center())
536                        .color(RewriteColor::ChangeAlpha(0.8)),
537                );
538                details.draw_extra.zoomed.append(
539                    GeomBatch::load_svg(ctx, "system/assets/tools/alert.svg")
540                        .scale(0.5)
541                        .color(RewriteColor::ChangeAlpha(0.5))
542                        .centered_on(i.polygon.center()),
543                );
544                details.tooltips.push((
545                    i.polygon.clone(),
546                    Text::from_multiline(vec![
547                        Line(format!("This intersection has {} legs", i.roads.len())),
548                        Line("This has an increased risk of crash or injury for cyclists"),
549                        Line("Source: 2020 Seattle DOT Safety Analysis"),
550                    ]),
551                    (id, *time),
552                ));
553            }
554            Problem::OvertakeDesired(on) => {
555                let pt = on.get_polyline(map).middle();
556                details.draw_extra.unzoomed.append(
557                    GeomBatch::load_svg(ctx, "system/assets/tools/alert.svg")
558                        .centered_on(pt)
559                        .color(RewriteColor::ChangeAlpha(0.8)),
560                );
561                details.draw_extra.zoomed.append(
562                    GeomBatch::load_svg(ctx, "system/assets/tools/alert.svg")
563                        .scale(0.5)
564                        .color(RewriteColor::ChangeAlpha(0.5))
565                        .centered_on(pt),
566                );
567                details.tooltips.push((
568                    match on {
569                        Traversable::Lane(l) => map.get_parent(*l).get_thick_polygon(),
570                        Traversable::Turn(t) => map.get_i(t.parent).polygon.clone(),
571                    },
572                    Text::from("A vehicle wanted to over-take this cyclist near here."),
573                    (id, *time),
574                ));
575            }
576            Problem::ArterialIntersectionCrossing(t) => {
577                let t = map.get_t(*t);
578
579                let geom = t.geom.make_polygons(Distance::meters(10.0));
580                details.draw_extra.unzoomed.append(
581                    GeomBatch::load_svg(ctx, "system/assets/tools/alert.svg")
582                        .centered_on(geom.center())
583                        .color(RewriteColor::ChangeAlpha(0.8)),
584                );
585                details.draw_extra.zoomed.append(
586                    GeomBatch::load_svg(ctx, "system/assets/tools/alert.svg")
587                        .scale(0.5)
588                        .color(RewriteColor::ChangeAlpha(0.5))
589                        .centered_on(geom.center()),
590                );
591                details.tooltips.push((
592                    geom,
593                    Text::from_multiline(vec![
594                        Line("Arterial intersections have an increased risk of crash or injury for pedestrians"),
595                        Line("Source: 2020 Seattle DOT Safety Analysis"),
596                    ]),
597                    (id, *time)
598                ));
599            }
600            Problem::PedestrianOvercrowding(on) => {
601                let pt = on.get_polyline(map).middle();
602                details.draw_extra.unzoomed.append(
603                    GeomBatch::load_svg(ctx, "system/assets/tools/alert.svg")
604                        .centered_on(pt)
605                        .color(RewriteColor::ChangeAlpha(0.8)),
606                );
607                details.draw_extra.zoomed.append(
608                    GeomBatch::load_svg(ctx, "system/assets/tools/alert.svg")
609                        .scale(0.5)
610                        .color(RewriteColor::ChangeAlpha(0.5))
611                        .centered_on(pt),
612                );
613                details.tooltips.push((
614                    match on {
615                        Traversable::Lane(l) => map.get_parent(*l).get_thick_polygon(),
616                        Traversable::Turn(t) => map.get_i(t.parent).polygon.clone(),
617                    },
618                    Text::from("Too many pedestrians are crowded together here."),
619                    (id, *time),
620                ));
621            }
622        }
623    }
624}
625
626/// Draws the timeline for a single trip, with tooltips
627fn make_timeline(
628    ctx: &mut EventCtx,
629    app: &App,
630    trip_id: TripID,
631    phases: &[TripPhase],
632    progress_along_path: Option<f64>,
633) -> Widget {
634    let map = &app.primary.map;
635    let sim = &app.primary.sim;
636
637    let total_width = 0.22 * ctx.canvas.window_width;
638    let trip = sim.trip_info(trip_id);
639    let end_time = phases.last().as_ref().and_then(|p| p.end_time);
640    let total_duration_so_far = end_time.unwrap_or_else(|| sim.time()) - trip.departure;
641
642    // Represent each phase of a trip as a rectangular segment, with width proportional to the
643    // phase's duration. As we go through, build up one batch to draw containing the rectangles and
644    // icons above them.
645    let mut batch = GeomBatch::new();
646    // And associate a tooltip with each rectangle segment
647    let mut tooltips = Vec::new();
648    // How far along are we from previous segments?
649    let mut x1 = 0.0;
650    let rectangle_height = 15.0;
651    let icon_height = 30.0;
652    for (idx, p) in phases.iter().enumerate() {
653        let mut tooltip = vec![
654            p.phase_type.describe(map),
655            format!("  Started at {}", p.start_time.ampm_tostring()),
656        ];
657        let phase_duration = if let Some(t2) = p.end_time {
658            let d = t2 - p.start_time;
659            tooltip.push(format!(
660                "  Ended at {} (duration: {})",
661                t2.ampm_tostring(),
662                d
663            ));
664            d
665        } else {
666            let d = sim.time() - p.start_time;
667            tooltip.push(format!(
668                "  Ongoing (duration so far: {})",
669                d.to_string(&app.opts.units)
670            ));
671            d
672        };
673        let percent_duration = if total_duration_so_far == Duration::ZERO {
674            0.0
675        } else {
676            phase_duration / total_duration_so_far
677        };
678        // Don't crash when this is too low
679        let percent_duration = percent_duration.max(0.01);
680        tooltip.push(format!(
681            "  {}% of trip percentage",
682            (100.0 * percent_duration) as usize
683        ));
684        tooltip.push(format!(
685            "  Total delayed time {}",
686            sim.trip_blocked_time(trip_id)
687        ));
688
689        let phase_width = total_width * percent_duration;
690        let rectangle =
691            Polygon::rectangle(phase_width, rectangle_height).translate(x1, icon_height);
692
693        tooltips.push((
694            rectangle.clone(),
695            Text::from_multiline(tooltip.into_iter().map(Line).collect()),
696            None,
697        ));
698
699        batch.push(
700            color_for_trip_phase(app, p.phase_type).alpha(0.7),
701            rectangle,
702        );
703        if idx == phases.len() - 1 {
704            // Show where we are in the trip currently
705            if let Some(p) = progress_along_path {
706                batch.append(
707                    GeomBatch::load_svg(ctx, "system/assets/timeline/current_pos.svg").centered_on(
708                        Pt2D::new(x1 + p * phase_width, icon_height + (rectangle_height / 2.0)),
709                    ),
710                );
711            }
712        }
713        batch.append(
714            GeomBatch::load_svg(
715                ctx.prerender,
716                match p.phase_type {
717                    TripPhaseType::Driving => "system/assets/timeline/driving.svg",
718                    TripPhaseType::Walking => "system/assets/timeline/walking.svg",
719                    TripPhaseType::Biking => "system/assets/timeline/biking.svg",
720                    TripPhaseType::Parking => "system/assets/timeline/parking.svg",
721                    TripPhaseType::WaitingForBus(_, _) => {
722                        "system/assets/timeline/waiting_for_bus.svg"
723                    }
724                    TripPhaseType::RidingBus(_, _, _) => "system/assets/timeline/riding_bus.svg",
725                    TripPhaseType::Cancelled | TripPhaseType::Finished => unreachable!(),
726                    TripPhaseType::DelayedStart => "system/assets/timeline/delayed_start.svg",
727                },
728            )
729            .centered_on(Pt2D::new(x1 + phase_width / 2.0, icon_height / 2.0)),
730        );
731
732        x1 += phase_width;
733    }
734
735    DrawWithTooltips::new_widget(ctx, batch, tooltips, Box::new(|_| GeomBatch::new()))
736}
737
738/// Creates the timeline, location warp, and time warp buttons for one trip, and draws the route on
739/// the map.
740fn make_trip_details(
741    ctx: &mut EventCtx,
742    app: &App,
743    trip_id: TripID,
744    open_trip: &mut OpenTrip,
745    details: &mut Details,
746    phases: Vec<TripPhase>,
747    map_for_pathfinding: &Map,
748    progress_along_path: Option<f64>,
749) -> Widget {
750    let sim = &app.primary.sim;
751    let trip = sim.trip_info(trip_id);
752    let end_time = phases.last().as_ref().and_then(|p| p.end_time);
753
754    let start_btn = {
755        let (id, center, name) = endpoint(&trip.start, app);
756        details
757            .warpers
758            .insert(format!("jump to start of {}", trip_id), id);
759
760        details
761            .draw_extra
762            .unzoomed
763            .append(map_gui::tools::start_marker(ctx, center, 2.0));
764        details
765            .draw_extra
766            .zoomed
767            .append(map_gui::tools::start_marker(ctx, center, 0.5));
768
769        ctx.style()
770            .btn_plain
771            .icon("system/assets/timeline/start_pos.svg")
772            .image_color(RewriteColor::NoOp, ControlState::Default)
773            .tooltip(name)
774            .build_widget(ctx, format!("jump to start of {}", trip_id))
775    };
776
777    let goal_btn = {
778        let (id, center, name) = endpoint(&trip.end, app);
779        details
780            .warpers
781            .insert(format!("jump to goal of {}", trip_id), id);
782
783        details
784            .draw_extra
785            .unzoomed
786            .append(map_gui::tools::goal_marker(ctx, center, 2.0));
787        details
788            .draw_extra
789            .zoomed
790            .append(map_gui::tools::goal_marker(ctx, center, 0.5));
791
792        ctx.style()
793            .btn_plain
794            .icon("system/assets/timeline/goal_pos.svg")
795            .image_color(RewriteColor::NoOp, ControlState::Default)
796            .tooltip(name)
797            .build_widget(ctx, format!("jump to goal of {}", trip_id))
798    };
799
800    let timeline = make_timeline(ctx, app, trip_id, &phases, progress_along_path);
801    let mut elevation = Vec::new();
802    let mut path_impossible = false;
803    for (idx, p) in phases.into_iter().enumerate() {
804        let color = color_for_trip_phase(app, p.phase_type).alpha(0.7);
805        if let Some(path) = &p.path {
806            // Don't show the elevation plot for somebody walking to their car
807            if ((trip.mode == TripMode::Walk || trip.mode == TripMode::Transit)
808                && p.phase_type == TripPhaseType::Walking)
809                || (trip.mode == TripMode::Bike && p.phase_type == TripPhaseType::Biking)
810            {
811                elevation.push(make_elevation(
812                    ctx,
813                    color,
814                    p.phase_type == TripPhaseType::Walking,
815                    path,
816                    map_for_pathfinding,
817                    app.opts.units,
818                ));
819            }
820
821            // This is expensive, so cache please
822            if idx == open_trip.cached_routes.len() {
823                if let Some(trace) = path.trace(map_for_pathfinding) {
824                    open_trip.cached_routes.push(Some((
825                        trace.make_polygons(Distance::meters(10.0)),
826                        trace.dashed_lines(
827                            Distance::meters(0.75),
828                            Distance::meters(1.0),
829                            Distance::meters(0.4),
830                        ),
831                    )));
832                } else {
833                    open_trip.cached_routes.push(None);
834                }
835            }
836            if let Some((ref unzoomed, ref zoomed)) = open_trip.cached_routes[idx] {
837                details.draw_extra.unzoomed.push(color, unzoomed.clone());
838                details.draw_extra.zoomed.extend(color, zoomed.clone());
839            }
840        } else if p.has_path_req {
841            path_impossible = true;
842        }
843        // Just fill this in so the indexing doesn't mess up
844        if idx == open_trip.cached_routes.len() {
845            open_trip.cached_routes.push(None);
846        }
847    }
848
849    let mut col = vec![
850        // TODO Button alignment is off. The timeline has some negative coordinates...
851        Widget::custom_row(vec![
852            start_btn.align_bottom(),
853            timeline,
854            goal_btn.align_bottom(),
855        ])
856        .evenly_spaced(),
857        Widget::row(vec![
858            trip.departure.ampm_tostring().text_widget(ctx),
859            if let Some(t) = end_time {
860                t.ampm_tostring().text_widget(ctx).align_right()
861            } else {
862                Widget::nothing()
863            },
864        ]),
865        Widget::row(vec![
866            if details.can_jump_to_time {
867                details.time_warpers.insert(
868                    format!("jump to {}", trip.departure),
869                    (trip_id, trip.departure),
870                );
871                ctx.style()
872                    .btn_plain
873                    .icon("system/assets/speed/jump_to_time.svg")
874                    .tooltip({
875                        let mut txt = Text::from("This will jump to ");
876                        txt.append(Line(trip.departure.ampm_tostring()).fg(Color::hex("#F9EC51")));
877                        txt.add_line("The simulation will continue, and your score");
878                        txt.add_line("will be calculated at this new time.");
879                        txt
880                    })
881                    .build_widget(ctx, format!("jump to {}", trip.departure))
882            } else {
883                Widget::nothing()
884            },
885            if let Some(t) = end_time {
886                if details.can_jump_to_time {
887                    details
888                        .time_warpers
889                        .insert(format!("jump to {}", t), (trip_id, t));
890                    ctx.style()
891                        .btn_plain
892                        .icon("system/assets/speed/jump_to_time.svg")
893                        .tooltip({
894                            let mut txt = Text::from("This will jump to ");
895                            txt.append(Line(t.ampm_tostring()).fg(Color::hex("#F9EC51")));
896                            txt.add_line("The simulation will continue, and your score");
897                            txt.add_line("will be calculated at this new time.");
898                            txt
899                        })
900                        .build_widget(ctx, format!("jump to {}", t))
901                        .align_right()
902                } else {
903                    Widget::nothing()
904                }
905            } else {
906                Widget::nothing()
907            },
908        ]),
909    ];
910    if path_impossible {
911        col.push("Map edits have disconnected the path taken before".text_widget(ctx));
912    }
913    col.extend(elevation);
914
915    let analytics = if app.has_prebaked().is_none() || open_trip.show_after {
916        app.primary.sim.get_analytics()
917    } else {
918        app.prebaked()
919    };
920    draw_problems(ctx, app, analytics, details, trip_id, map_for_pathfinding);
921    Widget::col(col)
922}
923
924fn make_elevation(
925    ctx: &EventCtx,
926    color: Color,
927    walking: bool,
928    path: &Path,
929    map: &Map,
930    unit_fmt: UnitFmt,
931) -> Widget {
932    let mut pts: Vec<(Distance, Distance)> = Vec::new();
933    let mut dist = Distance::ZERO;
934    for step in path.get_steps() {
935        if let PathStep::Turn(t) | PathStep::ContraflowTurn(t) = step {
936            pts.push((dist, map.get_i(t.parent).elevation));
937        }
938        dist += step.as_traversable().get_polyline(map).length();
939    }
940
941    // TODO Show roughly where we are in the trip; use distance covered by current path for this
942    LinePlot::new_widget(
943        ctx,
944        "elevation",
945        vec![Series {
946            label: if walking {
947                "Elevation for walking"
948            } else {
949                "Elevation for biking"
950            }
951            .to_string(),
952            color,
953            pts,
954        }],
955        PlotOptions {
956            max_x: Some(dist.round_up_for_axis()),
957            // We want to use the same Y scale for this plot when comparing before/after map edits.
958            // If we use the max elevation encountered along the route, then no matter how we
959            // round, there are always edge cases where the scale will jump. So just use the
960            // maximum elevation from the entire map.
961            max_y: Some(map.max_elevation().round_up_for_axis()),
962            ..Default::default()
963        },
964        unit_fmt,
965    )
966}
967
968// (ID, center, name)
969fn endpoint(endpt: &TripEndpoint, app: &App) -> (ID, Pt2D, String) {
970    match endpt {
971        TripEndpoint::Building(b) => {
972            let bldg = app.primary.map.get_b(*b);
973            (ID::Building(*b), bldg.label_center, bldg.address.clone())
974        }
975        TripEndpoint::Border(i) => {
976            let i = app.primary.map.get_i(*i);
977            (
978                ID::Intersection(i.id),
979                i.polygon.center(),
980                format!(
981                    "off map, via {}",
982                    i.name(app.opts.language.as_ref(), &app.primary.map)
983                ),
984            )
985        }
986        TripEndpoint::SuddenlyAppear(pos) => (
987            ID::Lane(pos.lane()),
988            pos.pt(&app.primary.map),
989            format!(
990                "suddenly appear {} along",
991                pos.dist_along().to_string(&app.opts.units)
992            ),
993        ),
994    }
995}