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 min_zoom = config.min_zoom();
162 let max_zoom = config.max_zoom().max(min_zoom);
163 let zoom = config.default_zoom().clamp(min_zoom, max_zoom);
164 Self {
165 tiles: HashMap::new(),
166 mouse_pos: None,
167 config: Box::new(config),
168 center,
169 zoom,
170 layers: BTreeMap::new(),
171 }
172 }
173
174 pub fn add_layer(&mut self, key: impl Into<String>, layer: impl Layer + 'static) {
176 self.layers.insert(key.into(), Box::new(layer));
177 }
178
179 pub fn remove_layer(&mut self, key: &str) -> bool {
181 self.layers.remove(key).is_some()
182 }
183
184 #[must_use]
186 pub fn layers(&self) -> &BTreeMap<String, Box<dyn Layer>> {
187 &self.layers
188 }
189
190 pub fn layers_mut(&mut self) -> &mut BTreeMap<String, Box<dyn Layer>> {
192 &mut self.layers
193 }
194
195 #[must_use]
197 pub fn layer<T: Layer>(&self, key: &str) -> Option<&T> {
198 self.layers
199 .get(key)
200 .and_then(|layer| layer.as_any().downcast_ref::<T>())
201 }
202
203 pub fn layer_mut<T: Layer>(&mut self, key: &str) -> Option<&mut T> {
205 self.layers
206 .get_mut(key)
207 .and_then(|layer| layer.as_any_mut().downcast_mut::<T>())
208 }
209
210 fn handle_input(&mut self, ui: &Ui, rect: &Rect, response: &Response) {
212 if response.dragged() {
214 let delta = response.drag_delta();
215 let center_in_tiles_x = lon_to_x(self.center.lon, self.zoom);
216 let center_in_tiles_y = lat_to_y(self.center.lat, self.zoom);
217
218 let mut new_center_x = center_in_tiles_x - (f64::from(delta.x) / f64::from(TILE_SIZE));
219 let mut new_center_y = center_in_tiles_y - (f64::from(delta.y) / f64::from(TILE_SIZE));
220
221 let world_size_in_tiles = 2.0_f64.powi(i32::from(self.zoom));
223 let view_size_in_tiles_x = f64::from(rect.width()) / f64::from(TILE_SIZE);
224 let view_size_in_tiles_y = f64::from(rect.height()) / f64::from(TILE_SIZE);
225
226 let min_center_x = view_size_in_tiles_x / 2.0;
227 let max_center_x = world_size_in_tiles - view_size_in_tiles_x / 2.0;
228 let min_center_y = view_size_in_tiles_y / 2.0;
229 let max_center_y = world_size_in_tiles - view_size_in_tiles_y / 2.0;
230
231 new_center_x = if min_center_x > max_center_x {
233 world_size_in_tiles / 2.0
234 } else {
235 new_center_x.clamp(min_center_x, max_center_x)
236 };
237 new_center_y = if min_center_y > max_center_y {
238 world_size_in_tiles / 2.0
239 } else {
240 new_center_y.clamp(min_center_y, max_center_y)
241 };
242
243 self.center = (
244 x_to_lon(new_center_x, self.zoom),
245 y_to_lat(new_center_y, self.zoom),
246 )
247 .into();
248 }
249
250 if response.double_clicked()
252 && let Some(pointer_pos) = response.interact_pointer_pos()
253 {
254 let min_zoom = self.config.min_zoom();
255 let max_zoom = self.config.max_zoom().max(min_zoom);
256 let new_zoom = (self.zoom + 1).clamp(min_zoom, max_zoom);
257
258 if new_zoom != self.zoom {
259 let mouse_rel = pointer_pos - rect.min;
261 let center_x = lon_to_x(self.center.lon, self.zoom);
262 let center_y = lat_to_y(self.center.lat, self.zoom);
263 let widget_center_x = f64::from(rect.width()) / 2.0;
264 let widget_center_y = f64::from(rect.height()) / 2.0;
265
266 let target_x =
267 center_x + (f64::from(mouse_rel.x) - widget_center_x) / f64::from(TILE_SIZE);
268 let target_y =
269 center_y + (f64::from(mouse_rel.y) - widget_center_y) / f64::from(TILE_SIZE);
270
271 let new_center_lon = x_to_lon(target_x, self.zoom);
272 let new_center_lat = y_to_lat(target_y, self.zoom);
273
274 self.zoom = new_zoom;
276 self.center = (new_center_lon, new_center_lat).into();
277 }
278 }
279
280 if response.hovered()
282 && let Some(mouse_pos) = response.hover_pos()
283 {
284 let mouse_rel = mouse_pos - rect.min;
285
286 let center_x = lon_to_x(self.center.lon, self.zoom);
288 let center_y = lat_to_y(self.center.lat, self.zoom);
289 let widget_center_x = f64::from(rect.width()) / 2.0;
290 let widget_center_y = f64::from(rect.height()) / 2.0;
291
292 let target_x =
293 center_x + (f64::from(mouse_rel.x) - widget_center_x) / f64::from(TILE_SIZE);
294 let target_y =
295 center_y + (f64::from(mouse_rel.y) - widget_center_y) / f64::from(TILE_SIZE);
296
297 let scroll = ui.input(|i| i.smooth_scroll_delta.y);
298 if scroll != 0.0 {
299 let min_zoom = self.config.min_zoom();
300 let max_zoom = self.config.max_zoom().max(min_zoom);
301 let old_zoom = self.zoom;
302 let mut new_zoom = (i32::from(self.zoom) + scroll.signum() as i32)
303 .clamp(i32::from(min_zoom), i32::from(max_zoom))
304 as u8;
305
306 if scroll < 0.0 {
308 let world_pixel_size = 2.0_f64.powi(i32::from(new_zoom)) * f64::from(TILE_SIZE);
309 if world_pixel_size < f64::from(rect.width())
311 || world_pixel_size < f64::from(rect.height())
312 {
313 new_zoom = old_zoom; }
315 }
316
317 if new_zoom != old_zoom {
318 let target_lon = x_to_lon(target_x, old_zoom);
319 let target_lat = y_to_lat(target_y, old_zoom);
320
321 self.zoom = new_zoom;
323
324 let new_target_x = lon_to_x(target_lon, new_zoom);
327 let new_target_y = lat_to_y(target_lat, new_zoom);
328
329 let new_center_x = new_target_x
330 - (f64::from(mouse_rel.x) - widget_center_x) / f64::from(TILE_SIZE);
331 let new_center_y = new_target_y
332 - (f64::from(mouse_rel.y) - widget_center_y) / f64::from(TILE_SIZE);
333
334 self.center = (
335 x_to_lon(new_center_x, new_zoom),
336 y_to_lat(new_center_y, new_zoom),
337 )
338 .into();
339 }
340 }
341 }
342 }
343
344 fn draw_attribution(&self, ui: &mut Ui, rect: &Rect) {
346 if let Some(attribution) = self.config.attribution() {
347 let (_text_color, bg_color) = if ui.visuals().dark_mode {
348 (Color32::from_gray(230), Color32::from_black_alpha(150))
349 } else {
350 (Color32::from_gray(80), Color32::from_white_alpha(150))
351 };
352
353 let frame = egui::Frame::NONE
354 .inner_margin(egui::Margin::same(5)) .fill(bg_color)
356 .corner_radius(3.0); let attribution_pos = rect.left_bottom() + egui::vec2(5.0, -5.0);
363
364 let mut child_ui = ui.new_child(
367 egui::UiBuilder::new()
368 .max_rect(Rect::from_min_size(
369 attribution_pos - egui::vec2(0.0, 30.0),
370 egui::vec2(rect.width() - 10.0, 30.0),
371 ))
372 .id_salt("attribution"),
373 );
374
375 child_ui.with_layout(egui::Layout::left_to_right(egui::Align::BOTTOM), |ui| {
376 frame.show(ui, |ui| {
377 ui.style_mut().override_text_style = Some(egui::TextStyle::Small);
378 ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); if let Some(url) = self.config.attribution_url() {
381 ui.hyperlink_to(attribution, url);
382 } else {
383 ui.label(attribution);
384 }
385 });
386 });
387 }
388 }
389}
390
391fn lon_to_x(lon: f64, zoom: u8) -> f64 {
393 (lon + 180.0) / 360.0 * (2.0_f64.powi(i32::from(zoom)))
394}
395
396fn lat_to_y(lat: f64, zoom: u8) -> f64 {
398 (1.0 - lat.to_radians().tan().asinh() / std::f64::consts::PI) / 2.0
399 * (2.0_f64.powi(i32::from(zoom)))
400}
401
402fn x_to_lon(x: f64, zoom: u8) -> f64 {
404 x / (2.0_f64.powi(i32::from(zoom))) * 360.0 - 180.0
405}
406
407fn y_to_lat(y: f64, zoom: u8) -> f64 {
409 let n = std::f64::consts::PI - 2.0 * std::f64::consts::PI * y / (2.0_f64.powi(i32::from(zoom)));
410 n.sinh().atan().to_degrees()
411}
412
413pub(crate) fn draw_map(
415 tiles: &mut HashMap<TileId, Tile>,
416 config: &dyn MapConfig,
417 painter: &egui::Painter,
418 projection: &MapProjection,
419) {
420 let visible_tiles: Vec<_> = visible_tiles(projection).collect();
421 for (tile_id, tile_pos) in visible_tiles {
422 load_tile(tiles, config, painter.ctx(), tile_id);
423 draw_tile(tiles, painter, &tile_id, tile_pos, Color32::WHITE);
424 }
425}
426
427pub(crate) fn visible_tiles(
429 projection: &MapProjection,
430) -> impl Iterator<Item = (TileId, egui::Pos2)> {
431 let center_x = lon_to_x(projection.center_lon, projection.zoom);
432 let center_y = lat_to_y(projection.center_lat, projection.zoom);
433
434 let widget_center_x = projection.widget_rect.width() / 2.0;
435 let widget_center_y = projection.widget_rect.height() / 2.0;
436
437 let x_min = (center_x - f64::from(widget_center_x) / f64::from(TILE_SIZE)).floor() as i32;
438 let y_min = (center_y - f64::from(widget_center_y) / f64::from(TILE_SIZE)).floor() as i32;
439 let x_max = (center_x + f64::from(widget_center_x) / f64::from(TILE_SIZE)).ceil() as i32;
440 let y_max = (center_y + f64::from(widget_center_y) / f64::from(TILE_SIZE)).ceil() as i32;
441
442 let zoom = projection.zoom;
443 let rect_min = projection.widget_rect.min;
444 (x_min..=x_max).flat_map(move |x| {
445 (y_min..=y_max).map(move |y| {
446 let tile_id = TileId {
447 z: zoom,
448 x: x as u32,
449 y: y as u32,
450 };
451 let screen_x = widget_center_x + (f64::from(x) - center_x) as f32 * TILE_SIZE as f32;
452 let screen_y = widget_center_y + (f64::from(y) - center_y) as f32 * TILE_SIZE as f32;
453 let tile_pos = rect_min + Vec2::new(screen_x, screen_y);
454 (tile_id, tile_pos)
455 })
456 })
457}
458
459pub(crate) fn load_tile(
461 tiles: &mut HashMap<TileId, Tile>,
462 config: &dyn MapConfig,
463 ctx: &egui::Context,
464 tile_id: TileId,
465) {
466 let tile_state = tiles.entry(tile_id).or_insert_with(|| {
467 let url = tile_id.to_url(config);
468 let promise =
469 Promise::spawn_thread("download_tile", move || -> Result<_, Arc<eyre::Report>> {
470 let result: Result<_, eyre::Report> = (|| {
471 debug!("Downloading tile from {}", &url);
472 let response = CLIENT.get(&url).send().map_err(MapError::from)?;
473
474 if !response.status().is_success() {
475 return Err(MapError::TileDownloadError(response.status().to_string()));
476 }
477
478 let bytes = response.bytes().map_err(MapError::from)?.to_vec();
479 let image = image::load_from_memory(&bytes)
480 .map_err(MapError::from)?
481 .to_rgba8();
482
483 let size = [image.width() as _, image.height() as _];
484 let pixels = image.into_raw();
485 Ok(egui::ColorImage::from_rgba_unmultiplied(size, &pixels))
486 })()
487 .with_context(|| format!("Failed to download tile from {}", &url));
488
489 result.map_err(Arc::new)
490 });
491 Tile::Loading(promise)
492 });
493
494 if let Tile::Loading(promise) = tile_state
498 && let Some(result) = promise.ready()
499 {
500 match result {
501 Ok(color_image) => {
502 let texture = ctx.load_texture(
503 format!("tile_{}_{}_{}", tile_id.z, tile_id.x, tile_id.y),
504 color_image.clone(),
505 Default::default(),
506 );
507 *tile_state = Tile::Loaded(texture);
508 }
509 Err(e) => {
510 error!("{e:?}");
511 *tile_state = Tile::Failed(e.clone());
512 }
513 }
514 }
515}
516
517pub(crate) fn draw_tile(
519 tiles: &HashMap<TileId, Tile>,
520 painter: &egui::Painter,
521 tile_id: &TileId,
522 tile_pos: egui::Pos2,
523 tint: Color32,
524) {
525 let tile_rect = Rect::from_min_size(tile_pos, Vec2::new(TILE_SIZE as f32, TILE_SIZE as f32));
526 let default_state = Tile::Unknown;
527 let tile_state = tiles.get(tile_id).unwrap_or(&default_state);
528 match tile_state {
529 Tile::Loading(_) => {
530 painter.rect_filled(tile_rect, 0.0, Color32::from_gray(220));
532 painter.rect_stroke(
533 tile_rect,
534 0.0,
535 egui::Stroke::new(1.0, Color32::GRAY),
536 egui::StrokeKind::Inside,
537 );
538
539 painter.text(
541 tile_rect.center(),
542 egui::Align2::CENTER_CENTER,
543 "⌛",
544 egui::FontId::proportional(40.0),
545 Color32::ORANGE,
546 );
547
548 painter.ctx().request_repaint();
550 }
551 Tile::Loaded(texture) => {
552 painter.image(
553 texture.id(),
554 tile_rect,
555 Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
556 tint,
557 );
558 }
559 Tile::Failed(e) => {
560 painter.rect_filled(tile_rect, 0.0, Color32::from_gray(220));
562 painter.rect_stroke(
563 tile_rect,
564 0.0,
565 egui::Stroke::new(1.0, Color32::GRAY),
566 egui::StrokeKind::Inside,
567 );
568
569 painter.text(
571 tile_rect.center(),
572 egui::Align2::CENTER_CENTER,
573 "❌",
574 egui::FontId::proportional(40.0),
575 Color32::RED,
576 );
577
578 error!("Failed to load tile: {e:?}");
580 }
581 Tile::Unknown => {
582 painter.rect_filled(tile_rect, 0.0, Color32::from_gray(220));
584 painter.rect_stroke(
585 tile_rect,
586 0.0,
587 egui::Stroke::new(1.0, Color32::GRAY),
588 egui::StrokeKind::Inside,
589 );
590
591 painter.text(
593 tile_rect.center(),
594 egui::Align2::CENTER_CENTER,
595 "❓",
596 egui::FontId::proportional(40.0),
597 Color32::RED,
598 );
599
600 error!("Tile state not found for {tile_id:?}");
601 }
602 }
603}
604
605impl Widget for &mut Map {
606 fn ui(self, ui: &mut Ui) -> Response {
607 let desired_size = if ui.layout().main_dir().is_horizontal() {
610 let side = TILE_SIZE as f32;
612 Vec2::splat(side)
613 } else {
614 let mut available_size = ui
616 .available_size()
617 .at_least(Vec2::new(TILE_SIZE as f32, TILE_SIZE as f32));
618 available_size.y = TILE_SIZE as f32;
619 available_size
620 };
621
622 let response = ui.allocate_response(desired_size, Sense::drag().union(Sense::click()));
623 let rect = response.rect;
624
625 let input_projection = MapProjection::new(self.zoom, self.center, rect);
627
628 let mut input_handled_by_layer = false;
629 for layer in self.layers.values_mut() {
630 if layer.handle_input(&response, &input_projection) {
631 input_handled_by_layer = true;
632 break; }
634 }
635
636 if !input_handled_by_layer {
637 self.handle_input(ui, &rect, &response);
638
639 if response.dragged() {
641 ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
642 } else if response.hovered() {
643 ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
644 }
645 }
646
647 self.mouse_pos = response
649 .hover_pos()
650 .map(|pos| input_projection.unproject(pos));
651
652 let draw_projection = MapProjection::new(self.zoom, self.center, rect);
654
655 let painter = ui.painter_at(rect);
656 painter.rect_filled(rect, 0.0, Color32::from_rgb(220, 220, 220)); draw_map(
659 &mut self.tiles,
660 self.config.as_ref(),
661 &painter,
662 &draw_projection,
663 );
664
665 for layer in self.layers.values() {
666 layer.draw(&painter, &draw_projection);
667 }
668
669 self.draw_attribution(ui, &rect);
670
671 response
672 }
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678 use crate::config::OpenStreetMapConfig;
679
680 const EPSILON: f64 = 1e-9;
681
682 #[test]
683 fn test_coord_conversion_roundtrip() {
684 let original_lon = 24.93545;
685 let original_lat = 60.16952;
686 let zoom: u8 = 10;
687
688 let x = lon_to_x(original_lon, zoom);
689 let y = lat_to_y(original_lat, zoom);
690
691 let final_lon = x_to_lon(x, zoom);
692 let final_lat = y_to_lat(y, zoom);
693
694 assert!((original_lon - final_lon).abs() < EPSILON);
695 assert!((original_lat - final_lat).abs() < EPSILON);
696
697 let original_lon = -122.4194;
698 let original_lat = 37.7749;
699
700 let x = lon_to_x(original_lon, zoom);
701 let y = lat_to_y(original_lat, zoom);
702
703 let final_lon = x_to_lon(x, zoom);
704 let final_lat = y_to_lat(y, zoom);
705
706 assert!((original_lon - final_lon).abs() < EPSILON);
707 assert!((original_lat - final_lat).abs() < EPSILON);
708 }
709
710 #[test]
711 fn test_y_to_lat_conversion() {
712 let test_cases = vec![
714 (0.5, 0, 0.0),
716 (128.0, 8, 0.0),
717 (0.0, 0, 85.0511287798),
719 (1.0, 0, -85.0511287798),
720 (0.0, 8, 85.0511287798),
721 (256.0, 8, -85.0511287798),
722 (9.262574089998255, 5, 60.16952),
724 (85.12653378959828, 8, 51.5074),
726 ];
727
728 for (y, zoom, expected_lat) in test_cases {
729 assert!((y_to_lat(y, zoom) - expected_lat).abs() < EPSILON);
730 }
731 }
732
733 #[test]
734 fn test_lat_to_y_conversion() {
735 let test_cases = vec![
737 (0.0, 0, 0.5),
739 (0.0, 8, 128.0),
740 (85.0511287798, 0, 0.0),
742 (-85.0511287798, 0, 1.0),
743 (85.0511287798, 8, 0.0),
744 (-85.0511287798, 8, 256.0),
745 (60.16952, 5, 9.262574089998255),
747 (51.5074, 8, 85.12653378959828),
749 ];
750
751 for (lat, zoom, expected_y) in test_cases {
752 assert!((lat_to_y(lat, zoom) - expected_y).abs() < EPSILON);
753 }
754 }
755
756 #[test]
757 fn test_x_to_lon_conversion() {
758 let test_cases = vec![
760 (0.5, 0, 0.0),
762 (128.0, 8, 0.0),
763 (0.0, 0, -180.0),
765 (1.0, 0, 180.0),
766 (0.0, 8, -180.0),
767 (256.0, 8, 180.0),
768 (18.216484444444444, 5, 24.93545),
770 ];
771
772 for (x, zoom, expected_lon) in test_cases {
773 assert!((x_to_lon(x, zoom) - expected_lon).abs() < EPSILON);
774 }
775 }
776
777 #[test]
778 fn test_lon_to_x_conversion() {
779 let test_cases = vec![
781 (0.0, 0, 0.5),
783 (0.0, 8, 128.0),
784 (-180.0, 0, 0.0),
786 (180.0, 0, 1.0), (-180.0, 8, 0.0),
788 (180.0, 8, 256.0),
789 (24.93545, 5, 18.216484444444444),
791 (-0.1275, 8, 127.90933333333333),
793 ];
794
795 for (lon, zoom, expected_x) in test_cases {
796 assert!((lon_to_x(lon, zoom) - expected_x).abs() < EPSILON);
797 }
798 }
799
800 #[test]
801 fn test_tile_id_to_url() {
802 let config = OpenStreetMapConfig::default();
803 let tile_id = TileId {
804 z: 10,
805 x: 559,
806 y: 330,
807 };
808 let url = tile_id.to_url(&config);
809 assert_eq!(url, "https://tile.openstreetmap.org/10/559/330.png");
810 }
811
812 #[test]
813 fn test_map_new() {
814 let config = OpenStreetMapConfig::default();
815 let default_center = config.default_center();
816 let default_zoom = config.default_zoom();
817
818 let map = Map::new(config);
819
820 assert_eq!(map.center, default_center.into());
821 assert_eq!(map.zoom, default_zoom);
822 assert!(map.mouse_pos.is_none());
823 assert!(map.tiles.is_empty());
824 }
825
826 #[test]
827 #[cfg(feature = "openstreetmap")]
828 fn test_map_invalid_zoom_limits() {
829 let config = OpenStreetMapConfig::default().min_zoom(15).max_zoom(5);
830 let map = Map::new(config);
831 assert_eq!(map.zoom, 15);
832 }
833}