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 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 } else if x == "filter starts" {
165 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 }
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 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 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 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 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}