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
18pub struct CityPicker<A: AppLike> {
20 panel: Panel,
21 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 Transition::Keep
565 }
566 }),
567 ));
568}