fifteen_min/
common.rs

1use std::str::FromStr;
2
3use abstutil::MultiMap;
4use geom::Distance;
5use map_gui::tools::{CityPicker, Navigator};
6use map_gui::ID;
7use map_model::connectivity::WalkingOptions;
8use map_model::{AmenityType, BuildingID};
9use widgetry::tools::{ColorLegend, PopupMsg};
10use widgetry::{
11    lctrl, Choice, Color, Drawable, EventCtx, GeomBatch, GfxCtx, HorizontalAlignment, Key, Panel,
12    Text, Toggle, Transition, VerticalAlignment, Widget,
13};
14
15use crate::isochrone::{Isochrone, MovementOptions, Options};
16use crate::App;
17
18pub enum Mode {
19    SingleStart,
20    StartFromAmenity,
21    ScoreHomes,
22}
23
24pub fn build_panel(ctx: &mut EventCtx, app: &App, mode: Mode, contents: Widget) -> Panel {
25    fn current_mode(ctx: &mut EventCtx, name: &str) -> Widget {
26        ctx.style()
27            .btn_solid_primary
28            .text(name)
29            .disabled(true)
30            .build_def(ctx)
31    }
32
33    let rows = vec![
34        map_gui::tools::app_header(ctx, app, "15-minute neighborhood explorer"),
35        Widget::row(vec![
36            ctx.style().btn_outline.text("About").build_def(ctx),
37            ctx.style()
38                .btn_outline
39                .text("Sketch bus route (experimental)")
40                .build_def(ctx),
41            ctx.style()
42                .btn_plain
43                .icon("system/assets/tools/search.svg")
44                .hotkey(lctrl(Key::F))
45                .build_widget(ctx, "search"),
46        ]),
47        Widget::horiz_separator(ctx, 1.0).margin_above(10),
48        Widget::row(vec![
49            if matches!(mode, Mode::SingleStart { .. }) {
50                current_mode(ctx, "Start from a building")
51            } else {
52                ctx.style()
53                    .btn_outline
54                    .text("Start from a building")
55                    .build_def(ctx)
56            },
57            if matches!(mode, Mode::StartFromAmenity { .. }) {
58                current_mode(ctx, "Start from an amenity")
59            } else {
60                ctx.style()
61                    .btn_outline
62                    .text("Start from an amenity")
63                    .build_def(ctx)
64            },
65            if matches!(mode, Mode::ScoreHomes { .. }) {
66                current_mode(ctx, "Score homes by access")
67            } else {
68                ctx.style()
69                    .btn_outline
70                    .text("Score homes by access")
71                    .build_def(ctx)
72            },
73        ]),
74        contents.named("contents"),
75        Widget::horiz_separator(ctx, 1.0).margin_above(10),
76        options_to_controls(ctx, &app.session),
77    ];
78
79    Panel::new_builder(Widget::col(rows))
80        .aligned(HorizontalAlignment::Right, VerticalAlignment::Top)
81        .build(ctx)
82}
83
84pub fn on_click(ctx: &mut EventCtx, app: &App, x: &str) -> Transition<App> {
85    match x {
86        "Sketch bus route (experimental)" => {
87            return Transition::Push(crate::bus::BusExperiment::new_state(ctx, app));
88        }
89        "Home" => {
90            return Transition::Clear(vec![map_gui::tools::TitleScreen::new_state(
91                ctx,
92                app,
93                map_gui::tools::Executable::FifteenMin,
94                Box::new(|ctx, app, _| crate::single_start::SingleStart::random_start(ctx, app)),
95            )]);
96        }
97        "change map" => {
98            return Transition::Push(CityPicker::new_state(
99                ctx,
100                app,
101                Box::new(|ctx, app| {
102                    Transition::Multi(vec![
103                        Transition::Pop,
104                        Transition::Replace(crate::single_start::SingleStart::random_start(
105                            ctx, app,
106                        )),
107                    ])
108                }),
109            ));
110        }
111        "About" => {
112            return Transition::Push(PopupMsg::new_state(
113                ctx,
114                "15-minute neighborhood explorer",
115                vec![
116                    "What if you could access most of your daily needs with a 15-minute \
117                             walk or bike ride from your house?",
118                    "Wouldn't it be nice to not rely on a climate unfriendly motor \
119                             vehicle and get stuck in traffic for these simple errands?",
120                    "Different cities around the world are talking about what design and \
121                             policy changes could lead to 15-minute neighborhoods.",
122                    "This tool lets you see what commercial amenities are near you right \
123                             now, using data from OpenStreetMap.",
124                    "",
125                    "Note that sidewalks and crosswalks are assumed on most roads.",
126                    "Especially around North Seattle, many roads lack sidewalks and \
127                             aren't safe for some people to use.",
128                    "We're working to improve the accuracy of the map.",
129                ],
130            ));
131        }
132        "search" => {
133            return Transition::Push(Navigator::new_state(ctx, app));
134        }
135        "Start from a building" => {
136            return Transition::Replace(crate::single_start::SingleStart::random_start(ctx, app));
137        }
138        "Start from an amenity" => {
139            return Transition::Replace(crate::from_amenity::FromAmenity::random_amenity(ctx, app));
140        }
141        "Score homes by access" => {
142            return Transition::Push(crate::score_homes::ScoreHomes::new_state(
143                ctx,
144                app,
145                Vec::new(),
146            ));
147        }
148        _ => panic!("Unhandled click {x}"),
149    }
150}
151
152fn options_to_controls(ctx: &mut EventCtx, opts: &Options) -> Widget {
153    let mut rows = vec![Toggle::choice(
154        ctx,
155        "walking / biking",
156        "walking",
157        "biking",
158        None,
159        match opts.movement {
160            MovementOptions::Walking(_) => true,
161            MovementOptions::Biking => false,
162        },
163    )];
164    match opts.movement {
165        MovementOptions::Walking(ref opts) => {
166            rows.push(Toggle::switch(
167                ctx,
168                "Allow walking on the shoulder of the road without a sidewalk",
169                None,
170                opts.allow_shoulders,
171            ));
172            rows.push(Widget::dropdown(
173                ctx,
174                "speed",
175                opts.walking_speed,
176                WalkingOptions::common_speeds()
177                    .into_iter()
178                    .map(|(label, speed)| Choice::new(label, speed))
179                    .collect(),
180            ));
181
182            rows.push(ColorLegend::row(ctx, Color::BLUE, "unwalkable roads"));
183        }
184        MovementOptions::Biking => {}
185    }
186    Widget::col(rows).section(ctx)
187}
188
189pub fn options_from_controls(panel: &Panel) -> MovementOptions {
190    if panel.is_checked("walking / biking") {
191        MovementOptions::Walking(WalkingOptions {
192            allow_shoulders: panel
193                .maybe_is_checked("Allow walking on the shoulder of the road without a sidewalk")
194                .unwrap_or(true),
195            walking_speed: panel
196                .maybe_dropdown_value("speed")
197                .unwrap_or_else(WalkingOptions::default_speed),
198        })
199    } else {
200        MovementOptions::Biking
201    }
202}
203
204pub struct HoverOnBuilding {
205    pub tooltip: Text,
206    pub drawn_route: Drawable,
207}
208/// (building, scale factor)
209pub type HoverKey = (BuildingID, f64);
210
211impl HoverOnBuilding {
212    pub fn key(ctx: &EventCtx, app: &App) -> Option<HoverKey> {
213        match app.mouseover_unzoomed_buildings(ctx) {
214            Some(ID::Building(b)) => {
215                let scale_factor = if ctx.canvas.is_zoomed() { 1.0 } else { 10.0 };
216                Some((b, scale_factor))
217            }
218            _ => None,
219        }
220    }
221
222    pub fn value(
223        ctx: &mut EventCtx,
224        app: &App,
225        key: HoverKey,
226        isochrone: &Isochrone,
227    ) -> HoverOnBuilding {
228        debug!("Calculating route for {:?}", key);
229
230        let (hover_id, scale_factor) = key;
231        let mut batch = GeomBatch::new();
232        if let Some(polyline) = isochrone
233            .path_to(&app.map, hover_id)
234            .and_then(|path| path.trace(&app.map))
235        {
236            let dashed_lines = polyline.dashed_lines(
237                Distance::meters(0.75 * scale_factor),
238                Distance::meters(1.0 * scale_factor),
239                Distance::meters(0.4 * scale_factor),
240            );
241            batch.extend(Color::BLACK, dashed_lines);
242        }
243
244        HoverOnBuilding {
245            tooltip: if let Some(time) = isochrone.time_to_reach_building.get(&hover_id) {
246                Text::from(format!("{} away", time))
247            } else {
248                Text::from("This is more than 15 minutes away")
249            },
250            drawn_route: ctx.upload(batch),
251        }
252    }
253}
254
255pub struct HoverOnCategory {
256    // TODO Try using Cached?
257    state: Option<(AmenityType, Drawable)>,
258    color: Color,
259}
260
261impl HoverOnCategory {
262    pub fn new(color: Color) -> Self {
263        Self { state: None, color }
264    }
265
266    pub fn update_on_mouse_move(
267        &mut self,
268        ctx: &EventCtx,
269        app: &App,
270        panel: &Panel,
271        amenities_reachable: &MultiMap<AmenityType, BuildingID>,
272    ) {
273        let key = panel
274            .currently_hovering()
275            .and_then(|x| x.strip_prefix("businesses: "));
276        if let Some(category) = key {
277            let category = AmenityType::from_str(category).unwrap();
278            if self
279                .state
280                .as_ref()
281                .map(|(cat, _)| *cat != category)
282                .unwrap_or(true)
283            {
284                let mut batch = GeomBatch::new();
285                for b in amenities_reachable.get(category) {
286                    batch.push(self.color, app.map.get_b(*b).polygon.clone());
287                }
288                self.state = Some((category, ctx.upload(batch)));
289            }
290        } else {
291            self.state = None;
292        }
293    }
294
295    pub fn draw(&self, g: &mut GfxCtx) {
296        if let Some((_, ref draw)) = self.state {
297            g.redraw(draw);
298        }
299    }
300}