game/info/
person.rs

1use std::collections::BTreeMap;
2
3use rand::seq::SliceRandom;
4use rand::{Rng, SeedableRng};
5use rand_xorshift::XorShiftRng;
6
7use geom::{Angle, Duration, Time};
8use map_model::Map;
9use sim::{
10    AgentID, CarID, ParkingSpot, PedestrianID, Person, PersonID, PersonState, TripID, TripResult,
11    VehicleType,
12};
13use synthpop::{TripEndpoint, TripMode};
14use widgetry::{
15    include_labeled_bytes, Color, ControlState, CornerRounding, EdgeInsets, EventCtx, GeomBatch,
16    Image, Key, Line, RewriteColor, Text, TextExt, TextSpan, Widget,
17};
18
19use crate::app::App;
20use crate::info::{building, header_btns, make_table, make_tabs, trip, Details, OpenTrip, Tab};
21
22pub fn trips(
23    ctx: &mut EventCtx,
24    app: &App,
25    details: &mut Details,
26    id: PersonID,
27    open_trips: &mut BTreeMap<TripID, OpenTrip>,
28    is_paused: bool,
29) -> Widget {
30    Widget::custom_col(vec![
31        header(
32            ctx,
33            app,
34            details,
35            id,
36            Tab::PersonTrips(id, open_trips.clone()),
37            is_paused,
38        ),
39        trips_body(ctx, app, details, id, open_trips).tab_body(ctx),
40    ])
41}
42
43fn trips_body(
44    ctx: &mut EventCtx,
45    app: &App,
46    details: &mut Details,
47    id: PersonID,
48    open_trips: &mut BTreeMap<TripID, OpenTrip>,
49) -> Widget {
50    let mut rows = vec![];
51
52    let map = &app.primary.map;
53    let sim = &app.primary.sim;
54    let person = sim.get_person(id);
55
56    // If there's at least one open trip, then we'll draw a route on the map. If so, add a dark
57    // overlay for better contrast in the unzoomed view. Only add it once, even if multiple trips
58    // are open.
59    if !open_trips.is_empty() {
60        details.draw_extra.unzoomed.push(
61            app.cs.fade_map_dark,
62            app.primary.map.get_boundary_polygon().clone(),
63        );
64    }
65
66    // I'm sorry for bad variable names
67    let mut wheres_waldo = true;
68    for (idx, t) in person.trips.iter().enumerate() {
69        let (trip_status, color, maybe_info) = match sim.trip_to_agent(*t) {
70            TripResult::TripNotStarted => {
71                if wheres_waldo {
72                    wheres_waldo = false;
73                    rows.push(current_status(ctx, person, map));
74                }
75                if sim.time() > sim.trip_info(*t).departure {
76                    (
77                        "delayed start",
78                        Color::YELLOW,
79                        open_trips
80                            .get_mut(t)
81                            .map(|open_trip| trip::future(ctx, app, *t, open_trip, details)),
82                    )
83                } else {
84                    (
85                        "future",
86                        Color::hex("#4CA7E9"),
87                        open_trips
88                            .get_mut(t)
89                            .map(|open_trip| trip::future(ctx, app, *t, open_trip, details)),
90                    )
91                }
92            }
93            TripResult::Ok(a) => {
94                assert!(wheres_waldo);
95                wheres_waldo = false;
96                (
97                    "ongoing",
98                    Color::hex("#7FFA4D"),
99                    open_trips
100                        .get_mut(t)
101                        .map(|open_trip| trip::ongoing(ctx, app, *t, a, open_trip, details)),
102                )
103            }
104            TripResult::ModeChange => {
105                // TODO No details. Weird case.
106                assert!(wheres_waldo);
107                wheres_waldo = false;
108                (
109                    "ongoing",
110                    Color::hex("#7FFA4D"),
111                    open_trips.get(t).map(|_| Widget::nothing()),
112                )
113            }
114            TripResult::TripDone => {
115                assert!(wheres_waldo);
116                (
117                    "finished",
118                    Color::hex("#A3A3A3"),
119                    if open_trips.contains_key(t) {
120                        Some(trip::finished(ctx, app, id, open_trips, *t, details))
121                    } else {
122                        None
123                    },
124                )
125            }
126            TripResult::TripCancelled => {
127                // Cancelled trips can happen anywhere in the schedule right now
128                (
129                    "cancelled",
130                    app.cs.signal_banned_turn,
131                    open_trips
132                        .get_mut(t)
133                        .map(|open_trip| trip::cancelled(ctx, app, *t, open_trip, details)),
134                )
135            }
136            TripResult::TripDoesntExist => unreachable!(),
137        };
138        let trip = sim.trip_info(*t);
139
140        let (row_btn, _hitbox) = Widget::custom_row(vec![
141            format!("Trip {} ", idx + 1)
142                .batch_text(ctx)
143                .centered_vert()
144                .margin_right(21),
145            Widget::row(vec![
146                GeomBatch::load_svg(
147                    ctx.prerender,
148                    match trip.mode {
149                        TripMode::Walk => "system/assets/meters/pedestrian.svg",
150                        TripMode::Bike => "system/assets/meters/bike.svg",
151                        TripMode::Drive => "system/assets/meters/car.svg",
152                        TripMode::Transit => "system/assets/meters/bus.svg",
153                    },
154                )
155                // we want the icon to be about the same height as the text
156                .scale(0.75)
157                // discard any padding built into the svg
158                .autocrop()
159                .color(RewriteColor::ChangeAll(color))
160                .batch(),
161                // Without this bottom padding, text is much closer to bottom of pill than top -
162                // seemingly more so than just text ascender/descender descrepancies - why?
163                Line(trip_status)
164                    .small()
165                    .fg(color)
166                    .batch(ctx)
167                    .container()
168                    .padding_bottom(2),
169            ])
170            .centered()
171            .corner_rounding(CornerRounding::FullyRounded)
172            .outline((1.0, color))
173            .bg(color.alpha(0.2))
174            .padding(EdgeInsets {
175                top: 5.0,
176                bottom: 5.0,
177                left: 10.0,
178                right: 10.0,
179            })
180            .margin_right(21),
181            if trip.modified {
182                Line("modified").batch(ctx).centered_vert().margin_right(15)
183            } else {
184                Widget::nothing()
185            },
186            if trip_status == "finished" {
187                if let Some(before) = app
188                    .has_prebaked()
189                    .and_then(|_| app.prebaked().finished_trip_time(*t))
190                {
191                    let (after, _, _) = app.primary.sim.finished_trip_details(*t).unwrap();
192                    Text::from(cmp_duration_shorter(after, before))
193                        .batch(ctx)
194                        .centered_vert()
195                } else {
196                    Widget::nothing()
197                }
198            } else {
199                Widget::nothing()
200            },
201            {
202                let mut icon = Image::from_bytes(include_labeled_bytes!(
203                    "../../../../widgetry/icons/arrow_drop_down.svg"
204                ))
205                .build_batch(ctx)
206                .expect("invalid svg")
207                .0
208                .scale(1.5);
209
210                if !open_trips.contains_key(t) {
211                    icon = icon.rotate(Angle::degrees(180.0));
212                }
213
214                icon.batch().container().align_right().margin_right(10)
215            },
216        ])
217        .centered()
218        .outline(ctx.style().section_outline)
219        .padding(16)
220        .bg(app.cs.inner_panel_bg)
221        .into_geom(ctx, Some(0.3));
222        rows.push(
223            ctx.style()
224                .btn_solid
225                .btn()
226                .custom_batch(row_btn.clone(), ControlState::Default)
227                .custom_batch(
228                    row_btn.color(RewriteColor::Change(
229                        app.cs.inner_panel_bg,
230                        ctx.style().btn_outline.bg_hover,
231                    )),
232                    ControlState::Hovered,
233                )
234                .build_widget(
235                    ctx,
236                    format!(
237                        "{} {}",
238                        if open_trips.contains_key(t) {
239                            "hide"
240                        } else {
241                            "show"
242                        },
243                        t
244                    ),
245                )
246                .margin_above(if idx == 0 { 0 } else { 16 }),
247        );
248
249        if let Some(info) = maybe_info {
250            rows.push(
251                info.outline(ctx.style().section_outline)
252                    .bg(app.cs.inner_panel_bg)
253                    .padding(16),
254            );
255
256            let mut new_trips = open_trips.clone();
257            new_trips.remove(t);
258            details
259                .hyperlinks
260                .insert(format!("hide {}", t), Tab::PersonTrips(id, new_trips));
261        } else {
262            let mut new_trips = open_trips.clone();
263            new_trips.insert(*t, OpenTrip::new());
264            details
265                .hyperlinks
266                .insert(format!("show {}", t), Tab::PersonTrips(id, new_trips));
267        }
268    }
269    if wheres_waldo {
270        rows.push(current_status(ctx, person, map));
271    }
272
273    Widget::col(rows)
274}
275
276pub fn bio(
277    ctx: &mut EventCtx,
278    app: &App,
279    details: &mut Details,
280    id: PersonID,
281    is_paused: bool,
282) -> Widget {
283    Widget::custom_col(vec![
284        header(ctx, app, details, id, Tab::PersonBio(id), is_paused),
285        bio_body(ctx, app, details, id).tab_body(ctx),
286    ])
287}
288
289fn bio_body(ctx: &mut EventCtx, app: &App, details: &mut Details, id: PersonID) -> Widget {
290    let mut rows = vec![];
291    let person = app.primary.sim.get_person(id);
292    let mut rng = XorShiftRng::seed_from_u64(id.0 as u64);
293
294    let mut svg_data = Vec::new();
295    svg_face::generate_face(&mut svg_data, &mut rng).unwrap();
296    let batch = GeomBatch::load_svg_bytes_uncached(&svg_data).autocrop();
297    let dims = batch.get_dims();
298    let batch = batch.scale((200.0 / dims.width).min(200.0 / dims.height));
299    rows.push(batch.into_widget(ctx).centered_horiz());
300
301    let nickname = petname::Petnames::default().generate(&mut rng, 2, " ");
302    let age = rng.gen_range(5..100);
303
304    let mut table = vec![("Nickname", nickname), ("Age", age.to_string())];
305    if app.opts.dev {
306        table.push(("Debug ID", format!("{:?}", person.orig_id)));
307    }
308    rows.extend(make_table(ctx, table));
309    // TODO Mad libs!
310    // - Keeps a collection of ___ at all times
311    // - Origin story: accidentally fell into a vat of cheese curds
312    // - Superpower: Makes unnervingly realistic squirrel noises
313    // - Rides a fixie
314    // - Has 17 pinky toe piercings (surprising, considering they're the state champ at
315    // barefoot marathons)
316    // TODO Favorite color: colors.lol
317
318    if let Some(p) = app.primary.sim.get_pandemic_model() {
319        // TODO add hospitalization/quarantine probably
320        let status = if p.is_sane(id) {
321            "Susceptible".to_string()
322        } else if p.is_exposed(id) {
323            format!("Exposed at {}", p.get_time(id).unwrap().ampm_tostring())
324        } else if p.is_infectious(id) {
325            format!("Infected at {}", p.get_time(id).unwrap().ampm_tostring())
326        } else if p.is_recovered(id) {
327            format!("Recovered at {}", p.get_time(id).unwrap().ampm_tostring())
328        } else if p.is_dead(id) {
329            format!("Dead at {}", p.get_time(id).unwrap().ampm_tostring())
330        } else {
331            // TODO More info here? Make these public too?
332            "Other (hospitalized or quarantined)".to_string()
333        };
334        rows.push(
335            Text::from_all(vec![
336                Line("Pandemic model state: ").secondary(),
337                Line(status),
338            ])
339            .into_widget(ctx),
340        );
341    }
342
343    let mut has_bike = false;
344    for v in &person.vehicles {
345        if v.vehicle_type == VehicleType::Bike {
346            has_bike = true;
347        } else if app.primary.sim.lookup_parked_car(v.id).is_some() {
348            rows.push(
349                ctx.style()
350                    .btn_outline
351                    .text(format!("Owner of {} (parked)", v.id))
352                    .build_def(ctx),
353            );
354            details
355                .hyperlinks
356                .insert(format!("Owner of {} (parked)", v.id), Tab::ParkedCar(v.id));
357        } else if let PersonState::Trip(t) = person.state {
358            match app.primary.sim.trip_to_agent(t) {
359                TripResult::Ok(AgentID::Car(x)) if x == v.id => {
360                    rows.push(format!("Owner of {} (currently driving)", v.id).text_widget(ctx));
361                }
362                _ => {
363                    rows.push(format!("Owner of {} (off-map)", v.id).text_widget(ctx));
364                }
365            }
366        } else {
367            rows.push(format!("Owner of {} (off-map)", v.id).text_widget(ctx));
368        }
369    }
370    if has_bike {
371        rows.push("Owns a bike".text_widget(ctx));
372    }
373
374    // Debug info about their simulation state
375    if app.opts.dev {
376        if let Some(AgentID::Car(car)) = app.primary.sim.person_to_agent(id) {
377            rows.push(
378                Text::from(format!("State: {:?}", app.primary.sim.debug_car_ui(car)))
379                    .wrap_to_pct(ctx, 20)
380                    .into_widget(ctx),
381            );
382        }
383    }
384
385    Widget::col(rows)
386}
387
388pub fn schedule(
389    ctx: &mut EventCtx,
390    app: &App,
391    details: &mut Details,
392    id: PersonID,
393    is_paused: bool,
394) -> Widget {
395    Widget::custom_col(vec![
396        header(ctx, app, details, id, Tab::PersonSchedule(id), is_paused),
397        schedule_body(ctx, app, id).tab_body(ctx),
398    ])
399}
400
401fn schedule_body(ctx: &mut EventCtx, app: &App, id: PersonID) -> Widget {
402    let mut rows = vec![];
403    let person = app.primary.sim.get_person(id);
404    let mut rng = XorShiftRng::seed_from_u64(id.0 as u64);
405
406    // TODO Proportional 24-hour timeline would be easier to understand
407    let mut last_t = Time::START_OF_DAY;
408    for t in &person.trips {
409        let trip = app.primary.sim.trip_info(*t);
410        let at = match trip.start {
411            TripEndpoint::Building(b) => {
412                let b = app.primary.map.get_b(b);
413                if b.amenities.is_empty() {
414                    b.address.clone()
415                } else {
416                    let list = b
417                        .amenities
418                        .iter()
419                        .map(|a| a.names.get(app.opts.language.as_ref()))
420                        .collect::<Vec<_>>();
421                    format!("{} (at {})", list.choose(&mut rng).unwrap(), b.address)
422                }
423            }
424            TripEndpoint::Border(_) => "off-map".to_string(),
425            TripEndpoint::SuddenlyAppear(_) => "suddenly appear".to_string(),
426        };
427        rows.push(
428            Text::from(format!("  Spends {} at {}", trip.departure - last_t, at)).into_widget(ctx),
429        );
430        // TODO Ideally end time if we know
431        last_t = trip.departure;
432    }
433    // Where do they spend the night?
434    let last_trip = app.primary.sim.trip_info(*person.trips.last().unwrap());
435    let at = match last_trip.end {
436        TripEndpoint::Building(b) => {
437            let b = app.primary.map.get_b(b);
438            if b.amenities.is_empty() {
439                b.address.clone()
440            } else {
441                let list = b
442                    .amenities
443                    .iter()
444                    .map(|a| a.names.get(app.opts.language.as_ref()))
445                    .collect::<Vec<_>>();
446                format!("{} (at {})", list.choose(&mut rng).unwrap(), b.address)
447            }
448        }
449        TripEndpoint::Border(_) => "off-map".to_string(),
450        TripEndpoint::SuddenlyAppear(_) => "suddenly disappear".to_string(),
451    };
452    rows.push(
453        Text::from(format!(
454            "  Spends {} at {}",
455            app.primary.sim.get_end_of_day() - last_trip.departure,
456            at
457        ))
458        .into_widget(ctx),
459    );
460
461    Widget::col(rows)
462}
463
464pub fn crowd(ctx: &EventCtx, app: &App, details: &mut Details, members: &[PedestrianID]) -> Widget {
465    let header = Widget::custom_col(vec![
466        Line("Pedestrian crowd").small_heading().into_widget(ctx),
467        header_btns(ctx),
468    ]);
469    Widget::custom_col(vec![
470        header,
471        crowd_body(ctx, app, details, members).tab_body(ctx),
472    ])
473}
474
475fn crowd_body(
476    ctx: &EventCtx,
477    app: &App,
478    details: &mut Details,
479    members: &[PedestrianID],
480) -> Widget {
481    let mut rows = vec![];
482    for (idx, id) in members.iter().enumerate() {
483        let person = app
484            .primary
485            .sim
486            .agent_to_person(AgentID::Pedestrian(*id))
487            .unwrap();
488        // TODO What other info is useful to summarize?
489        rows.push(Widget::row(vec![
490            format!("{})", idx + 1).text_widget(ctx).centered_vert(),
491            ctx.style()
492                .btn_outline
493                .text(person.to_string())
494                .build_def(ctx),
495        ]));
496        details.hyperlinks.insert(
497            person.to_string(),
498            Tab::PersonTrips(
499                person,
500                OpenTrip::single(
501                    app.primary
502                        .sim
503                        .agent_to_trip(AgentID::Pedestrian(*id))
504                        .unwrap(),
505                ),
506            ),
507        );
508    }
509
510    Widget::col(rows)
511}
512
513pub fn parked_car(
514    ctx: &mut EventCtx,
515    app: &App,
516    details: &mut Details,
517    id: CarID,
518    is_paused: bool,
519) -> Widget {
520    let header = Widget::row(vec![
521        Line(format!("Parked car #{}", id.id))
522            .small_heading()
523            .into_widget(ctx),
524        Widget::row(vec![
525            // Little indirect, but the handler of this action is actually the ContextualActions
526            // for SandboxMode.
527            if is_paused {
528                ctx.style()
529                    .btn_plain
530                    .icon("system/assets/tools/location.svg")
531                    .hotkey(Key::F)
532                    .build_widget(ctx, "follow (run the simulation)")
533            } else {
534                // TODO Blink
535                ctx.style()
536                    .btn_plain
537                    .icon("system/assets/tools/location.svg")
538                    .image_color(Color::hex("#7FFA4D"), ControlState::Default)
539                    .hotkey(Key::F)
540                    .build_widget(ctx, "unfollow (pause the simulation)")
541            },
542            ctx.style().btn_close_widget(ctx),
543        ])
544        .align_right(),
545    ]);
546
547    Widget::custom_col(vec![
548        header,
549        parked_car_body(ctx, app, details, id).tab_body(ctx),
550    ])
551}
552
553fn parked_car_body(ctx: &mut EventCtx, app: &App, details: &mut Details, id: CarID) -> Widget {
554    // TODO prev trips, next trips, etc
555    let mut rows = vec![];
556
557    let p = app.primary.sim.get_owner_of_car(id).unwrap();
558    rows.push(
559        ctx.style()
560            .btn_outline
561            .text(format!("Owned by {}", p))
562            .build_def(ctx),
563    );
564    details.hyperlinks.insert(
565        format!("Owned by {}", p),
566        Tab::PersonTrips(p, BTreeMap::new()),
567    );
568
569    if let Some(p) = app.primary.sim.lookup_parked_car(id) {
570        match p.spot {
571            ParkingSpot::Onstreet(_, _) | ParkingSpot::Lot(_, _) => {
572                ctx.canvas.center_on_map_pt(
573                    app.primary
574                        .sim
575                        .canonical_pt_for_agent(AgentID::Car(id), &app.primary.map)
576                        .unwrap(),
577                );
578            }
579            ParkingSpot::Offstreet(b, _) => {
580                ctx.canvas
581                    .center_on_map_pt(app.primary.map.get_b(b).polygon.center());
582                rows.push(
583                    format!("Parked inside {}", app.primary.map.get_b(b).address).text_widget(ctx),
584                );
585            }
586        }
587
588        rows.push(
589            format!(
590                "Parked here for {}",
591                app.primary.sim.time() - p.parked_since
592            )
593            .text_widget(ctx),
594        );
595    } else {
596        rows.push("No longer parked".text_widget(ctx));
597    }
598
599    Widget::col(rows)
600}
601
602fn header(
603    ctx: &mut EventCtx,
604    app: &App,
605    details: &mut Details,
606    id: PersonID,
607    tab: Tab,
608    is_paused: bool,
609) -> Widget {
610    let mut rows = vec![];
611
612    let (current_trip, (descr, maybe_icon)) = match app.primary.sim.get_person(id).state {
613        PersonState::Inside(b) => {
614            ctx.canvas
615                .center_on_map_pt(app.primary.map.get_b(b).label_center);
616            building::draw_occupants(details, app, b, Some(id));
617            (None, ("indoors", Some("system/assets/tools/home.svg")))
618        }
619        PersonState::Trip(t) => (
620            Some(t),
621            if let Some(a) = app.primary.sim.trip_to_agent(t).ok() {
622                if let Some(pt) = app.primary.sim.canonical_pt_for_agent(a, &app.primary.map) {
623                    ctx.canvas.center_on_map_pt(pt);
624                }
625                match a {
626                    AgentID::Pedestrian(_) => {
627                        ("walking", Some("system/assets/meters/pedestrian.svg"))
628                    }
629                    AgentID::Car(c) => match c.vehicle_type {
630                        VehicleType::Car => ("driving", Some("system/assets/meters/car.svg")),
631                        VehicleType::Bike => ("biking", Some("system/assets/meters/bike.svg")),
632                        VehicleType::Bus | VehicleType::Train => unreachable!(),
633                    },
634                    AgentID::BusPassenger(_, _) => {
635                        ("riding a bus", Some("system/assets/meters/bus.svg"))
636                    }
637                }
638            } else {
639                // TODO Really should clean up the TripModeChange issue
640                ("...", None)
641            },
642        ),
643        PersonState::OffMap => (None, ("off map", None)),
644    };
645
646    rows.push(Widget::custom_row(vec![
647        Line(format!("{}", id)).small_heading().into_widget(ctx),
648        if let Some(icon) = maybe_icon {
649            let batch = GeomBatch::load_svg(ctx, icon)
650                .color(RewriteColor::ChangeAll(Color::hex("#A3A3A3")))
651                .autocrop();
652            let y_factor = 20.0 / batch.get_dims().height;
653            batch.scale(y_factor).into_widget(ctx).margin_left(28)
654        } else {
655            Widget::nothing()
656        }
657        .centered_vert(),
658        Line(descr.to_string())
659            .small_heading()
660            .fg(Color::hex("#A3A3A3"))
661            .into_widget(ctx)
662            .margin_horiz(10),
663        Widget::row(vec![
664            // Little indirect, but the handler of this action is actually the ContextualActions
665            // for SandboxMode.
666            if is_paused {
667                ctx.style()
668                    .btn_plain
669                    .icon("system/assets/tools/location.svg")
670                    .hotkey(Key::F)
671                    .build_widget(ctx, "follow (run the simulation)")
672            } else {
673                // TODO Blink
674                ctx.style()
675                    .btn_plain
676                    .icon("system/assets/tools/location.svg")
677                    .image_color(Color::hex("#7FFA4D"), ControlState::Default)
678                    .hotkey(Key::F)
679                    .build_widget(ctx, "unfollow (pause the simulation)")
680            },
681            ctx.style().btn_close_widget(ctx),
682        ])
683        .align_right(),
684    ]));
685
686    let open_trips = if let Some(t) = current_trip {
687        OpenTrip::single(t)
688    } else {
689        BTreeMap::new()
690    };
691    let mut tabs = vec![
692        ("Trips", Tab::PersonTrips(id, open_trips)),
693        ("Bio", Tab::PersonBio(id)),
694    ];
695    if app.opts.dev {
696        tabs.push(("Schedule", Tab::PersonSchedule(id)));
697    }
698    rows.push(make_tabs(ctx, &mut details.hyperlinks, tab, tabs));
699
700    Widget::col(rows)
701}
702
703fn current_status(ctx: &EventCtx, person: &Person, map: &Map) -> Widget {
704    (match person.state {
705        PersonState::Inside(b) => {
706            // TODO hyperlink
707            format!("Currently inside {}", map.get_b(b).address).text_widget(ctx)
708        }
709        PersonState::Trip(_) => unreachable!(),
710        PersonState::OffMap => "Currently outside the map boundaries".text_widget(ctx),
711    })
712    .margin_vert(16)
713}
714
715// TODO Dedupe with the version in helpers
716fn cmp_duration_shorter(after: Duration, before: Duration) -> TextSpan {
717    if after.epsilon_eq(before) {
718        Line("no change").small()
719    } else if after < before {
720        Line(format!("{} faster", before - after))
721            .small()
722            .fg(Color::GREEN)
723    } else if after > before {
724        Line(format!("{} slower", after - before))
725            .small()
726            .fg(Color::RED)
727    } else {
728        unreachable!()
729    }
730}