game/sandbox/dashboards/
trip_table.rs

1use std::collections::{BTreeSet, HashMap};
2use std::io::Write;
3
4use abstutil::prettyprint_usize;
5use geom::{Duration, Polygon, Time};
6use map_gui::tools::{checkbox_per_mode, color_for_mode};
7use sim::TripID;
8use synthpop::{TripEndpoint, TripMode};
9use widgetry::table::{Col, Filter, Table};
10use widgetry::tools::PopupMsg;
11use widgetry::{
12    Color, EventCtx, Filler, GeomBatch, GfxCtx, Line, Outcome, Panel, Stash, State, TabController,
13    Text, Toggle, Widget,
14};
15
16use super::generic_trip_table::{open_trip_transition, preview_trip};
17use super::selector::RectangularSelector;
18use super::DashTab;
19use crate::app::{App, Transition};
20use crate::common::cmp_duration_shorter;
21
22pub struct TripTable {
23    tab: DashTab,
24    table_tabs: TabController,
25    panel: Panel,
26    finished_trips_table: Table<App, FinishedTrip, Filters>,
27    cancelled_trips_table: Table<App, CancelledTrip, Filters>,
28    unfinished_trips_table: Table<App, UnfinishedTrip, Filters>,
29    recompute_filters: bool,
30}
31
32impl TripTable {
33    pub fn new(ctx: &mut EventCtx, app: &App) -> Self {
34        let mut tabs = TabController::new("trips_tabs");
35
36        let (finished, unfinished) = app.primary.sim.num_trips();
37        let mut cancelled = 0;
38        // TODO Can we avoid iterating through this again?
39        for (_, _, _, maybe_dt) in &app.primary.sim.get_analytics().finished_trips {
40            if maybe_dt.is_none() {
41                cancelled += 1;
42            }
43        }
44        let total = finished + cancelled + unfinished;
45
46        let percent = |x: usize| -> f64 {
47            if total > 0 {
48                (x as f64) / (total as f64) * 100.0
49            } else {
50                0.0
51            }
52        };
53
54        let finished_trips_btn = ctx
55            .style()
56            .btn_tab
57            .text(format!(
58                "Finished Trips: {} ({:.1}%)",
59                prettyprint_usize(finished),
60                percent(finished),
61            ))
62            .tooltip("Finished Trips");
63
64        let finished_trips_table = make_table_finished_trips(app);
65        let finished_trips_content = Widget::col(vec![
66            finished_trips_table.render(ctx, app),
67            ctx.style()
68                .btn_plain
69                .text("Export to CSV")
70                .build_def(ctx)
71                .align_bottom(),
72            Filler::square_width(ctx, 0.15)
73                .named("preview")
74                .centered_horiz(),
75        ]);
76        tabs.push_tab(finished_trips_btn, finished_trips_content);
77
78        let cancelled_trips_table = make_table_cancelled_trips(app);
79        let cancelled_trips_btn = ctx
80            .style()
81            .btn_tab
82            .text(format!("Cancelled Trips: {}", prettyprint_usize(cancelled)))
83            .tooltip("Cancelled Trips");
84        let cancelled_trips_content = Widget::col(vec![
85            cancelled_trips_table.render(ctx, app),
86            Filler::square_width(ctx, 0.15)
87                .named("preview")
88                .centered_horiz(),
89        ]);
90        tabs.push_tab(cancelled_trips_btn, cancelled_trips_content);
91
92        let unfinished_trips_table = make_table_unfinished_trips(app);
93        let unfinished_trips_btn = ctx
94            .style()
95            .btn_tab
96            .text(format!(
97                "Unfinished Trips: {} ({:.1}%)",
98                prettyprint_usize(unfinished),
99                percent(unfinished)
100            ))
101            .tooltip("Unfinished Trips");
102        let unfinished_trips_content = Widget::col(vec![
103            unfinished_trips_table.render(ctx, app),
104            Filler::square_width(ctx, 0.15)
105                .named("preview")
106                .centered_horiz(),
107        ]);
108        tabs.push_tab(unfinished_trips_btn, unfinished_trips_content);
109
110        let panel = Panel::new_builder(Widget::col(vec![
111            DashTab::TripTable.picker(ctx, app),
112            tabs.build_widget(ctx),
113        ]))
114        .exact_size_percent(90, 90)
115        .build(ctx);
116
117        Self {
118            tab: DashTab::TripTable,
119            table_tabs: tabs,
120            panel,
121            finished_trips_table,
122            cancelled_trips_table,
123            unfinished_trips_table,
124            recompute_filters: false,
125        }
126    }
127}
128
129impl State<App> for TripTable {
130    fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Transition {
131        match self.panel.event(ctx) {
132            Outcome::Clicked(x) => {
133                if x == "Export to CSV" {
134                    return Transition::Push(match export_trip_table(app) {
135                        Ok(path) => PopupMsg::new_state(
136                            ctx,
137                            "Data exported",
138                            vec![format!("Data exported to {}", path)],
139                        ),
140                        Err(err) => {
141                            PopupMsg::new_state(ctx, "Export failed", vec![err.to_string()])
142                        }
143                    });
144                }
145                if self.table_tabs.active_tab_idx() == 0 && self.finished_trips_table.clicked(&x) {
146                    self.finished_trips_table
147                        .replace_render(ctx, app, &mut self.panel);
148                } else if self.table_tabs.active_tab_idx() == 1
149                    && self.cancelled_trips_table.clicked(&x)
150                {
151                    self.cancelled_trips_table
152                        .replace_render(ctx, app, &mut self.panel);
153                } else if self.table_tabs.active_tab_idx() == 2
154                    && self.unfinished_trips_table.clicked(&x)
155                {
156                    self.unfinished_trips_table
157                        .replace_render(ctx, app, &mut self.panel);
158                } else if let Ok(idx) = x.parse::<usize>() {
159                    return open_trip_transition(app, idx);
160                } else if x == "close" {
161                    return Transition::Pop;
162                } else if self.table_tabs.handle_action(ctx, &x, &mut self.panel) {
163                    // if true, tabs handled the action
164                } else if x == "filter starts" {
165                    // Set the recompute_filters bit, so we re-apply the filters when the selector
166                    // state is done.
167                    self.recompute_filters = true;
168                    return Transition::Push(RectangularSelector::new_state(
169                        ctx,
170                        self.panel.stash("starts_in"),
171                    ));
172                } else if x == "filter ends" {
173                    self.recompute_filters = true;
174                    return Transition::Push(RectangularSelector::new_state(
175                        ctx,
176                        self.panel.stash("ends_in"),
177                    ));
178                } else {
179                    unreachable!("unhandled action: {}", x)
180                }
181            }
182            Outcome::Changed(_) => {
183                if let Some(t) = self.tab.transition(ctx, app, &self.panel) {
184                    return t;
185                }
186
187                self.recompute_filters = true;
188            }
189            _ => {}
190        }
191
192        if self.recompute_filters {
193            self.recompute_filters = false;
194            match self.table_tabs.active_tab_idx() {
195                0 => {
196                    self.finished_trips_table.panel_changed(&self.panel);
197                    self.finished_trips_table
198                        .replace_render(ctx, app, &mut self.panel);
199                }
200                1 => {
201                    self.cancelled_trips_table.panel_changed(&self.panel);
202                    self.cancelled_trips_table
203                        .replace_render(ctx, app, &mut self.panel);
204                }
205                2 => {
206                    self.unfinished_trips_table.panel_changed(&self.panel);
207                    self.unfinished_trips_table
208                        .replace_render(ctx, app, &mut self.panel);
209                }
210                _ => unreachable!(),
211            }
212        }
213
214        Transition::Keep
215    }
216
217    fn draw(&self, g: &mut GfxCtx, app: &App) {
218        self.panel.draw(g);
219        let mut batch = GeomBatch::new();
220        if self.panel.has_widget("starts_in") {
221            if let Some(p) = self.panel.clone_stashed::<Option<Polygon>>("starts_in") {
222                batch.push(Color::RED.alpha(0.5), p);
223            }
224            if let Some(p) = self.panel.clone_stashed::<Option<Polygon>>("ends_in") {
225                batch.push(Color::BLUE.alpha(0.5), p);
226            }
227        }
228        preview_trip(g, app, &self.panel, batch, None);
229    }
230}
231
232struct FinishedTrip {
233    id: TripID,
234    mode: TripMode,
235    modified: bool,
236    start: TripEndpoint,
237    end: TripEndpoint,
238    departure: Time,
239    duration_after: Duration,
240    duration_before: Duration,
241    waiting: Duration,
242    percent_waiting: usize,
243}
244
245struct CancelledTrip {
246    id: TripID,
247    mode: TripMode,
248    departure: Time,
249    start: TripEndpoint,
250    end: TripEndpoint,
251    duration_before: Duration,
252    reason: String,
253}
254
255struct UnfinishedTrip {
256    id: TripID,
257    mode: TripMode,
258    departure: Time,
259    duration_before: Duration,
260    // TODO Estimated wait time?
261}
262
263struct Filters {
264    modes: BTreeSet<TripMode>,
265    off_map_starts: bool,
266    off_map_ends: bool,
267    starts_in: Option<Polygon>,
268    ends_in: Option<Polygon>,
269    unmodified_trips: bool,
270    modified_trips: bool,
271}
272
273fn produce_raw_data(app: &App) -> (Vec<FinishedTrip>, Vec<CancelledTrip>) {
274    let mut finished = Vec::new();
275    let mut cancelled = Vec::new();
276
277    // Only make one pass through prebaked data
278    let trip_times_before = if app.has_prebaked().is_some() {
279        let mut times = HashMap::new();
280        for (_, id, _, maybe_dt) in &app.prebaked().finished_trips {
281            if let Some(dt) = maybe_dt {
282                times.insert(*id, *dt);
283            }
284        }
285        Some(times)
286    } else {
287        None
288    };
289
290    let sim = &app.primary.sim;
291    for (_, id, mode, maybe_duration_after) in &sim.get_analytics().finished_trips {
292        let trip = sim.trip_info(*id);
293        let duration_before = if let Some(ref times) = trip_times_before {
294            times.get(id).cloned()
295        } else {
296            Some(Duration::ZERO)
297        };
298
299        if maybe_duration_after.is_none() || duration_before.is_none() {
300            let reason = trip.cancellation_reason.clone().unwrap_or_else(|| {
301                "trip succeeded now, but not before the current proposal".to_string()
302            });
303            cancelled.push(CancelledTrip {
304                id: *id,
305                mode: *mode,
306                departure: trip.departure,
307                start: trip.start,
308                end: trip.end,
309                duration_before: duration_before.unwrap_or(Duration::ZERO),
310                reason,
311            });
312            continue;
313        };
314
315        let (_, waiting, _) = sim.finished_trip_details(*id).unwrap();
316
317        let duration_after = maybe_duration_after.unwrap();
318        finished.push(FinishedTrip {
319            id: *id,
320            mode: *mode,
321            departure: trip.departure,
322            modified: trip.modified,
323            start: trip.start,
324            end: trip.end,
325            duration_after,
326            duration_before: duration_before.unwrap(),
327            waiting,
328            percent_waiting: (100.0 * waiting / duration_after) as usize,
329        });
330    }
331
332    (finished, cancelled)
333}
334
335fn make_table_finished_trips(app: &App) -> Table<App, FinishedTrip, Filters> {
336    let (finished, _) = produce_raw_data(app);
337    let filter: Filter<App, FinishedTrip, Filters> = Filter {
338        state: Filters {
339            modes: TripMode::all().into_iter().collect(),
340            off_map_starts: true,
341            off_map_ends: true,
342            starts_in: None,
343            ends_in: None,
344            unmodified_trips: true,
345            modified_trips: true,
346        },
347        to_controls: Box::new(move |ctx, app, state| {
348            Widget::col(vec![
349                checkbox_per_mode(ctx, app, &state.modes),
350                Widget::row(vec![
351                    Toggle::switch(ctx, "starting off-map", None, state.off_map_starts),
352                    Toggle::switch(ctx, "ending off-map", None, state.off_map_ends),
353                    ctx.style().btn_plain.text("filter starts").build_def(ctx),
354                    ctx.style().btn_plain.text("filter ends").build_def(ctx),
355                    Stash::new_widget("starts_in", state.starts_in.clone()),
356                    Stash::new_widget("ends_in", state.ends_in.clone()),
357                    if app.primary.has_modified_trips {
358                        Toggle::switch(
359                            ctx,
360                            "trips unmodified by experiment",
361                            None,
362                            state.unmodified_trips,
363                        )
364                    } else {
365                        Widget::nothing()
366                    },
367                    if app.primary.has_modified_trips {
368                        Toggle::switch(
369                            ctx,
370                            "trips modified by experiment",
371                            None,
372                            state.modified_trips,
373                        )
374                    } else {
375                        Widget::nothing()
376                    },
377                ]),
378            ])
379        }),
380        from_controls: Box::new(|panel| {
381            let mut modes = BTreeSet::new();
382            for m in TripMode::all() {
383                if panel.is_checked(m.ongoing_verb()) {
384                    modes.insert(m);
385                }
386            }
387            Filters {
388                modes,
389                off_map_starts: panel.is_checked("starting off-map"),
390                off_map_ends: panel.is_checked("ending off-map"),
391                starts_in: panel.clone_stashed("starts_in"),
392                ends_in: panel.clone_stashed("ends_in"),
393                unmodified_trips: panel
394                    .maybe_is_checked("trips unmodified by experiment")
395                    .unwrap_or(true),
396                modified_trips: panel
397                    .maybe_is_checked("trips modified by experiment")
398                    .unwrap_or(true),
399            }
400        }),
401        apply: Box::new(|state, x, app| {
402            if !state.modes.contains(&x.mode) {
403                return false;
404            }
405            if !state.off_map_starts && matches!(x.start, TripEndpoint::Border(_)) {
406                return false;
407            }
408            if !state.off_map_ends && matches!(x.end, TripEndpoint::Border(_)) {
409                return false;
410            }
411            if let Some(ref polygon) = state.starts_in {
412                if !polygon.contains_pt(x.start.pt(&app.primary.map)) {
413                    return false;
414                }
415            }
416            if let Some(ref polygon) = state.ends_in {
417                if !polygon.contains_pt(x.end.pt(&app.primary.map)) {
418                    return false;
419                }
420            }
421            if !state.unmodified_trips && !x.modified {
422                return false;
423            }
424            if !state.modified_trips && x.modified {
425                return false;
426            }
427            true
428        }),
429    };
430
431    let mut table = Table::new(
432        "finished_trips_table",
433        finished,
434        Box::new(|x| x.id.0.to_string()),
435        "Percent waiting",
436        filter,
437    );
438    table.static_col("Trip ID", Box::new(|x| x.id.0.to_string()));
439    if app.primary.has_modified_trips {
440        table.static_col(
441            "Modified",
442            Box::new(|x| {
443                if x.modified {
444                    "Yes".to_string()
445                } else {
446                    "No".to_string()
447                }
448            }),
449        );
450    }
451    table.column(
452        "Type",
453        Box::new(|ctx, app, x| {
454            Text::from(Line(x.mode.ongoing_verb()).fg(color_for_mode(app, x.mode))).render(ctx)
455        }),
456        Col::Static,
457    );
458    table.column(
459        "Departure",
460        Box::new(|ctx, _, x| Text::from(x.departure.ampm_tostring()).render(ctx)),
461        Col::Sortable(Box::new(|rows| rows.sort_by_key(|x| x.departure))),
462    );
463    table.column(
464        "Duration",
465        Box::new(|ctx, app, x| Text::from(x.duration_after.to_string(&app.opts.units)).render(ctx)),
466        Col::Sortable(Box::new(|rows| rows.sort_by_key(|x| x.duration_after))),
467    );
468
469    if app.has_prebaked().is_some() {
470        table.column(
471            "Comparison",
472            Box::new(|ctx, app, x| {
473                Text::from_all(cmp_duration_shorter(
474                    app,
475                    x.duration_after,
476                    x.duration_before,
477                ))
478                .render(ctx)
479            }),
480            Col::Sortable(Box::new(|rows| {
481                rows.sort_by_key(|x| x.duration_after - x.duration_before)
482            })),
483        );
484        table.column(
485            "Normalized",
486            Box::new(|ctx, _, x| {
487                Text::from(match x.duration_after.cmp(&x.duration_before) {
488                    std::cmp::Ordering::Equal => "same".to_string(),
489                    std::cmp::Ordering::Less => {
490                        format!(
491                            "{}% faster",
492                            (100.0 * (1.0 - (x.duration_after / x.duration_before))) as usize
493                        )
494                    }
495                    std::cmp::Ordering::Greater => {
496                        format!(
497                            "{}% slower ",
498                            (100.0 * ((x.duration_after / x.duration_before) - 1.0)) as usize
499                        )
500                    }
501                })
502                .render(ctx)
503            }),
504            Col::Sortable(Box::new(|rows| {
505                rows.sort_by_key(|x| (100.0 * (x.duration_after / x.duration_before)) as isize)
506            })),
507        );
508    }
509
510    table.column(
511        "Time spent waiting",
512        Box::new(|ctx, app, x| Text::from(x.waiting.to_string(&app.opts.units)).render(ctx)),
513        Col::Sortable(Box::new(|rows| rows.sort_by_key(|x| x.waiting))),
514    );
515    table.column(
516        "Percent waiting",
517        Box::new(|ctx, _, x| Text::from(x.percent_waiting.to_string()).render(ctx)),
518        Col::Sortable(Box::new(|rows| rows.sort_by_key(|x| x.percent_waiting))),
519    );
520
521    table
522}
523
524fn make_table_cancelled_trips(app: &App) -> Table<App, CancelledTrip, Filters> {
525    let (_, cancelled) = produce_raw_data(app);
526    // Reuse the same filters, but ignore modified trips
527    let filter: Filter<App, CancelledTrip, Filters> = Filter {
528        state: Filters {
529            modes: TripMode::all().into_iter().collect(),
530            off_map_starts: true,
531            off_map_ends: true,
532            starts_in: None,
533            ends_in: None,
534            unmodified_trips: true,
535            modified_trips: true,
536        },
537        to_controls: Box::new(move |ctx, app, state| {
538            Widget::col(vec![
539                checkbox_per_mode(ctx, app, &state.modes),
540                Widget::row(vec![
541                    Toggle::switch(ctx, "starting off-map", None, state.off_map_starts),
542                    Toggle::switch(ctx, "ending off-map", None, state.off_map_ends),
543                ]),
544            ])
545        }),
546        from_controls: Box::new(|panel| {
547            let mut modes = BTreeSet::new();
548            for m in TripMode::all() {
549                if panel.is_checked(m.ongoing_verb()) {
550                    modes.insert(m);
551                }
552            }
553            Filters {
554                modes,
555                off_map_starts: panel.is_checked("starting off-map"),
556                off_map_ends: panel.is_checked("ending off-map"),
557                starts_in: None,
558                ends_in: None,
559                unmodified_trips: true,
560                modified_trips: true,
561            }
562        }),
563        apply: Box::new(|state, x, app| {
564            if !state.modes.contains(&x.mode) {
565                return false;
566            }
567            if !state.off_map_starts && matches!(x.start, TripEndpoint::Border(_)) {
568                return false;
569            }
570            if !state.off_map_ends && matches!(x.end, TripEndpoint::Border(_)) {
571                return false;
572            }
573            if let Some(ref polygon) = state.starts_in {
574                if !polygon.contains_pt(x.start.pt(&app.primary.map)) {
575                    return false;
576                }
577            }
578            if let Some(ref polygon) = state.ends_in {
579                if !polygon.contains_pt(x.end.pt(&app.primary.map)) {
580                    return false;
581                }
582            }
583            true
584        }),
585    };
586
587    let mut table = Table::new(
588        "cancelled_trips_table",
589        cancelled,
590        Box::new(|x| x.id.0.to_string()),
591        "Departure",
592        filter,
593    );
594    table.static_col("Trip ID", Box::new(|x| x.id.0.to_string()));
595    table.column(
596        "Type",
597        Box::new(|ctx, app, x| {
598            Text::from(Line(x.mode.ongoing_verb()).fg(color_for_mode(app, x.mode))).render(ctx)
599        }),
600        Col::Static,
601    );
602    table.column(
603        "Departure",
604        Box::new(|ctx, _, x| Text::from(x.departure.ampm_tostring()).render(ctx)),
605        Col::Sortable(Box::new(|rows| rows.sort_by_key(|x| x.departure))),
606    );
607    if app.has_prebaked().is_some() {
608        table.column(
609            "Estimated duration",
610            Box::new(|ctx, app, x| {
611                Text::from(x.duration_before.to_string(&app.opts.units)).render(ctx)
612            }),
613            Col::Sortable(Box::new(|rows| rows.sort_by_key(|x| x.duration_before))),
614        );
615    }
616    table.static_col("Reason", Box::new(|x| x.reason.clone()));
617
618    table
619}
620
621fn make_table_unfinished_trips(app: &App) -> Table<App, UnfinishedTrip, Filters> {
622    // Only make one pass through prebaked data
623    let trip_times_before = if app.has_prebaked().is_some() {
624        let mut times = HashMap::new();
625        for (_, id, _, maybe_dt) in &app.prebaked().finished_trips {
626            if let Some(dt) = maybe_dt {
627                times.insert(*id, *dt);
628            }
629        }
630        Some(times)
631    } else {
632        None
633    };
634    let mut unfinished = Vec::new();
635    for (id, trip) in app.primary.sim.all_trip_info() {
636        if app.primary.sim.finished_trip_details(id).is_none() {
637            let duration_before = trip_times_before
638                .as_ref()
639                .and_then(|times| times.get(&id))
640                .cloned()
641                .unwrap_or(Duration::ZERO);
642            unfinished.push(UnfinishedTrip {
643                id,
644                mode: trip.mode,
645                departure: trip.departure,
646                duration_before,
647            });
648        }
649    }
650
651    // Reuse the same filters, but ignore modified trips
652    let filter: Filter<App, UnfinishedTrip, Filters> = Filter {
653        state: Filters {
654            modes: TripMode::all().into_iter().collect(),
655            off_map_starts: true,
656            off_map_ends: true,
657            starts_in: None,
658            ends_in: None,
659            unmodified_trips: true,
660            modified_trips: true,
661        },
662        to_controls: Box::new(move |ctx, app, state| checkbox_per_mode(ctx, app, &state.modes)),
663        from_controls: Box::new(|panel| {
664            let mut modes = BTreeSet::new();
665            for m in TripMode::all() {
666                if panel.is_checked(m.ongoing_verb()) {
667                    modes.insert(m);
668                }
669            }
670            Filters {
671                modes,
672                off_map_starts: true,
673                off_map_ends: true,
674                starts_in: None,
675                ends_in: None,
676                unmodified_trips: true,
677                modified_trips: true,
678            }
679        }),
680        apply: Box::new(|state, x, _| {
681            if !state.modes.contains(&x.mode) {
682                return false;
683            }
684            true
685        }),
686    };
687
688    let mut table = Table::new(
689        "unfinished_trips_table",
690        unfinished,
691        Box::new(|x| x.id.0.to_string()),
692        "Departure",
693        filter,
694    );
695    table.static_col("Trip ID", Box::new(|x| x.id.0.to_string()));
696    table.column(
697        "Type",
698        Box::new(|ctx, app, x| {
699            Text::from(Line(x.mode.ongoing_verb()).fg(color_for_mode(app, x.mode))).render(ctx)
700        }),
701        Col::Static,
702    );
703    table.column(
704        "Departure",
705        Box::new(|ctx, _, x| Text::from(x.departure.ampm_tostring()).render(ctx)),
706        Col::Sortable(Box::new(|rows| rows.sort_by_key(|x| x.departure))),
707    );
708    if app.has_prebaked().is_some() {
709        table.column(
710            "Estimated duration",
711            Box::new(|ctx, app, x| {
712                Text::from(x.duration_before.to_string(&app.opts.units)).render(ctx)
713            }),
714            Col::Sortable(Box::new(|rows| rows.sort_by_key(|x| x.duration_before))),
715        );
716    }
717
718    table
719}
720
721fn export_trip_table(app: &App) -> anyhow::Result<String> {
722    let (finished, _) = produce_raw_data(app);
723    let path = format!(
724        "trip_table_{}_{}.csv",
725        app.primary.map.get_name().as_filename(),
726        app.primary.sim.time().as_filename()
727    );
728
729    let mut out = std::io::Cursor::new(Vec::new());
730    writeln!(
731        out,
732        "id,mode,modified,departure,duration,waiting_time,percent_waiting,duration_before"
733    )?;
734
735    for trip in finished {
736        writeln!(
737            out,
738            "{},{:?},{},{},{},{},{},{}",
739            trip.id.0,
740            trip.mode,
741            trip.modified,
742            trip.departure,
743            trip.duration_after.inner_seconds(),
744            trip.waiting.inner_seconds(),
745            trip.percent_waiting,
746            trip.duration_before.inner_seconds()
747        )?;
748    }
749
750    abstio::write_file(path, String::from_utf8(out.into_inner())?).map_err(anyhow::Error::from)
751}