1#![warn(missing_docs)]
2
3pub mod config;
43
44#[cfg(feature = "layers")]
46pub mod layers;
47
48pub mod projection;
50
51use eframe::egui;
52use egui::{Color32, NumExt, Rect, Response, Sense, Ui, Vec2, Widget, pos2};
53use eyre::{Context, Result};
54use log::{debug, error};
55use poll_promise::Promise;
56use std::collections::{BTreeMap, HashMap};
57use std::sync::Arc;
58use thiserror::Error;
59
60use crate::config::MapConfig;
61use crate::layers::Layer;
62use crate::projection::{GeoPos, MapProjection};
63
64const TILE_SIZE: u32 = 256;
66pub const MIN_ZOOM: u8 = 0;
68pub const MAX_ZOOM: u8 = 19;
70
71static CLIENT: std::sync::LazyLock<reqwest::blocking::Client> = std::sync::LazyLock::new(|| {
73 reqwest::blocking::Client::builder()
74 .user_agent(format!(
75 "{}/{}",
76 env!("CARGO_PKG_NAME"),
77 env!("CARGO_PKG_VERSION")
78 ))
79 .build()
80 .expect("Failed to build reqwest client")
81});
82
83#[derive(Error, Debug)]
85pub enum MapError {
86 #[error("Connection error")]
88 ConnectionError(#[from] reqwest::Error),
89
90 #[error("A map tile failed to download. HTTP Status: `{0}`")]
92 TileDownloadError(String),
93
94 #[error("Unable to convert downloaded map tile bytes as image")]
96 TileBytesConversionError(#[from] image::ImageError),
97}
98
99#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq)]
101pub struct TileId {
102 pub z: u8,
104
105 pub x: u32,
107
108 pub y: u32,
110}
111
112impl TileId {
113 fn to_url(self, config: &dyn MapConfig) -> String {
114 config.tile_url(&self)
115 }
116}
117
118enum Tile {
120 Loading(Promise<Result<egui::ColorImage, Arc<eyre::Report>>>),
122
123 Loaded(egui::TextureHandle),
125
126 Failed(Arc<eyre::Report>),
128
129 Unknown,
131}
132
133pub struct Map {
135 pub center: GeoPos,
137
138 pub zoom: u8,
140
141 tiles: HashMap<TileId, Tile>,
142
143 pub mouse_pos: Option<GeoPos>,
145
146 config: Box<dyn MapConfig>,
148
149 layers: BTreeMap<String, Box<dyn Layer>>,
151}
152
153impl Map {
154 pub fn new<C: MapConfig + 'static>(config: C) -> Self {
160 let center = GeoPos::from(config.default_center());
161 let zoom = config.default_zoom();
162 Self {
163 tiles: HashMap::new(),
164 mouse_pos: None,
165 config: Box::new(config),
166 center,
167 zoom,
168 layers: BTreeMap::new(),
169 }
170 }
171
172 pub fn add_layer(&mut self, key: impl Into<String>, layer: impl Layer + 'static) {
174 self.layers.insert(key.into(), Box::new(layer));
175 }
176
177 pub fn remove_layer(&mut self, key: &str) -> bool {
179 self.layers.remove(key).is_some()
180 }
181
182 #[must_use]
184 pub fn layers(&self) -> &BTreeMap<String, Box<dyn Layer>> {
185 &self.layers
186 }
187
188 pub fn layers_mut(&mut self) -> &mut BTreeMap<String, Box<dyn Layer>> {
190 &mut self.layers
191 }
192
193 #[must_use]
195 pub fn layer<T: Layer>(&self, key: &str) -> Option<&T> {
196 self.layers
197 .get(key)
198 .and_then(|layer| layer.as_any().downcast_ref::<T>())
199 }
200
201 pub fn layer_mut<T: Layer>(&mut self, key: &str) -> Option<&mut T> {
203 self.layers
204 .get_mut(key)
205 .and_then(|layer| layer.as_any_mut().downcast_mut::<T>())
206 }
207
208 fn handle_input(&mut self, ui: &Ui, rect: &Rect, response: &Response) {
210 if response.dragged() {
212 let delta = response.drag_delta();
213 let center_in_tiles_x = lon_to_x(self.center.lon, self.zoom);
214 let center_in_tiles_y = lat_to_y(self.center.lat, self.zoom);
215
216 let mut new_center_x = center_in_tiles_x - (f64::from(delta.x) / f64::from(TILE_SIZE));
217 let mut new_center_y = center_in_tiles_y - (f64::from(delta.y) / f64::from(TILE_SIZE));
218
219 let world_size_in_tiles = 2.0_f64.powi(i32::from(self.zoom));
221 let view_size_in_tiles_x = f64::from(rect.width()) / f64::from(TILE_SIZE);
222 let view_size_in_tiles_y = f64::from(rect.height()) / f64::from(TILE_SIZE);
223
224 let min_center_x = view_size_in_tiles_x / 2.0;
225 let max_center_x = world_size_in_tiles - view_size_in_tiles_x / 2.0;
226 let min_center_y = view_size_in_tiles_y / 2.0;
227 let max_center_y = world_size_in_tiles - view_size_in_tiles_y / 2.0;
228
229 new_center_x = if min_center_x > max_center_x {
231 world_size_in_tiles / 2.0
232 } else {
233 new_center_x.clamp(min_center_x, max_center_x)
234 };
235 new_center_y = if min_center_y > max_center_y {
236 world_size_in_tiles / 2.0
237 } else {
238 new_center_y.clamp(min_center_y, max_center_y)
239 };
240
241 self.center = (
242 x_to_lon(new_center_x, self.zoom),
243 y_to_lat(new_center_y, self.zoom),
244 )
245 .into();
246 }
247
248 if response.double_clicked()
250 && let Some(pointer_pos) = response.interact_pointer_pos()
251 {
252 let new_zoom = (self.zoom + 1).clamp(MIN_ZOOM, MAX_ZOOM);
253
254 if new_zoom != self.zoom {
255 let mouse_rel = pointer_pos - rect.min;
257 let center_x = lon_to_x(self.center.lon, self.zoom);
258 let center_y = lat_to_y(self.center.lat, self.zoom);
259 let widget_center_x = f64::from(rect.width()) / 2.0;
260 let widget_center_y = f64::from(rect.height()) / 2.0;
261
262 let target_x =
263 center_x + (f64::from(mouse_rel.x) - widget_center_x) / f64::from(TILE_SIZE);
264 let target_y =
265 center_y + (f64::from(mouse_rel.y) - widget_center_y) / f64::from(TILE_SIZE);
266
267 let new_center_lon = x_to_lon(target_x, self.zoom);
268 let new_center_lat = y_to_lat(target_y, self.zoom);
269
270 self.zoom = new_zoom;
272 self.center = (new_center_lon, new_center_lat).into();
273 }
274 }
275
276 if response.hovered()
278 && let Some(mouse_pos) = response.hover_pos()
279 {
280 let mouse_rel = mouse_pos - rect.min;
281
282 let center_x = lon_to_x(self.center.lon, self.zoom);
284 let center_y = lat_to_y(self.center.lat, self.zoom);
285 let widget_center_x = f64::from(rect.width()) / 2.0;
286 let widget_center_y = f64::from(rect.height()) / 2.0;
287
288 let target_x =
289 center_x + (f64::from(mouse_rel.x) - widget_center_x) / f64::from(TILE_SIZE);
290 let target_y =
291 center_y + (f64::from(mouse_rel.y) - widget_center_y) / f64::from(TILE_SIZE);
292
293 let scroll = ui.input(|i| i.smooth_scroll_delta.y);
294 if scroll != 0.0 {
295 let old_zoom = self.zoom;
296 let mut new_zoom = (i32::from(self.zoom) + scroll.signum() as i32)
297 .clamp(i32::from(MIN_ZOOM), i32::from(MAX_ZOOM))
298 as u8;
299
300 if scroll < 0.0 {
302 let world_pixel_size = 2.0_f64.powi(i32::from(new_zoom)) * f64::from(TILE_SIZE);
303 if world_pixel_size < f64::from(rect.width())
305 || world_pixel_size < f64::from(rect.height())
306 {
307 new_zoom = old_zoom; }
309 }
310
311 if new_zoom != old_zoom {
312 let target_lon = x_to_lon(target_x, old_zoom);
313 let target_lat = y_to_lat(target_y, old_zoom);
314
315 self.zoom = new_zoom;
317
318 let new_target_x = lon_to_x(target_lon, new_zoom);
321 let new_target_y = lat_to_y(target_lat, new_zoom);
322
323 let new_center_x = new_target_x
324 - (f64::from(mouse_rel.x) - widget_center_x) / f64::from(TILE_SIZE);
325 let new_center_y = new_target_y
326 - (f64::from(mouse_rel.y) - widget_center_y) / f64::from(TILE_SIZE);
327
328 self.center = (
329 x_to_lon(new_center_x, new_zoom),
330 y_to_lat(new_center_y, new_zoom),
331 )
332 .into();
333 }
334 }
335 }
336 }
337
338 fn draw_attribution(&self, ui: &mut Ui, rect: &Rect) {
340 if let Some(attribution) = self.config.attribution() {
341 let (_text_color, bg_color) = if ui.visuals().dark_mode {
342 (Color32::from_gray(230), Color32::from_black_alpha(150))
343 } else {
344 (Color32::from_gray(80), Color32::from_white_alpha(150))
345 };
346
347 let frame = egui::Frame::NONE
348 .inner_margin(egui::Margin::same(5)) .fill(bg_color)
350 .corner_radius(3.0); let attribution_pos = rect.left_bottom() + egui::vec2(5.0, -5.0);
357
358 let mut child_ui = ui.new_child(
361 egui::UiBuilder::new()
362 .max_rect(Rect::from_min_size(
363 attribution_pos - egui::vec2(0.0, 30.0),
364 egui::vec2(rect.width() - 10.0, 30.0),
365 ))
366 .id_salt("attribution"),
367 );
368
369 child_ui.with_layout(egui::Layout::left_to_right(egui::Align::BOTTOM), |ui| {
370 frame.show(ui, |ui| {
371 ui.style_mut().override_text_style = Some(egui::TextStyle::Small);
372 ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); if let Some(url) = self.config.attribution_url() {
375 ui.hyperlink_to(attribution, url);
376 } else {
377 ui.label(attribution);
378 }
379 });
380 });
381 }
382 }
383}
384
385fn lon_to_x(lon: f64, zoom: u8) -> f64 {
387 (lon + 180.0) / 360.0 * (2.0_f64.powi(i32::from(zoom)))
388}
389
390fn lat_to_y(lat: f64, zoom: u8) -> f64 {
392 (1.0 - lat.to_radians().tan().asinh() / std::f64::consts::PI) / 2.0
393 * (2.0_f64.powi(i32::from(zoom)))
394}
395
396fn x_to_lon(x: f64, zoom: u8) -> f64 {
398 x / (2.0_f64.powi(i32::from(zoom))) * 360.0 - 180.0
399}
400
401fn y_to_lat(y: f64, zoom: u8) -> f64 {
403 let n = std::f64::consts::PI - 2.0 * std::f64::consts::PI * y / (2.0_f64.powi(i32::from(zoom)));
404 n.sinh().atan().to_degrees()
405}
406
407pub(crate) fn draw_map(
409 tiles: &mut HashMap<TileId, Tile>,
410 config: &dyn MapConfig,
411 painter: &egui::Painter,
412 projection: &MapProjection,
413) {
414 let visible_tiles: Vec<_> = visible_tiles(projection).collect();
415 for (tile_id, tile_pos) in visible_tiles {
416 load_tile(tiles, config, painter.ctx(), tile_id);
417 draw_tile(tiles, painter, &tile_id, tile_pos, Color32::WHITE);
418 }
419}
420
421pub(crate) fn visible_tiles(
423 projection: &MapProjection,
424) -> impl Iterator<Item = (TileId, egui::Pos2)> {
425 let center_x = lon_to_x(projection.center_lon, projection.zoom);
426 let center_y = lat_to_y(projection.center_lat, projection.zoom);
427
428 let widget_center_x = projection.widget_rect.width() / 2.0;
429 let widget_center_y = projection.widget_rect.height() / 2.0;
430
431 let x_min = (center_x - f64::from(widget_center_x) / f64::from(TILE_SIZE)).floor() as i32;
432 let y_min = (center_y - f64::from(widget_center_y) / f64::from(TILE_SIZE)).floor() as i32;
433 let x_max = (center_x + f64::from(widget_center_x) / f64::from(TILE_SIZE)).ceil() as i32;
434 let y_max = (center_y + f64::from(widget_center_y) / f64::from(TILE_SIZE)).ceil() as i32;
435
436 let zoom = projection.zoom;
437 let rect_min = projection.widget_rect.min;
438 (x_min..=x_max).flat_map(move |x| {
439 (y_min..=y_max).map(move |y| {
440 let tile_id = TileId {
441 z: zoom,
442 x: x as u32,
443 y: y as u32,
444 };
445 let screen_x = widget_center_x + (f64::from(x) - center_x) as f32 * TILE_SIZE as f32;
446 let screen_y = widget_center_y + (f64::from(y) - center_y) as f32 * TILE_SIZE as f32;
447 let tile_pos = rect_min + Vec2::new(screen_x, screen_y);
448 (tile_id, tile_pos)
449 })
450 })
451}
452
453pub(crate) fn load_tile(
455 tiles: &mut HashMap<TileId, Tile>,
456 config: &dyn MapConfig,
457 ctx: &egui::Context,
458 tile_id: TileId,
459) {
460 let tile_state = tiles.entry(tile_id).or_insert_with(|| {
461 let url = tile_id.to_url(config);
462 let promise =
463 Promise::spawn_thread("download_tile", move || -> Result<_, Arc<eyre::Report>> {
464 let result: Result<_, eyre::Report> = (|| {
465 debug!("Downloading tile from {}", &url);
466 let response = CLIENT.get(&url).send().map_err(MapError::from)?;
467
468 if !response.status().is_success() {
469 return Err(MapError::TileDownloadError(response.status().to_string()));
470 }
471
472 let bytes = response.bytes().map_err(MapError::from)?.to_vec();
473 let image = image::load_from_memory(&bytes)
474 .map_err(MapError::from)?
475 .to_rgba8();
476
477 let size = [image.width() as _, image.height() as _];
478 let pixels = image.into_raw();
479 Ok(egui::ColorImage::from_rgba_unmultiplied(size, &pixels))
480 })()
481 .with_context(|| format!("Failed to download tile from {}", &url));
482
483 result.map_err(Arc::new)
484 });
485 Tile::Loading(promise)
486 });
487
488 if let Tile::Loading(promise) = tile_state
492 && let Some(result) = promise.ready()
493 {
494 match result {
495 Ok(color_image) => {
496 let texture = ctx.load_texture(
497 format!("tile_{}_{}_{}", tile_id.z, tile_id.x, tile_id.y),
498 color_image.clone(),
499 Default::default(),
500 );
501 *tile_state = Tile::Loaded(texture);
502 }
503 Err(e) => {
504 error!("{e:?}");
505 *tile_state = Tile::Failed(e.clone());
506 }
507 }
508 }
509}
510
511pub(crate) fn draw_tile(
513 tiles: &HashMap<TileId, Tile>,
514 painter: &egui::Painter,
515 tile_id: &TileId,
516 tile_pos: egui::Pos2,
517 tint: Color32,
518) {
519 let tile_rect = Rect::from_min_size(tile_pos, Vec2::new(TILE_SIZE as f32, TILE_SIZE as f32));
520 let default_state = Tile::Unknown;
521 let tile_state = tiles.get(tile_id).unwrap_or(&default_state);
522 match tile_state {
523 Tile::Loading(_) => {
524 painter.rect_filled(tile_rect, 0.0, Color32::from_gray(220));
526 painter.rect_stroke(
527 tile_rect,
528 0.0,
529 egui::Stroke::new(1.0, Color32::GRAY),
530 egui::StrokeKind::Inside,
531 );
532
533 painter.text(
535 tile_rect.center(),
536 egui::Align2::CENTER_CENTER,
537 "⌛",
538 egui::FontId::proportional(40.0),
539 Color32::ORANGE,
540 );
541
542 painter.ctx().request_repaint();
544 }
545 Tile::Loaded(texture) => {
546 painter.image(
547 texture.id(),
548 tile_rect,
549 Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
550 tint,
551 );
552 }
553 Tile::Failed(e) => {
554 painter.rect_filled(tile_rect, 0.0, Color32::from_gray(220));
556 painter.rect_stroke(
557 tile_rect,
558 0.0,
559 egui::Stroke::new(1.0, Color32::GRAY),
560 egui::StrokeKind::Inside,
561 );
562
563 painter.text(
565 tile_rect.center(),
566 egui::Align2::CENTER_CENTER,
567 "❌",
568 egui::FontId::proportional(40.0),
569 Color32::RED,
570 );
571
572 error!("Failed to load tile: {e:?}");
574 }
575 Tile::Unknown => {
576 painter.rect_filled(tile_rect, 0.0, Color32::from_gray(220));
578 painter.rect_stroke(
579 tile_rect,
580 0.0,
581 egui::Stroke::new(1.0, Color32::GRAY),
582 egui::StrokeKind::Inside,
583 );
584
585 painter.text(
587 tile_rect.center(),
588 egui::Align2::CENTER_CENTER,
589 "❓",
590 egui::FontId::proportional(40.0),
591 Color32::RED,
592 );
593
594 error!("Tile state not found for {tile_id:?}");
595 }
596 }
597}
598
599impl Widget for &mut Map {
600 fn ui(self, ui: &mut Ui) -> Response {
601 let desired_size = if ui.layout().main_dir().is_horizontal() {
604 let side = TILE_SIZE as f32;
606 Vec2::splat(side)
607 } else {
608 let mut available_size = ui
610 .available_size()
611 .at_least(Vec2::new(TILE_SIZE as f32, TILE_SIZE as f32));
612 available_size.y = TILE_SIZE as f32;
613 available_size
614 };
615
616 let response = ui.allocate_response(desired_size, Sense::drag().union(Sense::click()));
617 let rect = response.rect;
618
619 let input_projection = MapProjection::new(self.zoom, self.center, rect);
621
622 let mut input_handled_by_layer = false;
623 for layer in self.layers.values_mut() {
624 if layer.handle_input(&response, &input_projection) {
625 input_handled_by_layer = true;
626 break; }
628 }
629
630 if !input_handled_by_layer {
631 self.handle_input(ui, &rect, &response);
632
633 if response.dragged() {
635 ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
636 } else if response.hovered() {
637 ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
638 }
639 }
640
641 self.mouse_pos = response
643 .hover_pos()
644 .map(|pos| input_projection.unproject(pos));
645
646 let draw_projection = MapProjection::new(self.zoom, self.center, rect);
648
649 let painter = ui.painter_at(rect);
650 painter.rect_filled(rect, 0.0, Color32::from_rgb(220, 220, 220)); draw_map(
653 &mut self.tiles,
654 self.config.as_ref(),
655 &painter,
656 &draw_projection,
657 );
658
659 for layer in self.layers.values() {
660 layer.draw(&painter, &draw_projection);
661 }
662
663 self.draw_attribution(ui, &rect);
664
665 response
666 }
667}
668
669#[cfg(test)]
670mod tests {
671 use super::*;
672 use crate::config::OpenStreetMapConfig;
673
674 const EPSILON: f64 = 1e-9;
675
676 #[test]
677 fn test_coord_conversion_roundtrip() {
678 let original_lon = 24.93545;
679 let original_lat = 60.16952;
680 let zoom: u8 = 10;
681
682 let x = lon_to_x(original_lon, zoom);
683 let y = lat_to_y(original_lat, zoom);
684
685 let final_lon = x_to_lon(x, zoom);
686 let final_lat = y_to_lat(y, zoom);
687
688 assert!((original_lon - final_lon).abs() < EPSILON);
689 assert!((original_lat - final_lat).abs() < EPSILON);
690
691 let original_lon = -122.4194;
692 let original_lat = 37.7749;
693
694 let x = lon_to_x(original_lon, zoom);
695 let y = lat_to_y(original_lat, zoom);
696
697 let final_lon = x_to_lon(x, zoom);
698 let final_lat = y_to_lat(y, zoom);
699
700 assert!((original_lon - final_lon).abs() < EPSILON);
701 assert!((original_lat - final_lat).abs() < EPSILON);
702 }
703
704 #[test]
705 fn test_y_to_lat_conversion() {
706 let test_cases = vec![
708 (0.5, 0, 0.0),
710 (128.0, 8, 0.0),
711 (0.0, 0, 85.0511287798),
713 (1.0, 0, -85.0511287798),
714 (0.0, 8, 85.0511287798),
715 (256.0, 8, -85.0511287798),
716 (9.262574089998255, 5, 60.16952),
718 (85.12653378959828, 8, 51.5074),
720 ];
721
722 for (y, zoom, expected_lat) in test_cases {
723 assert!((y_to_lat(y, zoom) - expected_lat).abs() < EPSILON);
724 }
725 }
726
727 #[test]
728 fn test_lat_to_y_conversion() {
729 let test_cases = vec![
731 (0.0, 0, 0.5),
733 (0.0, 8, 128.0),
734 (85.0511287798, 0, 0.0),
736 (-85.0511287798, 0, 1.0),
737 (85.0511287798, 8, 0.0),
738 (-85.0511287798, 8, 256.0),
739 (60.16952, 5, 9.262574089998255),
741 (51.5074, 8, 85.12653378959828),
743 ];
744
745 for (lat, zoom, expected_y) in test_cases {
746 assert!((lat_to_y(lat, zoom) - expected_y).abs() < EPSILON);
747 }
748 }
749
750 #[test]
751 fn test_x_to_lon_conversion() {
752 let test_cases = vec![
754 (0.5, 0, 0.0),
756 (128.0, 8, 0.0),
757 (0.0, 0, -180.0),
759 (1.0, 0, 180.0),
760 (0.0, 8, -180.0),
761 (256.0, 8, 180.0),
762 (18.216484444444444, 5, 24.93545),
764 ];
765
766 for (x, zoom, expected_lon) in test_cases {
767 assert!((x_to_lon(x, zoom) - expected_lon).abs() < EPSILON);
768 }
769 }
770
771 #[test]
772 fn test_lon_to_x_conversion() {
773 let test_cases = vec![
775 (0.0, 0, 0.5),
777 (0.0, 8, 128.0),
778 (-180.0, 0, 0.0),
780 (180.0, 0, 1.0), (-180.0, 8, 0.0),
782 (180.0, 8, 256.0),
783 (24.93545, 5, 18.216484444444444),
785 (-0.1275, 8, 127.90933333333333),
787 ];
788
789 for (lon, zoom, expected_x) in test_cases {
790 assert!((lon_to_x(lon, zoom) - expected_x).abs() < EPSILON);
791 }
792 }
793
794 #[test]
795 fn test_tile_id_to_url() {
796 let config = OpenStreetMapConfig::default();
797 let tile_id = TileId {
798 z: 10,
799 x: 559,
800 y: 330,
801 };
802 let url = tile_id.to_url(&config);
803 assert_eq!(url, "https://tile.openstreetmap.org/10/559/330.png");
804 }
805
806 #[test]
807 fn test_map_new() {
808 let config = OpenStreetMapConfig::default();
809 let default_center = config.default_center();
810 let default_zoom = config.default_zoom();
811
812 let map = Map::new(config);
813
814 assert_eq!(map.center, default_center.into());
815 assert_eq!(map.zoom, default_zoom);
816 assert!(map.mouse_pos.is_none());
817 assert!(map.tiles.is_empty());
818 }
819}