1use std::collections::BTreeSet;
2use std::fmt::Write;
3
4use anyhow::Result;
5
6use abstutil::prettyprint_usize;
7use geom::{Circle, Distance, Pt2D, Time};
8use map_gui::tools::{checkbox_per_mode, make_heatmap, HeatmapOptions};
9use map_model::Traversable;
10use sim::{Problem, ProblemType, TripInfo};
11use synthpop::TripMode;
12use widgetry::mapspace::ToggleZoomed;
13use widgetry::tools::PopupMsg;
14use widgetry::{
15 Color, EventCtx, GfxCtx, Line, Outcome, Panel, PanelDims, Slider, Text, TextExt, Toggle,
16 Transition, Widget,
17};
18
19use super::problems_diff::ProblemTypes;
20use crate::app::App;
21use crate::layer::{header, problems_diff, Layer, LayerOutcome, PANEL_PLACEMENT};
22
23pub struct ProblemMap {
24 time: Time,
25 opts: Options,
26 draw: ToggleZoomed,
27 panel: Panel,
28}
29
30impl Layer for ProblemMap {
31 fn name(&self) -> Option<&'static str> {
32 Some("problem map")
33 }
34 fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Option<LayerOutcome> {
35 if app.primary.sim.time() != self.time {
36 let mut new = ProblemMap::new(ctx, app, self.opts.clone());
37 new.panel.restore(ctx, &self.panel);
38 *self = new;
39 }
40
41 match self.panel.event(ctx) {
42 Outcome::Clicked(x) => match x.as_ref() {
43 "close" => {
44 return Some(LayerOutcome::Close);
45 }
46 "Export to CSV" => {
47 return Some(LayerOutcome::Transition(Transition::Push(
48 match export_raw_problems(app) {
49 Ok(path) => PopupMsg::new_state(
50 ctx,
51 "Data exported",
52 vec![format!("Data exported to {path}")],
53 ),
54 Err(err) => {
55 PopupMsg::new_state(ctx, "Export failed", vec![err.to_string()])
56 }
57 },
58 )));
59 }
60 _ => unreachable!(),
61 },
62 Outcome::Changed(x) => {
63 if x == "Compare before proposal" {
64 return Some(LayerOutcome::Replace(Box::new(
65 problems_diff::RelativeProblemMap::new(ctx, app, self.opts.types.clone()),
66 )));
67 }
68
69 let new_opts = self.options(app);
70 if self.opts != new_opts {
71 *self = ProblemMap::new(ctx, app, new_opts);
72 }
73 }
74 _ => {}
75 }
76 None
77 }
78 fn draw(&self, g: &mut GfxCtx, _: &App) {
79 self.panel.draw(g);
80 self.draw.draw(g);
81 }
82 fn draw_minimap(&self, g: &mut GfxCtx) {
83 g.redraw(&self.draw.unzoomed);
84 }
85}
86
87impl ProblemMap {
88 pub fn new(ctx: &mut EventCtx, app: &App, opts: Options) -> ProblemMap {
89 let mut pts = Vec::new();
90 for (trip, problems) in &app.primary.sim.get_analytics().problems_per_trip {
91 for (time, problem) in problems {
92 if opts.show(app.primary.sim.trip_info(*trip), *time, problem) {
93 pts.push(problem.point(&app.primary.map));
94 }
95 }
96 }
97 let num_pts = pts.len();
98
99 let mut draw = ToggleZoomed::builder();
100 let legend = if let Some(ref o) = opts.heatmap {
101 Some(make_heatmap(
102 ctx,
103 &mut draw.unzoomed,
104 app.primary.map.get_bounds(),
105 pts,
106 o,
107 ))
108 } else {
109 let circle = Circle::new(Pt2D::new(0.0, 0.0), Distance::meters(10.0)).to_polygon();
110 for pt in pts {
112 draw.unzoomed
113 .push(Color::PURPLE.alpha(0.8), circle.translate(pt.x(), pt.y()));
114 }
115 None
116 };
117 let controls = make_controls(ctx, app, &opts, legend, num_pts);
118 ProblemMap {
119 time: app.primary.sim.time(),
120 opts,
121 draw: draw.build(ctx),
122 panel: controls,
123 }
124 }
125
126 fn options(&self, app: &App) -> Options {
127 let heatmap = if self.panel.is_checked("Show heatmap") {
128 Some(HeatmapOptions::from_controls(&self.panel))
129 } else {
130 None
131 };
132 let mut modes = BTreeSet::new();
133 for m in TripMode::all() {
134 if self.panel.is_checked(m.ongoing_verb()) {
135 modes.insert(m);
136 }
137 }
138 let end_of_day = app.primary.sim.get_end_of_day();
139 Options {
140 heatmap,
141 modes,
142 time1: end_of_day.percent_of(self.panel.slider("time1").get_percent()),
143 time2: end_of_day.percent_of(self.panel.slider("time2").get_percent()),
144 types: ProblemTypes::from_controls(&self.panel),
145 }
146 }
147}
148
149#[derive(Clone, PartialEq)]
150pub struct Options {
151 heatmap: Option<HeatmapOptions>,
153 modes: BTreeSet<TripMode>,
154 time1: Time,
155 time2: Time,
156 pub types: ProblemTypes,
157}
158
159impl Options {
160 pub fn new(app: &App) -> Self {
161 Self {
162 heatmap: Some(HeatmapOptions::new()),
163 modes: TripMode::all().into_iter().collect(),
164 time1: Time::START_OF_DAY,
165 time2: app.primary.sim.get_end_of_day(),
166 types: ProblemTypes::new(),
167 }
168 }
169
170 fn show(&self, trip: TripInfo, time: Time, problem: &Problem) -> bool {
171 if !self.modes.contains(&trip.mode) || time < self.time1 || time > self.time2 {
172 return false;
173 }
174 self.types.show(problem)
175 }
176}
177
178fn make_controls(
179 ctx: &mut EventCtx,
180 app: &App,
181 opts: &Options,
182 legend: Option<Widget>,
183 num_problems: usize,
184) -> Panel {
185 let mut col = vec![
186 header(ctx, "Problems encountered"),
187 Text::from_all(vec![
188 Line("Matching problems: ").secondary(),
189 Line(prettyprint_usize(num_problems)),
190 ])
191 .into_widget(ctx),
192 ];
193 if app.has_prebaked().is_some() {
194 col.push(Toggle::switch(ctx, "Compare before proposal", None, false));
195 }
196
197 let end_of_day = app.primary.sim.get_end_of_day();
200 col.push(Widget::row(vec![
201 "Happening between:".text_widget(ctx).margin_right(20),
202 Slider::area(
203 ctx,
204 0.15 * ctx.canvas.window_width,
205 opts.time1.to_percent(end_of_day),
206 "time1",
207 )
208 .align_right(),
209 ]));
210 col.push(Widget::row(vec![
211 "and:".text_widget(ctx).margin_right(20),
212 Slider::area(
213 ctx,
214 0.15 * ctx.canvas.window_width,
215 opts.time2.to_percent(end_of_day),
216 "time2",
217 )
218 .align_right(),
219 ]));
220 col.push(checkbox_per_mode(ctx, app, &opts.modes));
221 col.push(opts.types.to_controls(ctx));
222
223 col.push(Toggle::choice(
224 ctx,
225 "Show heatmap",
226 "Heatmap",
227 "Points",
228 None,
229 opts.heatmap.is_some(),
230 ));
231 if let Some(ref o) = opts.heatmap {
232 col.push(Line("Heatmap Options").small_heading().into_widget(ctx));
233 col.extend(o.to_controls(ctx, legend.unwrap()));
234 }
235 col.push(ctx.style().btn_plain.text("Export to CSV").build_def(ctx));
236
237 Panel::new_builder(Widget::col(col))
238 .aligned_pair(PANEL_PLACEMENT)
239 .dims_height(PanelDims::MaxPercent(0.6))
241 .ignore_initial_events()
244 .build(ctx)
245}
246
247fn export_raw_problems(app: &App) -> Result<String> {
248 let map = &app.primary.map;
249 let path = format!(
250 "problems_{}_{}.csv",
251 map.get_name().as_filename(),
252 app.primary.sim.time().as_filename()
253 );
254 let mut out = String::new();
255 writeln!(out, "trip_id,time,problem_type,longitude,latitude,osm_url")?;
256 for (trip, problems) in &app.primary.sim.get_analytics().problems_per_trip {
257 for (time, problem) in problems {
258 let pt = problem.point(map).to_gps(map.get_gps_bounds());
259 let osm_url = match problem {
260 Problem::IntersectionDelay(i, _) | Problem::ComplexIntersectionCrossing(i) => {
261 map.get_i(*i).orig_id.to_string()
262 }
263 Problem::OvertakeDesired(on) | Problem::PedestrianOvercrowding(on) => match on {
264 Traversable::Lane(l) => map.get_r(l.road).orig_id.to_string(),
265 Traversable::Turn(t) => map.get_i(t.parent).orig_id.to_string(),
266 },
267 Problem::ArterialIntersectionCrossing(t) => map.get_i(t.parent).orig_id.to_string(),
268 };
269 writeln!(
270 out,
271 "{},{},{:?},{},{},{}",
272 trip.0,
273 time.inner_seconds(),
274 ProblemType::from(problem),
275 pt.x(),
276 pt.y(),
277 osm_url,
278 )?;
279 }
280 }
281
282 abstio::write_file(path, out)
283}