fifteen_min/
score_homes.rs

1use std::collections::BTreeSet;
2
3use crate::App;
4use abstutil::{prettyprint_usize, Counter, MultiMap, Timer};
5use geom::Percent;
6use map_gui::tools::grey_out_map;
7use map_model::connectivity::Spot;
8use map_model::{AmenityType, BuildingID};
9use widgetry::tools::{ColorLegend, PopupMsg, URLManager};
10use widgetry::{
11    Color, DrawBaselayer, Drawable, EventCtx, GeomBatch, GfxCtx, Key, Line, Outcome, Panel,
12    SimpleState, State, Text, TextExt, Toggle, Transition, Widget,
13};
14
15use crate::isochrone::Options;
16use crate::{common, render};
17
18/// Ask what types of amenities are necessary to be within a walkshed, then rank every house with
19/// how many of those needs are satisfied.
20pub struct ScoreHomes;
21
22impl ScoreHomes {
23    pub fn new_state(
24        ctx: &mut EventCtx,
25        app: &App,
26        amenities: Vec<AmenityType>,
27    ) -> Box<dyn State<App>> {
28        let amenities_present = app.map.get_available_amenity_types();
29        let mut toggles = Vec::new();
30        let mut missing = Vec::new();
31        for at in AmenityType::all() {
32            if amenities_present.contains(&at) {
33                toggles.push(Toggle::switch(
34                    ctx,
35                    &at.to_string(),
36                    None,
37                    amenities.contains(&at),
38                ));
39            } else {
40                missing.push(at.to_string());
41            }
42        }
43
44        let panel = Panel::new_builder(Widget::col(vec![
45            Widget::row(vec![Line("Calculate acces scores")
46                .small_heading()
47                .into_widget(ctx)]),
48            // TODO Adjust text to say bikeshed, or otherwise reflect the options chosen
49            "Select the types of businesses you want within a 15 minute walkshed.".text_widget(ctx),
50            Widget::row(vec![
51                ctx.style().btn_outline.text("Enable all").build_def(ctx),
52                ctx.style().btn_outline.text("Disable all").build_def(ctx),
53            ]),
54            Widget::custom_row(toggles).flex_wrap(ctx, Percent::int(50)),
55            ctx.style()
56                .btn_solid_primary
57                .text("Calculate")
58                .hotkey(Key::Enter)
59                .build_def(ctx),
60            Text::from(
61                Line(format!(
62                    "These amenities aren't present in this map: {}",
63                    missing.join(", ")
64                ))
65                .secondary(),
66            )
67            .wrap_to_pct(ctx, 50)
68            .into_widget(ctx),
69        ]))
70        .build(ctx);
71
72        <dyn SimpleState<_>>::new_state(panel, Box::new(ScoreHomes))
73    }
74}
75
76impl SimpleState<App> for ScoreHomes {
77    fn on_click(
78        &mut self,
79        ctx: &mut EventCtx,
80        app: &mut App,
81        x: &str,
82        panel: &mut Panel,
83    ) -> Transition<App> {
84        match x {
85            "Enable all" => {
86                return Transition::Replace(Self::new_state(
87                    ctx,
88                    app,
89                    app.map.get_available_amenity_types().into_iter().collect(),
90                ));
91            }
92            "Disable all" => {
93                return Transition::Replace(Self::new_state(ctx, app, Vec::new()));
94            }
95            "Calculate" => {
96                let amenities: Vec<AmenityType> = AmenityType::all()
97                    .into_iter()
98                    .filter(|at| panel.maybe_is_checked(&at.to_string()).unwrap_or(false))
99                    .collect();
100                if amenities.is_empty() {
101                    return Transition::Push(PopupMsg::new_state(
102                        ctx,
103                        "No amenities selected",
104                        vec!["Please select at least one amenity that you want in your walkshd"],
105                    ));
106                }
107
108                return Transition::Multi(vec![
109                    Transition::Pop,
110                    Transition::Replace(Results::new_state(ctx, app, amenities)),
111                ]);
112            }
113            _ => unreachable!(),
114        }
115    }
116
117    fn draw(&self, g: &mut GfxCtx, app: &App) {
118        grey_out_map(g, app);
119    }
120
121    fn draw_baselayer(&self) -> DrawBaselayer {
122        DrawBaselayer::PreviousState
123    }
124}
125
126/// For every house in the map, return the number of amenity types located within a 15min walkshed.
127/// A single matching business per category is enough to count as satisfied.
128fn score_houses_by_one_match(
129    app: &App,
130    amenities: Vec<AmenityType>,
131    timer: &mut Timer,
132) -> (Counter<BuildingID>, MultiMap<AmenityType, BuildingID>) {
133    let mut satisfied_per_bldg: Counter<BuildingID> = Counter::new();
134    let mut amenities_reachable = MultiMap::new();
135
136    let map = &app.map;
137    let movement_opts = &app.session.movement;
138    for (category, stores, times) in
139        timer.parallelize("find houses close to amenities", amenities, |category| {
140            // For each category, find all matching stores
141            let mut stores = BTreeSet::new();
142            let mut spots = Vec::new();
143            for b in map.all_buildings() {
144                if b.has_amenity(category) {
145                    stores.insert(b.id);
146                    spots.push(Spot::Building(b.id));
147                }
148            }
149            (
150                category,
151                stores,
152                movement_opts.clone().times_from(map, spots),
153            )
154        })
155    {
156        amenities_reachable.set(category, stores);
157        for (b, _) in times {
158            satisfied_per_bldg.inc(b);
159        }
160    }
161
162    (satisfied_per_bldg, amenities_reachable)
163}
164
165// TODO Show the matching amenities.
166// TODO As you hover over a building, show the nearest amenity of each type
167struct Results {
168    panel: Panel,
169    draw_houses: Drawable,
170    amenities: Vec<AmenityType>,
171    amenities_reachable: MultiMap<AmenityType, BuildingID>,
172    draw_unwalkable_roads: Drawable,
173    hovering_on_category: common::HoverOnCategory,
174}
175
176impl Results {
177    fn new_state(
178        ctx: &mut EventCtx,
179        app: &App,
180        amenities: Vec<AmenityType>,
181    ) -> Box<dyn State<App>> {
182        let draw_unwalkable_roads = render::draw_unwalkable_roads(ctx, app);
183
184        assert!(!amenities.is_empty());
185        let (scores, amenities_reachable) = ctx.loading_screen("search for houses", |_, timer| {
186            score_houses_by_one_match(app, amenities.clone(), timer)
187        });
188
189        let mut batch = GeomBatch::new();
190        let mut matches_all = 0;
191
192        for (b, count) in scores.consume() {
193            if count == amenities.len() {
194                matches_all += 1;
195            }
196            let color = app
197                .cs
198                .good_to_bad_red
199                .eval((count as f64) / (amenities.len() as f64));
200            batch.push(color, app.map.get_b(b).polygon.clone());
201        }
202
203        let panel = build_panel(ctx, app, &amenities, &amenities_reachable, matches_all);
204
205        Box::new(Self {
206            draw_unwalkable_roads,
207            panel,
208            draw_houses: ctx.upload(batch),
209            amenities,
210            amenities_reachable,
211            hovering_on_category: common::HoverOnCategory::new(Color::YELLOW),
212        })
213    }
214}
215
216impl State<App> for Results {
217    fn event(&mut self, ctx: &mut EventCtx, app: &mut App) -> Transition<App> {
218        // Allow panning and zooming
219        if ctx.canvas_movement() {
220            URLManager::update_url_cam(ctx, app.map.get_gps_bounds());
221        }
222
223        if ctx.redo_mouseover() {
224            self.hovering_on_category.update_on_mouse_move(
225                ctx,
226                app,
227                &self.panel,
228                &self.amenities_reachable,
229            );
230        }
231
232        match self.panel.event(ctx) {
233            Outcome::Clicked(x) => {
234                if x == "change scoring criteria" {
235                    return Transition::Push(ScoreHomes::new_state(
236                        ctx,
237                        app,
238                        self.amenities.clone(),
239                    ));
240                } else if x.starts_with("businesses: ") {
241                    // TODO Use ExploreAmenitiesDetails, but omit duration
242                    return Transition::Keep;
243                }
244                return common::on_click(ctx, app, &x);
245            }
246            Outcome::Changed(_) => {
247                app.session = Options {
248                    movement: common::options_from_controls(&self.panel),
249                    thresholds: Options::default_thresholds(),
250                };
251                return Transition::Replace(Self::new_state(ctx, app, self.amenities.clone()));
252            }
253            _ => {}
254        }
255
256        Transition::Keep
257    }
258
259    fn draw(&self, g: &mut GfxCtx, _: &App) {
260        g.redraw(&self.draw_unwalkable_roads);
261        g.redraw(&self.draw_houses);
262        self.hovering_on_category.draw(g);
263        self.panel.draw(g);
264    }
265}
266
267fn build_panel(
268    ctx: &mut EventCtx,
269    app: &App,
270    amenities: &Vec<AmenityType>,
271    amenities_reachable: &MultiMap<AmenityType, BuildingID>,
272    matches_all: usize,
273) -> Panel {
274    let contents = vec![
275        "What homes are within 15 minutes away?".text_widget(ctx),
276        "Containing at least 1 of each:".text_widget(ctx),
277        Widget::custom_row(
278            amenities_reachable
279                .borrow()
280                .iter()
281                .map(|(amenity, buildings)| {
282                    ctx.style()
283                        .btn_outline
284                        .text(format!("{}: {}", amenity, buildings.len()))
285                        .build_widget(ctx, format!("businesses: {}", amenity))
286                        .margin_right(4)
287                        .margin_below(4)
288                })
289                .collect(),
290        )
291        .flex_wrap(ctx, Percent::int(30)),
292        format!(
293            "{} houses match all categories",
294            prettyprint_usize(matches_all)
295        )
296        .text_widget(ctx),
297        Line("Darker is better; more categories")
298            .secondary()
299            .into_widget(ctx),
300        ColorLegend::gradient_with_width(
301            ctx,
302            &app.cs.good_to_bad_red,
303            vec!["0", &amenities.len().to_string()],
304            150.0,
305        ),
306        ctx.style()
307            .btn_outline
308            .text("change scoring criteria")
309            .build_def(ctx),
310    ];
311
312    common::build_panel(ctx, app, common::Mode::ScoreHomes, Widget::col(contents))
313}