map_gui/tools/
city_picker.rs

1use std::collections::BTreeMap;
2
3use abstio::{CityName, Manifest, MapName};
4use geom::{Distance, Percent};
5use map_model::City;
6use widgetry::tools::FileLoader;
7use widgetry::{
8    lctrl, Autocomplete, ClickOutcome, ControlState, DrawBaselayer, DrawWithTooltips, EventCtx,
9    GeomBatch, GfxCtx, Image, Key, Line, Outcome, Panel, PanelDims, RewriteColor, State, Text,
10    TextExt, Transition, Widget,
11};
12
13use crate::load::MapLoader;
14use crate::render::DrawArea;
15use crate::tools::{grey_out_map, nice_country_name, nice_map_name};
16use crate::AppLike;
17
18/// Lets the player switch maps.
19pub struct CityPicker<A: AppLike> {
20    panel: Panel,
21    // Wrapped in an Option just to make calling from event() work.
22    on_load: Option<Box<dyn FnOnce(&mut EventCtx, &mut A) -> Transition<A>>>,
23}
24
25impl<A: AppLike + 'static> CityPicker<A> {
26    pub fn new_state(
27        ctx: &mut EventCtx,
28        app: &A,
29        on_load: Box<dyn FnOnce(&mut EventCtx, &mut A) -> Transition<A>>,
30    ) -> Box<dyn State<A>> {
31        let city = app.map().get_city_name().clone();
32        CityPicker::new_in_city(ctx, on_load, city)
33    }
34
35    fn new_in_city(
36        ctx: &mut EventCtx,
37        on_load: Box<dyn FnOnce(&mut EventCtx, &mut A) -> Transition<A>>,
38        city_name: CityName,
39    ) -> Box<dyn State<A>> {
40        FileLoader::<A, City>::new_state(
41            ctx,
42            abstio::path(format!(
43                "system/{}/{}/city.bin",
44                city_name.country, city_name.city
45            )),
46            Box::new(move |ctx, app, _, maybe_city| {
47                // If city.bin exists, use it to draw the district map.
48                let district_picker = if let Ok(city) = maybe_city {
49                    let bounds = city.boundary.get_bounds();
50
51                    let zoom = (0.8 * ctx.canvas.window_width / bounds.width())
52                        .min(0.8 * ctx.canvas.window_height / bounds.height());
53
54                    let mut batch = GeomBatch::new();
55                    batch.push(app.cs().map_background.clone(), city.boundary);
56                    for (area_type, polygon) in city.areas {
57                        batch.push(DrawArea::fill(area_type, app.cs()), polygon);
58                    }
59
60                    // If somebody has just generated a new map somewhere with an existing
61                    // city.bin, but hasn't updated city.bin yet, that new map will be invisible on
62                    // the city-wide diagram.
63                    let outline_color = app.cs().minimap_cursor_border;
64                    let mut tooltips = Vec::new();
65                    for (name, polygon) in city.districts {
66                        if &name != app.map().get_name() {
67                            if let Ok(zoomed_polygon) = polygon.scale(zoom) {
68                                batch.push(
69                                    outline_color,
70                                    polygon.to_outline(Distance::meters(200.0)),
71                                );
72                                tooltips.push((
73                                    zoomed_polygon,
74                                    Text::from(nice_map_name(&name)),
75                                    Some(ClickOutcome::Custom(Box::new(name))),
76                                ));
77                            }
78                        }
79                    }
80                    DrawWithTooltips::new_widget(
81                        ctx,
82                        batch.scale(zoom),
83                        tooltips,
84                        Box::new(move |poly| {
85                            GeomBatch::from(vec![(outline_color.alpha(0.5), poly.clone())])
86                        }),
87                    )
88                } else {
89                    Widget::nothing()
90                };
91
92                // Use the filesystem to list the buttons on the side.
93                // (There's no point in listing these from city.bin if it exists -- if somebody
94                // imports a new map in an existing city, it could be out of sync anyway.)
95                let mut this_city =
96                    vec![format!("More districts in {}", city_name.describe()).text_widget(ctx)];
97                for name in MapName::list_all_maps_in_city_merged(&city_name, &Manifest::load()) {
98                    this_city.push(
99                        ctx.style()
100                            .btn_outline
101                            .text(nice_map_name(&name))
102                            .no_tooltip()
103                            .disabled(&name == app.map().get_name())
104                            .build_widget(ctx, &name.path()),
105                    );
106                }
107
108                let mut other_places = vec![Line("Other places").into_widget(ctx)];
109                for (country, cities) in cities_per_country() {
110                    // If there's only one city and we're already there, skip it.
111                    if cities.len() == 1 && cities[0] == city_name {
112                        continue;
113                    }
114                    let flag_path = format!("system/assets/flags/{}.svg", country);
115                    if abstio::file_exists(abstio::path(&flag_path)) {
116                        other_places.push(
117                            ctx.style()
118                                .btn_outline
119                                .icon_text(
120                                    &flag_path,
121                                    format!("{} in {}", cities.len(), nice_country_name(&country)),
122                                )
123                                .image_color(RewriteColor::NoOp, ControlState::Default)
124                                .image_dims(30.0)
125                                .build_widget(ctx, &country),
126                        );
127                    } else {
128                        other_places.push(
129                            ctx.style()
130                                .btn_outline
131                                .text(format!(
132                                    "{} in {}",
133                                    cities.len(),
134                                    nice_country_name(&country)
135                                ))
136                                .build_widget(ctx, country),
137                        );
138                    }
139                }
140
141                Transition::Replace(Box::new(CityPicker {
142                    on_load: Some(on_load),
143                    panel: Panel::new_builder(Widget::col(vec![
144                        Widget::row(vec![
145                            Line("Select a district").small_heading().into_widget(ctx),
146                            ctx.style().btn_close_widget(ctx),
147                        ]),
148                        if cfg!(target_arch = "wasm32") {
149                            // On web, this is a link, so it's styled appropriately.
150                            ctx.style()
151                                .btn_plain
152                                .btn()
153                                .label_underlined_text("Import a new city into A/B Street")
154                                .build_widget(ctx, "import new city")
155                        } else {
156                            // On native this shows the "import" instructions modal within
157                            // the app
158                            Widget::row(vec![
159                                ctx.style()
160                                    .btn_outline
161                                    .text("Import a new city into A/B Street")
162                                    .build_widget(ctx, "import new city"),
163                                ctx.style()
164                                    .btn_outline
165                                    .text("Re-import this map with latest OpenStreetMap data")
166                                    .tooltip("OSM edits take a few minutes to appear in Overpass. Note this will create a new copy of the map, not overwrite the original.")
167                                    .build_widget(ctx, "re-import this city"),
168                            ])
169                        },
170                        ctx.style()
171                            .btn_outline
172                            .icon_text("system/assets/tools/search.svg", "Search all maps")
173                            .hotkey(lctrl(Key::F))
174                            .build_def(ctx),
175                        Widget::row(vec![
176                            Widget::col(other_places).centered_vert(),
177                            district_picker,
178                            Widget::col(this_city).centered_vert(),
179                        ]),
180                    ]))
181                    .build(ctx),
182                }))
183            }),
184        )
185    }
186}
187
188impl<A: AppLike + 'static> State<A> for CityPicker<A> {
189    fn event(&mut self, ctx: &mut EventCtx, app: &mut A) -> Transition<A> {
190        // TODO This happens if we prompt the user to download something, but they cancel. At that
191        // point, we've lost the callback, so for now, just totally bail out.
192        if self.on_load.is_none() {
193            return Transition::Pop;
194        }
195
196        match self.panel.event(ctx) {
197            Outcome::Clicked(x) => match x.as_ref() {
198                "close" => {
199                    return Transition::Pop;
200                }
201                "Search all maps" => {
202                    return Transition::Replace(AllCityPicker::new_state(
203                        ctx,
204                        self.on_load.take().unwrap(),
205                    ));
206                }
207                "import new city" => {
208                    #[cfg(target_arch = "wasm32")]
209                    {
210                        widgetry::tools::open_browser(
211                            "https://a-b-street.github.io/docs/user/new_city.html",
212                        );
213                    }
214                    #[cfg(not(target_arch = "wasm32"))]
215                    {
216                        return Transition::Replace(crate::tools::importer::ImportCity::new_state(
217                            ctx,
218                            self.on_load.take().unwrap(),
219                        ));
220                    }
221                }
222                "re-import this city" => {
223                    #[cfg(target_arch = "wasm32")]
224                    {
225                        unreachable!()
226                    }
227                    #[cfg(not(target_arch = "wasm32"))]
228                    {
229                        return reimport_city(ctx, app);
230                    }
231                }
232                x => {
233                    if let Some(name) = MapName::from_path(x) {
234                        return chose_city(ctx, app, name, &mut self.on_load);
235                    }
236                    // Browse cities for another country
237                    return Transition::Replace(CitiesInCountryPicker::new_state(
238                        ctx,
239                        app,
240                        self.on_load.take().unwrap(),
241                        x,
242                    ));
243                }
244            },
245            Outcome::ClickCustom(data) => {
246                let name = data.as_any().downcast_ref::<MapName>().unwrap();
247                return chose_city(ctx, app, name.clone(), &mut self.on_load);
248            }
249            _ => {}
250        }
251
252        Transition::Keep
253    }
254
255    fn draw_baselayer(&self) -> DrawBaselayer {
256        DrawBaselayer::PreviousState
257    }
258
259    fn draw(&self, g: &mut GfxCtx, app: &A) {
260        grey_out_map(g, app);
261        self.panel.draw(g);
262    }
263}
264
265struct AllCityPicker<A: AppLike> {
266    panel: Panel,
267    // Wrapped in an Option just to make calling from event() work.
268    on_load: Option<Box<dyn FnOnce(&mut EventCtx, &mut A) -> Transition<A>>>,
269}
270
271impl<A: AppLike + 'static> AllCityPicker<A> {
272    fn new_state(
273        ctx: &mut EventCtx,
274        on_load: Box<dyn FnOnce(&mut EventCtx, &mut A) -> Transition<A>>,
275    ) -> Box<dyn State<A>> {
276        let mut autocomplete_entries = Vec::new();
277        for name in MapName::list_all_maps_merged(&Manifest::load()) {
278            autocomplete_entries.push((name.describe(), name.path()));
279        }
280
281        Box::new(AllCityPicker {
282            on_load: Some(on_load),
283            panel: Panel::new_builder(Widget::col(vec![
284                Widget::row(vec![
285                    Line("Select a district").small_heading().into_widget(ctx),
286                    ctx.style().btn_close_widget(ctx),
287                ]),
288                Widget::row(vec![
289                    Image::from_path("system/assets/tools/search.svg").into_widget(ctx),
290                    Autocomplete::new_widget(ctx, autocomplete_entries, 10).named("search"),
291                ])
292                .padding(8),
293            ]))
294            .dims_width(PanelDims::ExactPercent(0.8))
295            .dims_height(PanelDims::ExactPercent(0.8))
296            .build(ctx),
297        })
298    }
299}
300
301impl<A: AppLike + 'static> State<A> for AllCityPicker<A> {
302    fn event(&mut self, ctx: &mut EventCtx, app: &mut A) -> Transition<A> {
303        // Same as CityPicker
304        if self.on_load.is_none() {
305            return Transition::Pop;
306        }
307
308        if let Outcome::Clicked(x) = self.panel.event(ctx) {
309            match x.as_ref() {
310                "close" => {
311                    return Transition::Pop;
312                }
313                _ => unreachable!(),
314            }
315        }
316        if let Some(mut paths) = self.panel.autocomplete_done::<String>("search") {
317            if !paths.is_empty() {
318                return chose_city(
319                    ctx,
320                    app,
321                    MapName::from_path(&paths.remove(0)).unwrap(),
322                    &mut self.on_load,
323                );
324            }
325        }
326
327        Transition::Keep
328    }
329
330    fn draw_baselayer(&self) -> DrawBaselayer {
331        DrawBaselayer::PreviousState
332    }
333
334    fn draw(&self, g: &mut GfxCtx, app: &A) {
335        grey_out_map(g, app);
336        self.panel.draw(g);
337    }
338}
339
340struct CitiesInCountryPicker<A: AppLike> {
341    panel: Panel,
342    // Wrapped in an Option just to make calling from event() work.
343    on_load: Option<Box<dyn FnOnce(&mut EventCtx, &mut A) -> Transition<A>>>,
344}
345
346impl<A: AppLike + 'static> CitiesInCountryPicker<A> {
347    fn new_state(
348        ctx: &mut EventCtx,
349        app: &A,
350        on_load: Box<dyn FnOnce(&mut EventCtx, &mut A) -> Transition<A>>,
351        country: &str,
352    ) -> Box<dyn State<A>> {
353        let flag_path = format!("system/assets/flags/{}.svg", country);
354        let draw_flag = if abstio::file_exists(abstio::path(&flag_path)) {
355            let flag = GeomBatch::load_svg(ctx, format!("system/assets/flags/{}.svg", country));
356            let y_factor = 30.0 / flag.get_dims().height;
357            flag.scale(y_factor).into_widget(ctx)
358        } else {
359            Widget::nothing()
360        };
361        let mut col = vec![Widget::row(vec![
362            draw_flag,
363            Line(format!("Select a city in {}", nice_country_name(country)))
364                .small_heading()
365                .into_widget(ctx),
366            ctx.style().btn_close_widget(ctx),
367        ])];
368
369        let mut buttons = Vec::new();
370        let mut last_letter = ' ';
371        for city in cities_per_country().remove(country).unwrap() {
372            if &city == app.map().get_city_name() {
373                continue;
374            }
375            let letter = city
376                .city
377                .chars()
378                .next()
379                .unwrap()
380                .to_uppercase()
381                .next()
382                .unwrap();
383            if last_letter != letter {
384                if !buttons.is_empty() {
385                    let mut row = vec![Line(last_letter)
386                        .small_heading()
387                        .into_widget(ctx)
388                        .margin_right(20)];
389                    row.append(&mut buttons);
390                    col.push(
391                        Widget::custom_row(row).flex_wrap_no_inner_spacing(ctx, Percent::int(70)),
392                    );
393                }
394
395                last_letter = letter;
396            }
397
398            buttons.push(
399                ctx.style()
400                    .btn_outline
401                    .text(&city.city)
402                    .build_widget(ctx, &city.to_path())
403                    .margin_right(10)
404                    .margin_below(10),
405            );
406        }
407        if !buttons.is_empty() {
408            let mut row = vec![Line(last_letter)
409                .small_heading()
410                .into_widget(ctx)
411                .margin_right(20)];
412            row.append(&mut buttons);
413            col.push(Widget::custom_row(row).flex_wrap_no_inner_spacing(ctx, Percent::int(70)));
414        }
415
416        Box::new(CitiesInCountryPicker {
417            on_load: Some(on_load),
418            panel: Panel::new_builder(Widget::col(col))
419                .dims_width(PanelDims::ExactPercent(0.8))
420                .dims_height(PanelDims::ExactPercent(0.8))
421                .build(ctx),
422        })
423    }
424}
425
426impl<A: AppLike + 'static> State<A> for CitiesInCountryPicker<A> {
427    fn event(&mut self, ctx: &mut EventCtx, app: &mut A) -> Transition<A> {
428        // Same as CityPicker
429        if self.on_load.is_none() {
430            return Transition::Pop;
431        }
432
433        if let Outcome::Clicked(x) = self.panel.event(ctx) {
434            match x.as_ref() {
435                "close" => {
436                    // Go back to the screen that lets you choose all countries.
437                    return Transition::Replace(CityPicker::new_state(
438                        ctx,
439                        app,
440                        self.on_load.take().unwrap(),
441                    ));
442                }
443                path => {
444                    let city = CityName::parse(path).unwrap();
445                    let mut maps = MapName::list_all_maps_in_city_merged(&city, &Manifest::load());
446                    if maps.len() == 1 {
447                        return chose_city(ctx, app, maps.pop().unwrap(), &mut self.on_load);
448                    }
449
450                    // We may need to grab city.bin
451                    #[cfg(not(target_arch = "wasm32"))]
452                    {
453                        let path = format!("system/{}/{}/city.bin", city.country, city.city);
454                        if Manifest::load()
455                            .entries
456                            .contains_key(&format!("data/{}", path))
457                            && !abstio::file_exists(abstio::path(path))
458                        {
459                            return crate::tools::prompt_to_download_missing_data(
460                                ctx,
461                                maps.pop().unwrap(),
462                                self.on_load.take().unwrap(),
463                            );
464                        }
465                    }
466
467                    return Transition::Replace(CityPicker::new_in_city(
468                        ctx,
469                        self.on_load.take().unwrap(),
470                        city,
471                    ));
472                }
473            }
474        }
475
476        Transition::Keep
477    }
478
479    fn draw_baselayer(&self) -> DrawBaselayer {
480        DrawBaselayer::PreviousState
481    }
482
483    fn draw(&self, g: &mut GfxCtx, app: &A) {
484        grey_out_map(g, app);
485        self.panel.draw(g);
486    }
487}
488
489fn cities_per_country() -> BTreeMap<String, Vec<CityName>> {
490    let mut per_country = BTreeMap::new();
491    for city in CityName::list_all_cities_merged(&Manifest::load()) {
492        per_country
493            .entry(city.country.clone())
494            .or_insert_with(Vec::new)
495            .push(city);
496    }
497    per_country
498}
499
500fn chose_city<A: AppLike + 'static>(
501    ctx: &mut EventCtx,
502    app: &mut A,
503    name: MapName,
504    on_load: &mut Option<Box<dyn FnOnce(&mut EventCtx, &mut A) -> Transition<A>>>,
505) -> Transition<A> {
506    #[cfg(not(target_arch = "wasm32"))]
507    {
508        if !abstio::file_exists(name.path()) {
509            let on_load = on_load.take().unwrap();
510            return crate::tools::prompt_to_download_missing_data(
511                ctx,
512                name.clone(),
513                Box::new(move |ctx, app| {
514                    Transition::Replace(MapLoader::new_state(ctx, app, name, on_load))
515                }),
516            );
517        }
518    }
519
520    Transition::Replace(MapLoader::new_state(
521        ctx,
522        app,
523        name,
524        on_load.take().unwrap(),
525    ))
526}
527
528#[cfg(not(target_arch = "wasm32"))]
529fn reimport_city<A: AppLike + 'static>(ctx: &mut EventCtx, app: &A) -> Transition<A> {
530    let name = format!("updated_{}", app.map().get_name().as_filename());
531
532    let args = vec![
533        crate::tools::find_exe("cli"),
534        "one-step-import".to_string(),
535        "--geojson-path=boundary.json".to_string(),
536        format!("--map-name={}", name),
537    ];
538
539    // Write the current map boundary
540    abstio::write_json(
541        "boundary.json".to_string(),
542        &geom::geometries_to_geojson(vec![app
543            .map()
544            .get_boundary_polygon()
545            .to_geojson(Some(app.map().get_gps_bounds()))]),
546    );
547
548    return Transition::Push(crate::tools::RunCommand::new_state(
549        ctx,
550        true,
551        args,
552        Box::new(|_, _, success, _| {
553            if success {
554                abstio::delete_file("boundary.json");
555
556                Transition::ConsumeState(Box::new(move |state, ctx, app| {
557                    let mut state = state.downcast::<CityPicker<A>>().ok().unwrap();
558                    let on_load = state.on_load.take().unwrap();
559                    let map_name = MapName::new("zz", "oneshot", &name);
560                    vec![MapLoader::new_state(ctx, app, map_name, on_load)]
561                }))
562            } else {
563                // The popup already explained the failure
564                Transition::Keep
565            }
566        }),
567    ));
568}