fifteen_min/
single_start.rs

1//! This is a tool to experiment with the concept of 15-minute neighborhoods. Can you access your
2//! daily needs (like groceries, a cafe, a library) within a 15-minute walk, bike ride, or public
3//! transit ride of your home?
4//!
5//! See https://github.com/a-b-street/abstreet/issues/393 for more context.
6
7use std::str::FromStr;
8
9use abstutil::prettyprint_usize;
10use geom::{Distance, FindClosest, Percent};
11use map_gui::ID;
12use map_model::{AmenityType, Building, BuildingID};
13use widgetry::tools::{ColorLegend, URLManager};
14use widgetry::{
15    Cached, Color, Drawable, EventCtx, GfxCtx, Key, Line, Outcome, Panel, State, Text, Transition,
16    Widget,
17};
18
19use crate::common::{HoverKey, HoverOnBuilding, HoverOnCategory};
20use crate::isochrone::{Isochrone, Options};
21use crate::{common, render, App};
22
23/// This is the UI state for exploring the isochrone/walkshed from a single building.
24pub struct SingleStart {
25    panel: Panel,
26    snap_to_buildings: FindClosest<BuildingID>,
27    draw_unwalkable_roads: Drawable,
28
29    highlight_start: Drawable,
30    isochrone: Isochrone,
31    hovering_on_bldg: Cached<HoverKey, HoverOnBuilding>,
32    hovering_on_category: HoverOnCategory,
33}
34
35impl SingleStart {
36    /// Start with a random building
37    pub fn random_start(ctx: &mut EventCtx, app: &App) -> Box<dyn State<App>> {
38        let bldgs = app.map.all_buildings();
39        let start = bldgs[bldgs.len() / 2].id;
40        Self::new_state(ctx, app, start)
41    }
42
43    pub fn new_state(ctx: &mut EventCtx, app: &App, start: BuildingID) -> Box<dyn State<App>> {
44        map_gui::tools::update_url_map_name(app);
45
46        let draw_unwalkable_roads = render::draw_unwalkable_roads(ctx, app);
47
48        let mut snap_to_buildings = FindClosest::new();
49        for b in app.map.all_buildings() {
50            snap_to_buildings.add_polygon(b.id, &b.polygon);
51        }
52
53        let start = app.map.get_b(start);
54        let isochrone = Isochrone::new(ctx, app, vec![start.id], app.session.clone());
55        let highlight_start = render::draw_star(ctx, start);
56        let contents = panel_contents(ctx, start, &isochrone);
57        let panel = common::build_panel(ctx, app, common::Mode::SingleStart, contents);
58
59        Box::new(Self {
60            panel,
61            snap_to_buildings,
62            highlight_start: ctx.upload(highlight_start),
63            isochrone,
64            hovering_on_bldg: Cached::new(),
65            hovering_on_category: HoverOnCategory::new(Color::RED),
66            draw_unwalkable_roads,
67        })
68    }
69
70    fn change_start(&mut self, ctx: &mut EventCtx, app: &App, b: BuildingID) {
71        if self.isochrone.start[0] == b {
72            return;
73        }
74
75        let start = app.map.get_b(b);
76        self.isochrone = Isochrone::new(ctx, app, vec![start.id], app.session.clone());
77        let star = render::draw_star(ctx, start);
78        self.highlight_start = ctx.upload(star);
79        let contents = panel_contents(ctx, start, &self.isochrone);
80        self.panel.replace(ctx, "contents", contents);
81        // Any previous hover is from the perspective of the old `highlight_start`.
82        // Remove it so we don't have a dotted line to the previous isochrone's origin
83        self.hovering_on_bldg.clear();
84    }
85}
86
87impl State<App> for SingleStart {
88    fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Transition<App> {
89        // Allow panning and zooming
90        if ctx.canvas_movement() {
91            URLManager::update_url_cam(ctx, app.map.get_gps_bounds());
92        }
93
94        if ctx.redo_mouseover() {
95            let isochrone = &self.isochrone;
96            self.hovering_on_bldg
97                .update(HoverOnBuilding::key(ctx, app), |key| {
98                    HoverOnBuilding::value(ctx, app, key, isochrone)
99                });
100            // Also update this to conveniently get an outline drawn. Note we don't want to do this
101            // inside the callback above, because it doesn't run when the key becomes None.
102            app.current_selection = self.hovering_on_bldg.key().map(|(b, _)| ID::Building(b));
103
104            self.hovering_on_category.update_on_mouse_move(
105                ctx,
106                app,
107                &self.panel,
108                &self.isochrone.amenities_reachable,
109            );
110
111            if ctx.is_key_down(Key::LeftControl) {
112                if let Some(cursor) = ctx.canvas.get_cursor_in_map_space() {
113                    if let Some((b, _)) = self
114                        .snap_to_buildings
115                        .closest_pt(cursor, Distance::meters(30.0))
116                    {
117                        self.change_start(ctx, app, b);
118                    }
119                }
120            }
121        }
122
123        // Don't call normal_left_click unless we're hovering on something in map-space; otherwise
124        // panel.event never sees clicks.
125        if let Some(cursor) = ctx.canvas.get_cursor_in_map_space() {
126            if ctx.normal_left_click() {
127                if let Some((b, _)) = self
128                    .snap_to_buildings
129                    .closest_pt(cursor, Distance::meters(30.0))
130                {
131                    self.change_start(ctx, app, b);
132                }
133            }
134        }
135
136        match self.panel.event(ctx) {
137            Outcome::Clicked(x) => {
138                if let Some(category) = x.strip_prefix("businesses: ") {
139                    return Transition::Push(
140                        crate::amenities_details::ExploreAmenitiesDetails::new_state(
141                            ctx,
142                            app,
143                            &self.isochrone,
144                            AmenityType::from_str(category).unwrap(),
145                        ),
146                    );
147                } else {
148                    return common::on_click(ctx, app, &x);
149                }
150            }
151            Outcome::Changed(_) => {
152                app.session = Options {
153                    movement: common::options_from_controls(&self.panel),
154                    thresholds: Options::default_thresholds(),
155                };
156                self.draw_unwalkable_roads = render::draw_unwalkable_roads(ctx, app);
157                self.isochrone =
158                    Isochrone::new(ctx, app, vec![self.isochrone.start[0]], app.session.clone());
159                let contents =
160                    panel_contents(ctx, app.map.get_b(self.isochrone.start[0]), &self.isochrone);
161                self.panel.replace(ctx, "contents", contents);
162            }
163            _ => {}
164        }
165
166        Transition::Keep
167    }
168
169    fn draw(&self, g: &mut GfxCtx, _: &App) {
170        self.isochrone.draw.draw(g);
171        g.redraw(&self.highlight_start);
172        g.redraw(&self.draw_unwalkable_roads);
173        self.panel.draw(g);
174        if let Some(hover) = self.hovering_on_bldg.value() {
175            g.draw_mouse_tooltip(hover.tooltip.clone());
176            g.redraw(&hover.drawn_route);
177        }
178        self.hovering_on_category.draw(g);
179    }
180}
181
182fn panel_contents(ctx: &mut EventCtx, start: &Building, isochrone: &Isochrone) -> Widget {
183    Widget::col(vec![
184        Text::from_all(vec![
185            Line("Click").fg(ctx.style().text_hotkey_color),
186            Line(" a building or hold ").secondary(),
187            Line(Key::LeftControl.describe()).fg(ctx.style().text_hotkey_color),
188            Line(" to change the start point"),
189        ])
190        .into_widget(ctx),
191        Text::from_all(vec![
192            Line("Starting from: ").secondary(),
193            Line(&start.address),
194        ])
195        .into_widget(ctx),
196        Text::from_all(vec![
197            Line("Estimated population: ").secondary(),
198            Line(prettyprint_usize(isochrone.population)),
199        ])
200        .into_widget(ctx),
201        Text::from_all(vec![
202            Line("Estimated street parking spots: ").secondary(),
203            Line(prettyprint_usize(isochrone.onstreet_parking_spots)),
204        ])
205        .into_widget(ctx),
206        ColorLegend::categories(
207            ctx,
208            vec![
209                (Color::GREEN, "0 mins"),
210                (Color::ORANGE, "5"),
211                (Color::RED, "10"),
212            ],
213            "15",
214        ),
215        Widget::custom_row(
216            isochrone
217                .amenities_reachable
218                .borrow()
219                .iter()
220                .map(|(amenity, buildings)| {
221                    ctx.style()
222                        .btn_outline
223                        .text(format!("{}: {}", amenity, buildings.len()))
224                        .build_widget(ctx, format!("businesses: {}", amenity))
225                        .margin_right(4)
226                        .margin_below(4)
227                })
228                .collect(),
229        )
230        .flex_wrap(ctx, Percent::int(30)),
231    ])
232}