1use std::collections::HashSet;
2
3use crate::ID;
4use abstio::Manifest;
5use abstutil::{prettyprint_bytes, prettyprint_usize, Counter, Timer};
6use geom::{Distance, Duration, UnitFmt};
7use map_gui::tools::{percentage_bar, ColorNetwork};
8use map_model::{PathRequest, PathStepV2, RoadID};
9use synthpop::{Scenario, TripEndpoint, TripMode};
10use widgetry::mapspace::ToggleZoomed;
11use widgetry::tools::{open_browser, FileLoader};
12use widgetry::{EventCtx, GfxCtx, Line, Outcome, Panel, Spinner, State, Text, TextExt, Widget};
13
14use crate::app::{App, Transition};
15use crate::ungap::{Layers, Tab, TakeLayers};
16
17pub struct ShowGaps {
18 top_panel: Panel,
19 layers: Layers,
20 tooltip: Option<Text>,
21}
22
23impl TakeLayers for ShowGaps {
24 fn take_layers(self) -> Layers {
25 self.layers
26 }
27}
28
29impl ShowGaps {
30 pub fn new_state(ctx: &mut EventCtx, app: &mut App, layers: Layers) -> Box<dyn State<App>> {
31 Box::new(ShowGaps {
32 top_panel: make_top_panel(ctx, app),
33 layers,
34 tooltip: None,
35 })
36 }
37}
38
39impl State<App> for ShowGaps {
40 fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Transition {
41 ctx.canvas_movement();
42 if ctx.redo_mouseover() {
43 self.tooltip = None;
44 if let Some(data) = app.session.mode_shift.value() {
45 if let Some(r) = match app.mouseover_unzoomed_roads_and_intersections(ctx) {
46 Some(ID::Road(r)) => Some(r),
47 Some(ID::Lane(l)) => Some(l.road),
48 _ => None,
49 } {
50 let count = data.gaps.count_per_road.get(r);
51 if count > 0 {
52 self.tooltip = Some(Text::from(Line(format!(
54 "{} trips might cross this high-stress road",
55 prettyprint_usize(count)
56 ))));
57 }
58 }
59 }
60 }
61
62 match self.top_panel.event(ctx) {
63 Outcome::Clicked(x) => {
64 if x == "read about how this prediction works" {
65 open_browser("https://a-b-street.github.io/docs/software/ungap_the_map/tech_details.html#predict-impact");
66 return Transition::Keep;
67 } else if x == "Calculate" {
68 let change_key = app.primary.map.get_edits_change_key();
69 let map_name = app.primary.map.get_name().clone();
70 let scenario_name = Scenario::default_scenario_for_map(&map_name);
71 return Transition::Push(FileLoader::<App, Scenario>::new_state(
72 ctx,
73 abstio::path_scenario(&map_name, &scenario_name),
74 Box::new(move |ctx, app, timer, maybe_scenario| {
75 let scenario = maybe_scenario.unwrap();
77 let data = ModeShiftData::from_scenario(ctx, app, scenario, timer);
78 app.session.mode_shift.set((map_name, change_key), data);
79
80 Transition::Multi(vec![
81 Transition::Pop,
82 Transition::ConsumeState(Box::new(|state, ctx, app| {
83 let state = state.downcast::<ShowGaps>().ok().unwrap();
84 vec![ShowGaps::new_state(ctx, app, state.take_layers())]
85 })),
86 ])
87 }),
88 ));
89 }
90
91 return Tab::PredictImpact
92 .handle_action::<ShowGaps>(ctx, app, &x)
93 .unwrap();
94 }
95 Outcome::Changed(_) => {
96 let (map_name, mut data) = app.session.mode_shift.take().unwrap();
97 data.filters = Filters::from_controls(&self.top_panel);
98 ctx.loading_screen("update mode shift", |ctx, timer| {
99 data.recalculate_gaps(ctx, app, timer)
100 });
101 app.session.mode_shift.set(map_name, data);
102 self.top_panel = make_top_panel(ctx, app);
104 }
105 _ => {}
106 }
107
108 if let Some(t) = self.layers.event(ctx, app) {
109 return t;
110 }
111
112 Transition::Keep
113 }
114
115 fn draw(&self, g: &mut GfxCtx, app: &App) {
116 self.top_panel.draw(g);
117 self.layers.draw(g, app);
118
119 if let Some(data) = app.session.mode_shift.value() {
120 data.gaps.draw.draw(g);
121 }
122 if let Some(ref txt) = self.tooltip {
123 g.draw_mouse_tooltip(txt.clone());
124 }
125 }
126}
127
128fn make_top_panel(ctx: &mut EventCtx, app: &App) -> Panel {
129 let map_name = app.primary.map.get_name().clone();
130 let change_key = app.primary.map.get_edits_change_key();
131 let col;
132
133 if app.session.mode_shift.key().as_ref() == Some(&(map_name.clone(), change_key)) {
134 let data = app.session.mode_shift.value().unwrap();
135
136 col = vec![
137 ctx.style()
138 .btn_plain
139 .icon_text(
140 "system/assets/tools/info.svg",
141 "How many drivers might switch to biking?",
142 )
143 .build_widget(ctx, "read about how this prediction works"),
144 percentage_bar(
145 ctx,
146 Text::from(Line(format!(
147 "{} total driving trips in this area",
148 prettyprint_usize(data.all_candidate_trips.len())
149 ))),
150 0.0,
151 ),
152 Widget::col(vec![
153 "Who might cycle if it was safer?".text_widget(ctx),
154 data.filters.to_controls(ctx),
155 percentage_bar(
156 ctx,
157 Text::from(Line(format!(
158 "{} / {} trips, based on these thresholds",
159 prettyprint_usize(data.filtered_trips.len()),
160 prettyprint_usize(data.all_candidate_trips.len())
161 ))),
162 pct(data.filtered_trips.len(), data.all_candidate_trips.len()),
163 ),
164 ])
165 .section(ctx),
166 Widget::col(vec![
167 "How many would switch based on your proposal?".text_widget(ctx),
168 percentage_bar(
169 ctx,
170 Text::from(Line(format!(
171 "{} / {} trips would switch",
172 prettyprint_usize(data.results.num_trips),
173 prettyprint_usize(data.all_candidate_trips.len())
174 ))),
175 pct(data.results.num_trips, data.all_candidate_trips.len()),
176 ),
177 data.results.describe().into_widget(ctx),
178 ])
179 .section(ctx),
180 ];
181 } else {
182 let scenario_name = Scenario::default_scenario_for_map(&map_name);
183 if scenario_name == "home_to_work" {
184 col =
185 vec!["This city doesn't have travel demand model data available".text_widget(ctx)];
186 } else {
187 let size = Manifest::load()
188 .get_entry(&abstio::path_scenario(&map_name, &scenario_name))
189 .map(|entry| prettyprint_bytes(entry.compressed_size_bytes))
190 .unwrap_or_else(|| "???".to_string());
191 col = vec![
192 Text::from_multiline(vec![
193 Line("Predicting impact of your proposal may take a moment."),
194 Line("The application may freeze up during that time."),
195 Line(format!("We need to load a {} file", size)),
196 ])
197 .into_widget(ctx),
198 ctx.style()
199 .btn_solid_primary
200 .text("Calculate")
201 .build_def(ctx),
202 ];
203 }
204 }
205
206 Tab::PredictImpact.make_left_panel(ctx, app, Widget::col(col))
207}
208
209pub struct ModeShiftData {
214 all_candidate_trips: Vec<CandidateTrip>,
216 filters: Filters,
217 gaps: NetworkGaps,
219 filtered_trips: Vec<usize>,
221 results: Results,
222}
223
224struct CandidateTrip {
225 bike_req: PathRequest,
226 estimated_biking_time: Duration,
227 driving_distance: Distance,
228 total_elevation_gain: Distance,
229}
230
231struct Filters {
232 max_biking_time: Duration,
233 max_elevation_gain: Distance,
234}
235
236struct NetworkGaps {
237 draw: ToggleZoomed,
238 count_per_road: Counter<RoadID>,
239}
240
241struct Results {
245 num_trips: usize,
246 total_driving_distance: Distance,
247 annual_co2_emissions_tons: f64,
248}
249
250impl Filters {
251 fn default() -> Self {
252 Self {
253 max_biking_time: Duration::minutes(30),
254 max_elevation_gain: Distance::feet(100.0),
255 }
256 }
257
258 fn apply(&self, x: &CandidateTrip) -> bool {
259 x.estimated_biking_time <= self.max_biking_time
260 && x.total_elevation_gain <= self.max_elevation_gain
261 }
262
263 fn to_controls(&self, ctx: &mut EventCtx) -> Widget {
264 Widget::col(vec![
265 Widget::row(vec![
266 "Max biking time".text_widget(ctx).centered_vert(),
267 Spinner::widget(
268 ctx,
269 "max_biking_time",
270 (Duration::ZERO, Duration::hours(12)),
271 self.max_biking_time,
272 Duration::minutes(1),
273 ),
274 ]),
275 Widget::row(vec![
276 "Max elevation gain".text_widget(ctx).centered_vert(),
277 Spinner::widget_with_custom_rendering(
278 ctx,
279 "max_elevation_gain",
280 (Distance::ZERO, Distance::meters(500.0)),
281 self.max_elevation_gain,
282 Distance::meters(1.0),
283 Box::new(|x| x.to_string(&UnitFmt::metric())),
286 ),
287 ]),
288 ])
289 }
290
291 fn from_controls(panel: &Panel) -> Filters {
292 Filters {
293 max_biking_time: panel.spinner("max_biking_time"),
294 max_elevation_gain: panel.spinner("max_elevation_gain"),
295 }
296 }
297}
298
299impl Results {
300 fn default() -> Self {
301 Self {
302 num_trips: 0,
303 total_driving_distance: Distance::ZERO,
304 annual_co2_emissions_tons: 0.0,
305 }
306 }
307
308 fn describe(&self) -> Text {
309 let mut txt = Text::new();
310 txt.add_line(Line(format!(
311 "{} total vehicle miles traveled daily, now eliminated",
312 prettyprint_usize(self.total_driving_distance.to_miles() as usize)
313 )));
314 let tons = (self.annual_co2_emissions_tons * 10.0).round() / 10.0;
316 txt.add_line(Line(format!(
317 "{} tons of CO2 emissions saved annually",
318 tons
319 )));
320 txt
321 }
322}
323
324impl ModeShiftData {
325 fn empty(ctx: &mut EventCtx) -> Self {
326 Self {
327 all_candidate_trips: Vec::new(),
328 filters: Filters::default(),
329 gaps: NetworkGaps {
330 draw: ToggleZoomed::empty(ctx),
331 count_per_road: Counter::new(),
332 },
333 filtered_trips: Vec::new(),
334 results: Results::default(),
335 }
336 }
337
338 fn from_scenario(
339 ctx: &mut EventCtx,
340 app: &App,
341 scenario: Scenario,
342 timer: &mut Timer,
343 ) -> ModeShiftData {
344 let unedited_map = app
345 .secondary
346 .as_ref()
347 .map(|x| &x.map)
348 .unwrap_or(&app.primary.map);
349 let all_candidate_trips = timer
350 .parallelize(
351 "analyze trips",
352 scenario
353 .all_trips()
354 .filter(|trip| {
355 trip.mode == TripMode::Drive
356 && matches!(trip.origin, TripEndpoint::Building(_))
357 && matches!(trip.destination, TripEndpoint::Building(_))
358 })
359 .collect(),
360 |trip| {
361 if let (Some(driving_path), Some(biking_path)) = (
363 TripEndpoint::path_req(
364 trip.origin,
365 trip.destination,
366 TripMode::Drive,
367 unedited_map,
368 )
369 .and_then(|req| unedited_map.pathfind(req).ok()),
370 TripEndpoint::path_req(
371 trip.origin,
372 trip.destination,
373 TripMode::Bike,
374 unedited_map,
375 )
376 .and_then(|req| unedited_map.pathfind(req).ok()),
377 ) {
378 let (total_elevation_gain, _) =
379 biking_path.get_total_elevation_change(unedited_map);
380 Some(CandidateTrip {
381 bike_req: biking_path.get_req().clone(),
382 estimated_biking_time: biking_path
383 .estimate_duration(unedited_map, Some(map_model::MAX_BIKE_SPEED)),
384 driving_distance: driving_path.total_length(),
385 total_elevation_gain,
386 })
387 } else {
388 None
389 }
390 },
391 )
392 .into_iter()
393 .flatten()
394 .collect();
395 let mut data = ModeShiftData::empty(ctx);
396 data.all_candidate_trips = all_candidate_trips;
397 data.recalculate_gaps(ctx, app, timer);
398 data
399 }
400
401 fn recalculate_gaps(&mut self, ctx: &mut EventCtx, app: &App, timer: &mut Timer) {
402 let unedited_map = app
403 .secondary
404 .as_ref()
405 .map(|x| &x.map)
406 .unwrap_or(&app.primary.map);
407
408 let mut high_stress = HashSet::new();
410 for r in unedited_map.all_roads() {
411 for dr in r.id.both_directions() {
412 if r.high_stress_for_bikes(unedited_map, dr.dir) {
413 high_stress.insert(dr);
414 }
415 }
416 }
417
418 self.filtered_trips.clear();
419 let mut filtered_requests = Vec::new();
420 for (idx, trip) in self.all_candidate_trips.iter().enumerate() {
421 if self.filters.apply(trip) {
422 self.filtered_trips.push(idx);
423 filtered_requests.push((idx, trip.bike_req.clone()));
424 }
425 }
426
427 self.results = Results::default();
428
429 let mut count_per_road = Counter::new();
430 for (idx, path) in timer
431 .parallelize("calculate routes", filtered_requests, |(idx, req)| {
432 unedited_map.pathfind_v2(req).map(|path| (idx, path))
433 })
434 .into_iter()
435 .flatten()
436 {
437 let mut crosses_edited_road = false;
438 for step in path.get_steps() {
439 if let PathStepV2::Along(dr) = step {
441 if high_stress.contains(dr) {
442 count_per_road.inc(dr.road);
443
444 if !crosses_edited_road
446 && app
447 .primary
448 .map
449 .get_edits()
450 .original_roads
451 .contains_key(&dr.road)
452 {
453 crosses_edited_road = true;
454 }
455 }
456 }
457 }
458 if crosses_edited_road {
459 self.results.num_trips += 1;
460 self.results.total_driving_distance +=
461 self.all_candidate_trips[idx].driving_distance;
462 }
463 }
464
465 let annual_mileage = 5.0 * 52.0 * self.results.total_driving_distance.to_miles();
467 self.results.annual_co2_emissions_tons = 404.0 * annual_mileage / 907185.0;
471
472 let mut colorer = ColorNetwork::no_fading(app);
473 colorer.ranked_roads(count_per_road.clone(), &app.cs.good_to_bad_red);
474 self.gaps = NetworkGaps {
475 draw: colorer.build(ctx),
476 count_per_road,
477 };
478 }
479}
480
481fn pct(value: usize, total: usize) -> f64 {
482 if total == 0 {
483 1.0
484 } else {
485 value as f64 / total as f64
486 }
487}