game/layer/
traffic.rs

1use std::collections::BTreeSet;
2
3use anyhow::Result;
4use maplit::btreeset;
5
6use crate::ID;
7use abstutil::{prettyprint_usize, Counter};
8use geom::{Circle, Distance, Duration, Percent, Polygon, Pt2D, Time};
9use map_gui::tools::ColorNetwork;
10use map_model::{IntersectionID, Map, Traversable};
11use sim::{AgentType, VehicleType};
12use widgetry::mapspace::ToggleZoomed;
13use widgetry::mapspace::{DummyID, World};
14use widgetry::tools::{ColorLegend, DivergingScale, PopupMsg};
15use widgetry::{Color, EventCtx, GfxCtx, Line, Outcome, Panel, Text, TextExt, Toggle, Widget};
16
17use crate::app::{App, Transition};
18use crate::layer::{header, Layer, LayerOutcome, PANEL_PLACEMENT};
19use crate::render::unzoomed_agent_radius;
20
21pub struct Backpressure {
22    time: Time,
23    draw: ToggleZoomed,
24    panel: Panel,
25}
26
27impl Layer for Backpressure {
28    fn name(&self) -> Option<&'static str> {
29        Some("backpressure")
30    }
31    fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Option<LayerOutcome> {
32        if app.primary.sim.time() != self.time {
33            *self = Backpressure::new(ctx, app);
34        }
35
36        <dyn Layer>::simple_event(ctx, &mut self.panel)
37    }
38    fn draw(&self, g: &mut GfxCtx, _: &App) {
39        self.panel.draw(g);
40        self.draw.draw(g);
41    }
42    fn draw_minimap(&self, g: &mut GfxCtx) {
43        g.redraw(&self.draw.unzoomed);
44    }
45}
46
47impl Backpressure {
48    pub fn new(ctx: &mut EventCtx, app: &App) -> Backpressure {
49        let mut cnt_per_r = Counter::new();
50        let mut cnt_per_i = Counter::new();
51        for path in app.primary.sim.get_all_driving_paths() {
52            for step in path.get_steps() {
53                match step.as_traversable() {
54                    Traversable::Lane(l) => {
55                        cnt_per_r.inc(l.road);
56                    }
57                    Traversable::Turn(t) => {
58                        cnt_per_i.inc(t.parent);
59                    }
60                }
61            }
62        }
63
64        let panel = Panel::new_builder(Widget::col(vec![
65            header(ctx, "Backpressure"),
66            Text::from(
67                Line("This counts all active trips passing through a road in the future")
68                    .secondary(),
69            )
70            .wrap_to_pct(ctx, 15)
71            .into_widget(ctx),
72            ColorLegend::gradient(
73                ctx,
74                &app.cs.good_to_bad_red,
75                vec!["lowest count", "highest"],
76            ),
77        ]))
78        .aligned_pair(PANEL_PLACEMENT)
79        .build(ctx);
80
81        let mut colorer = ColorNetwork::new(app);
82        colorer.pct_roads(cnt_per_r, &app.cs.good_to_bad_red);
83        colorer.pct_intersections(cnt_per_i, &app.cs.good_to_bad_red);
84
85        Backpressure {
86            time: app.primary.sim.time(),
87            draw: colorer.build(ctx),
88            panel,
89        }
90    }
91}
92
93pub struct Throughput {
94    time: Time,
95    agent_types: BTreeSet<AgentType>,
96    tooltip: Option<Text>,
97    draw: ToggleZoomed,
98    panel: Panel,
99}
100
101impl Layer for Throughput {
102    fn name(&self) -> Option<&'static str> {
103        Some("throughput")
104    }
105    fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Option<LayerOutcome> {
106        let mut recalc_tooltip = false;
107        if app.primary.sim.time() != self.time {
108            *self = Throughput::new(ctx, app, self.agent_types.clone());
109            recalc_tooltip = true;
110        }
111
112        // Show a tooltip with count, only when unzoomed
113        if ctx.canvas.is_unzoomed() {
114            if ctx.redo_mouseover() || recalc_tooltip {
115                self.tooltip = None;
116                match app.mouseover_unzoomed_roads_and_intersections(ctx) {
117                    Some(ID::Road(r)) => {
118                        let cnt = app
119                            .primary
120                            .sim
121                            .get_analytics()
122                            .road_thruput
123                            .total_for_with_agent_types(r, self.agent_types.clone());
124                        if cnt > 0 {
125                            self.tooltip = Some(Text::from(prettyprint_usize(cnt)));
126                        }
127                    }
128                    Some(ID::Intersection(i)) => {
129                        let cnt = app
130                            .primary
131                            .sim
132                            .get_analytics()
133                            .intersection_thruput
134                            .total_for_with_agent_types(i, self.agent_types.clone());
135                        if cnt > 0 {
136                            self.tooltip = Some(Text::from(prettyprint_usize(cnt)));
137                        }
138                    }
139                    _ => {}
140                }
141            }
142        } else {
143            self.tooltip = None;
144        }
145
146        match self.panel.event(ctx) {
147            Outcome::Clicked(x) => match x.as_ref() {
148                "close" => {
149                    return Some(LayerOutcome::Close);
150                }
151                "Export to CSV" => {
152                    return Some(LayerOutcome::Transition(Transition::Push(
153                        match export_throughput(app) {
154                            Ok((path1, path2)) => PopupMsg::new_state(
155                                ctx,
156                                "Data exported",
157                                vec![format!("Data exported to {} and {}", path1, path2)],
158                            ),
159                            Err(err) => {
160                                PopupMsg::new_state(ctx, "Export failed", vec![err.to_string()])
161                            }
162                        },
163                    )));
164                }
165                _ => unreachable!(),
166            },
167            Outcome::Changed(_) => {
168                if self
169                    .panel
170                    .maybe_is_checked("Compare before proposal")
171                    .unwrap_or(false)
172                {
173                    return Some(LayerOutcome::Replace(Box::new(CompareThroughput::new(
174                        ctx, app,
175                    ))));
176                }
177
178                let mut agent_types = BTreeSet::new();
179                for agent_type in AgentType::all() {
180                    if self.panel.is_checked(agent_type.noun()) {
181                        agent_types.insert(agent_type);
182                    }
183                }
184
185                return Some(LayerOutcome::Replace(Box::new(Throughput::new(
186                    ctx,
187                    app,
188                    agent_types,
189                ))));
190            }
191            _ => {}
192        }
193        None
194    }
195    fn draw(&self, g: &mut GfxCtx, _: &App) {
196        self.panel.draw(g);
197        self.draw.draw(g);
198        if let Some(ref txt) = self.tooltip {
199            g.draw_mouse_tooltip(txt.clone());
200        }
201    }
202    fn draw_minimap(&self, g: &mut GfxCtx) {
203        g.redraw(&self.draw.unzoomed);
204    }
205}
206
207impl Throughput {
208    pub fn new(ctx: &mut EventCtx, app: &App, agent_types: BTreeSet<AgentType>) -> Throughput {
209        let stats = &app.primary.sim.get_analytics();
210        let road_counter = stats.road_thruput.all_total_counts(&agent_types);
211        let intersection_counter = stats.intersection_thruput.all_total_counts(&agent_types);
212        let panel = Panel::new_builder(Widget::col(vec![
213            header(ctx, "Throughput"),
214            Text::from(Line("This counts all people crossing since midnight").secondary())
215                .wrap_to_pct(ctx, 15)
216                .into_widget(ctx),
217            if app.has_prebaked().is_some() {
218                Toggle::switch(ctx, "Compare before proposal", None, false)
219            } else {
220                Widget::nothing()
221            },
222            Widget::custom_row(
223                AgentType::all()
224                    .into_iter()
225                    .map(|agent_type| {
226                        Toggle::checkbox(
227                            ctx,
228                            agent_type.noun(),
229                            None,
230                            agent_types.contains(&agent_type),
231                        )
232                    })
233                    .collect(),
234            )
235            .flex_wrap(ctx, Percent::int(20)),
236            ColorLegend::gradient(ctx, &app.cs.good_to_bad_red, vec!["0", "highest"]),
237            ctx.style().btn_plain.text("Export to CSV").build_def(ctx),
238        ]))
239        .aligned_pair(PANEL_PLACEMENT)
240        .build(ctx);
241
242        let mut colorer = ColorNetwork::new(app);
243        colorer.ranked_roads(road_counter, &app.cs.good_to_bad_red);
244        colorer.ranked_intersections(intersection_counter, &app.cs.good_to_bad_red);
245
246        Throughput {
247            time: app.primary.sim.time(),
248            agent_types,
249            tooltip: None,
250            draw: colorer.build(ctx),
251            panel,
252        }
253    }
254}
255
256pub struct CompareThroughput {
257    time: Time,
258    tooltip: Option<Text>,
259    draw: ToggleZoomed,
260    panel: Panel,
261}
262
263impl Layer for CompareThroughput {
264    fn name(&self) -> Option<&'static str> {
265        Some("throughput")
266    }
267    fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Option<LayerOutcome> {
268        let mut recalc_tooltip = false;
269        if app.primary.sim.time() != self.time {
270            *self = CompareThroughput::new(ctx, app);
271            recalc_tooltip = true;
272        }
273
274        // Show a tooltip with count, only when unzoomed
275        if ctx.canvas.is_unzoomed() {
276            if ctx.redo_mouseover() || recalc_tooltip {
277                self.tooltip = None;
278                match app.mouseover_unzoomed_roads_and_intersections(ctx) {
279                    Some(ID::Road(r)) => {
280                        let after = app.primary.sim.get_analytics().road_thruput.total_for(r);
281                        let before = app
282                            .prebaked()
283                            .road_thruput
284                            .total_for_by_time(r, app.primary.sim.time());
285                        if before + after > 0 {
286                            self.tooltip = Some(Text::from(format!(
287                                "{} before, {} after",
288                                prettyprint_usize(before),
289                                prettyprint_usize(after)
290                            )));
291                        }
292                    }
293                    Some(ID::Intersection(i)) => {
294                        let after = app
295                            .primary
296                            .sim
297                            .get_analytics()
298                            .intersection_thruput
299                            .total_for(i);
300                        let before = app
301                            .prebaked()
302                            .intersection_thruput
303                            .total_for_by_time(i, app.primary.sim.time());
304                        if before + after > 0 {
305                            self.tooltip = Some(Text::from(format!(
306                                "{} before, {} after",
307                                prettyprint_usize(before),
308                                prettyprint_usize(after)
309                            )));
310                        }
311                    }
312                    _ => {}
313                }
314            }
315        } else {
316            self.tooltip = None;
317        }
318
319        match self.panel.event(ctx) {
320            Outcome::Clicked(x) => match x.as_ref() {
321                "close" => {
322                    return Some(LayerOutcome::Close);
323                }
324                _ => unreachable!(),
325            },
326            Outcome::Changed(_) => {
327                return Some(LayerOutcome::Replace(Box::new(Throughput::new(
328                    ctx,
329                    app,
330                    AgentType::all().into_iter().collect(),
331                ))));
332            }
333            _ => {}
334        }
335        None
336    }
337    fn draw(&self, g: &mut GfxCtx, _: &App) {
338        self.panel.draw(g);
339        self.draw.draw(g);
340        if let Some(ref txt) = self.tooltip {
341            g.draw_mouse_tooltip(txt.clone());
342        }
343    }
344    fn draw_minimap(&self, g: &mut GfxCtx) {
345        g.redraw(&self.draw.unzoomed);
346    }
347}
348
349impl CompareThroughput {
350    pub fn new(ctx: &mut EventCtx, app: &App) -> CompareThroughput {
351        let after = app.primary.sim.get_analytics();
352        let before = app.prebaked();
353        let hour = app.primary.sim.time().get_hours();
354
355        let mut after_road = Counter::new();
356        let mut before_road = Counter::new();
357        {
358            for ((r, _, _), count) in &after.road_thruput.counts {
359                after_road.add(*r, *count);
360            }
361            // TODO ew. lerp?
362            for ((r, _, hr), count) in &before.road_thruput.counts {
363                if *hr <= hour {
364                    before_road.add(*r, *count);
365                }
366            }
367        }
368        let mut after_intersection = Counter::new();
369        let mut before_intersection = Counter::new();
370        {
371            for ((i, _, _), count) in &after.intersection_thruput.counts {
372                after_intersection.add(*i, *count);
373            }
374            // TODO ew. lerp?
375            for ((i, _, hr), count) in &before.intersection_thruput.counts {
376                if *hr <= hour {
377                    before_intersection.add(*i, *count);
378                }
379            }
380        }
381
382        let mut colorer = ColorNetwork::new(app);
383
384        let scale = DivergingScale::new(Color::hex("#5D9630"), Color::WHITE, Color::hex("#A32015"))
385            .range(0.0, 2.0)
386            .ignore(0.7, 1.3);
387
388        for (r, before, after) in before_road.compare(after_road) {
389            if let Some(c) = scale.eval((after as f64) / (before as f64)) {
390                colorer.add_r(r, c);
391            }
392        }
393        for (i, before, after) in before_intersection.compare(after_intersection) {
394            if let Some(c) = scale.eval((after as f64) / (before as f64)) {
395                colorer.add_i(i, c);
396            }
397        }
398
399        let panel = Panel::new_builder(Widget::col(vec![
400            header(ctx, "Relative Throughput"),
401            Toggle::switch(ctx, "Compare before proposal", None, true),
402            scale.make_legend(ctx, vec!["less traffic", "same", "more"]),
403        ]))
404        .aligned_pair(PANEL_PLACEMENT)
405        .build(ctx);
406
407        CompareThroughput {
408            time: app.primary.sim.time(),
409            tooltip: None,
410            draw: colorer.build(ctx),
411            panel,
412        }
413    }
414}
415
416pub struct TrafficJams {
417    time: Time,
418    draw: ToggleZoomed,
419    panel: Panel,
420}
421
422impl Layer for TrafficJams {
423    fn name(&self) -> Option<&'static str> {
424        Some("traffic jams")
425    }
426    fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Option<LayerOutcome> {
427        if app.primary.sim.time() != self.time {
428            *self = TrafficJams::new(ctx, app);
429        }
430
431        <dyn Layer>::simple_event(ctx, &mut self.panel)
432    }
433    fn draw(&self, g: &mut GfxCtx, _: &App) {
434        self.panel.draw(g);
435        self.draw.draw(g);
436    }
437    fn draw_minimap(&self, g: &mut GfxCtx) {
438        g.redraw(&self.draw.unzoomed);
439    }
440}
441
442impl TrafficJams {
443    pub fn new(ctx: &mut EventCtx, app: &App) -> TrafficJams {
444        // TODO Use cached delayed_intersections?
445        let mut draw = ToggleZoomed::builder();
446        draw.unzoomed.push(
447            app.cs.fade_map_dark,
448            app.primary.map.get_boundary_polygon().clone(),
449        );
450        let mut cnt = 0;
451        for (epicenter, boundary) in cluster_jams(
452            &app.primary.map,
453            app.primary.sim.delayed_intersections(Duration::minutes(5)),
454        ) {
455            cnt += 1;
456            draw.unzoomed
457                .push(Color::RED, boundary.to_outline(Distance::meters(5.0)));
458            draw.unzoomed.push(Color::RED.alpha(0.5), boundary.clone());
459            draw.unzoomed.push(Color::WHITE, epicenter.clone());
460
461            draw.zoomed.push(
462                Color::RED.alpha(0.4),
463                boundary.to_outline(Distance::meters(5.0)),
464            );
465            draw.zoomed.push(Color::RED.alpha(0.3), boundary);
466            draw.zoomed.push(Color::WHITE.alpha(0.4), epicenter);
467        }
468
469        let panel = Panel::new_builder(Widget::col(vec![
470            header(ctx, "Traffic jams"),
471            Text::from(
472                Line("A jam starts when delay exceeds 5 mins, then spreads out").secondary(),
473            )
474            .wrap_to_pct(ctx, 15)
475            .into_widget(ctx),
476            format!("{} jams detected", cnt).text_widget(ctx),
477        ]))
478        .aligned_pair(PANEL_PLACEMENT)
479        .build(ctx);
480
481        TrafficJams {
482            time: app.primary.sim.time(),
483            draw: draw.build(ctx),
484            panel,
485        }
486    }
487}
488
489struct Jam {
490    epicenter: IntersectionID,
491    members: BTreeSet<IntersectionID>,
492}
493
494// (Epicenter, entire shape)
495fn cluster_jams(map: &Map, problems: Vec<(IntersectionID, Time)>) -> Vec<(Polygon, Polygon)> {
496    let mut jams: Vec<Jam> = Vec::new();
497    // The delay itself doesn't matter, as long as they're sorted.
498    for (i, _) in problems {
499        // Is this connected to an existing problem?
500        if let Some(ref mut jam) = jams.iter_mut().find(|j| j.adjacent_to(map, i)) {
501            jam.members.insert(i);
502        } else {
503            jams.push(Jam {
504                epicenter: i,
505                members: btreeset! { i },
506            });
507        }
508    }
509
510    // TODO This silently hides jams where we can't calculate the convex hull. The caller just
511    // needs a Tessellation, so should we have a less strict version of convex_hull?
512    jams.into_iter()
513        .filter_map(|jam| {
514            let epicenter = map.get_i(jam.epicenter).polygon.clone();
515            Polygon::convex_hull(jam.all_polygons(map))
516                .ok()
517                .map(move |entire_shape| (epicenter, entire_shape))
518        })
519        .collect()
520}
521
522impl Jam {
523    fn adjacent_to(&self, map: &Map, i: IntersectionID) -> bool {
524        for r in &map.get_i(i).roads {
525            let r = map.get_r(*r);
526            if self.members.contains(&r.src_i) || self.members.contains(&r.dst_i) {
527                return true;
528            }
529        }
530        false
531    }
532
533    fn all_polygons(self, map: &Map) -> Vec<Polygon> {
534        let mut polygons = Vec::new();
535        for i in self.members {
536            polygons.push(map.get_i(i).polygon.clone());
537        }
538        polygons
539    }
540}
541
542// Shows how long each agent has been waiting in one spot.
543pub struct Delay {
544    time: Time,
545    draw: ToggleZoomed,
546    panel: Panel,
547}
548
549impl Layer for Delay {
550    fn name(&self) -> Option<&'static str> {
551        Some("delay")
552    }
553    fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Option<LayerOutcome> {
554        if app.primary.sim.time() != self.time {
555            *self = Delay::new(ctx, app);
556        }
557
558        if let Outcome::Clicked(x) = self.panel.event(ctx) {
559            match x.as_ref() {
560                "close" => {
561                    return Some(LayerOutcome::Close);
562                }
563                _ => unreachable!(),
564            }
565        }
566        None
567    }
568    fn draw(&self, g: &mut GfxCtx, _: &App) {
569        self.panel.draw(g);
570        self.draw.draw(g);
571    }
572    fn draw_minimap(&self, g: &mut GfxCtx) {
573        g.redraw(&self.draw.unzoomed);
574    }
575}
576
577impl Delay {
578    pub fn new(ctx: &mut EventCtx, app: &App) -> Delay {
579        let mut delays = app.primary.sim.all_waiting_people();
580        // Don't draw anything when zoomed in
581        let mut draw = ToggleZoomed::builder();
582        draw.unzoomed.push(
583            app.cs.fade_map_dark,
584            app.primary.map.get_boundary_polygon().clone(),
585        );
586        // A bit of copied code from draw_unzoomed_agents
587        let car_circle = Circle::new(
588            Pt2D::new(0.0, 0.0),
589            unzoomed_agent_radius(Some(VehicleType::Car)),
590        )
591        .to_polygon();
592        let ped_circle = Circle::new(Pt2D::new(0.0, 0.0), unzoomed_agent_radius(None)).to_polygon();
593        for agent in app.primary.sim.get_unzoomed_agents(&app.primary.map) {
594            if let Some(delay) = agent.person.and_then(|p| delays.remove(&p)) {
595                let color = app
596                    .cs
597                    .good_to_bad_red
598                    .eval((delay / Duration::minutes(15)).min(1.0));
599                if agent.id.to_vehicle_type().is_some() {
600                    draw.unzoomed
601                        .push(color, car_circle.translate(agent.pos.x(), agent.pos.y()));
602                } else {
603                    draw.unzoomed
604                        .push(color, ped_circle.translate(agent.pos.x(), agent.pos.y()));
605                }
606            }
607        }
608
609        Delay {
610            time: app.primary.sim.time(),
611            draw: draw.build(ctx),
612            panel: Panel::new_builder(Widget::col(vec![
613                header(ctx, "Delay per agent (minutes)"),
614                ColorLegend::gradient(ctx, &app.cs.good_to_bad_red, vec!["0", "5", "10", "15+"]),
615            ]))
616            .aligned_pair(PANEL_PLACEMENT)
617            .build(ctx),
618        }
619    }
620}
621
622pub struct PedestrianCrowding {
623    time: Time,
624    panel: Panel,
625    world: World<DummyID>,
626}
627
628impl Layer for PedestrianCrowding {
629    fn name(&self) -> Option<&'static str> {
630        Some("pedestrian crowding")
631    }
632    fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Option<LayerOutcome> {
633        if app.primary.sim.time() != self.time {
634            *self = Self::new(ctx, app);
635        }
636
637        if let Outcome::Clicked(x) = self.panel.event(ctx) {
638            match x.as_ref() {
639                "close" => {
640                    return Some(LayerOutcome::Close);
641                }
642                _ => unreachable!(),
643            }
644        }
645
646        // Just update tooltips
647        self.world.event(ctx);
648
649        None
650    }
651    fn draw(&self, g: &mut GfxCtx, _: &App) {
652        self.panel.draw(g);
653        self.world.draw(g);
654    }
655    // TODO This doesn't seem to be showing up
656    fn draw_minimap(&self, _: &mut GfxCtx) {}
657}
658
659impl PedestrianCrowding {
660    pub fn new(ctx: &mut EventCtx, app: &App) -> Self {
661        let map = &app.primary.map;
662        let categories = vec![
663            ("1 - 1.5", app.cs.good_to_bad_red.eval(0.2)),
664            ("1.5 - 2", app.cs.good_to_bad_red.eval(0.4)),
665            ("2 - 5", app.cs.good_to_bad_red.eval(0.6)),
666            ("> 5", app.cs.good_to_bad_red.eval(1.0)),
667        ];
668        let mut world = World::new();
669        let mut draw = ToggleZoomed::builder();
670        draw.unzoomed
671            .push(app.cs.fade_map_dark, map.get_boundary_polygon().clone());
672        world.draw_master_batch(ctx, draw);
673
674        fn bucket(x: f64) -> &'static str {
675            if x < 1.0 {
676                unreachable!()
677            } else if x <= 1.5 {
678                "1 - 1.5"
679            } else if x <= 2.0 {
680                "1.5 - 2"
681            } else if x <= 5.0 {
682                "2 - 5"
683            } else {
684                "> 5"
685            }
686        }
687        fn round(x: f64) -> f64 {
688            // Round up, so we don't show 0 density
689            (x * 10.0).ceil() / 10.0
690        }
691
692        let (roads, intersections) = app.primary.sim.get_pedestrian_density(map);
693        let mut max_density: f64 = 0.0;
694        for (r, density) in roads {
695            if density < 1.0 {
696                continue;
697            }
698            let density = round(density);
699            max_density = max_density.max(density);
700            let (_, color) = categories
701                .iter()
702                .find(|pair| pair.0 == bucket(density))
703                .unwrap();
704            world
705                .add_unnamed()
706                .hitbox(map.get_r(r).get_thick_polygon())
707                .draw_color_unzoomed(*color)
708                .invisibly_hoverable()
709                .tooltip(Text::from(format!("{density} people / m²")))
710                .build(ctx);
711        }
712        for (i, density) in intersections {
713            if density < 1.0 {
714                continue;
715            }
716            let density = round(density);
717            let (_, color) = categories
718                .iter()
719                .find(|pair| pair.0 == bucket(density))
720                .unwrap();
721            world
722                .add_unnamed()
723                .hitbox(map.get_i(i).polygon.clone())
724                .draw_color_unzoomed(*color)
725                .invisibly_hoverable()
726                .tooltip(Text::from(format!("{density} people / m²")))
727                .build(ctx);
728        }
729        world.initialize_hover(ctx);
730
731        Self {
732            time: app.primary.sim.time(),
733            world,
734            panel: Panel::new_builder(Widget::col(vec![
735                header(ctx, "Pedestrian crowding"),
736                "(people / m²)".text_widget(ctx),
737                format!("Max density: {max_density} m²").text_widget(ctx),
738                Widget::col(
739                    categories
740                        .into_iter()
741                        .map(|(name, color)| ColorLegend::row(ctx, color, name))
742                        .collect(),
743                ),
744            ]))
745            .aligned_pair(PANEL_PLACEMENT)
746            .build(ctx),
747        }
748    }
749}
750
751fn export_throughput(app: &App) -> Result<(String, String)> {
752    let path1 = format!(
753        "road_throughput_{}_{}.csv",
754        app.primary.map.get_name().as_filename(),
755        app.primary.sim.time().as_filename()
756    );
757    let path1 = abstio::write_file(
758        path1,
759        app.primary
760            .sim
761            .get_analytics()
762            .road_thruput
763            .export_csv(|id| id.0),
764    )?;
765
766    let path2 = format!(
767        "intersection_throughput_{}_{}.csv",
768        app.primary.map.get_name().as_filename(),
769        app.primary.sim.time().as_filename()
770    );
771    let path2 = abstio::write_file(
772        path2,
773        app.primary
774            .sim
775            .get_analytics()
776            .intersection_thruput
777            .export_csv(|id| id.0),
778    )?;
779
780    Ok((path1, path2))
781}