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 once_cell::sync::Lazy;
56use poll_promise::Promise;
57use std::collections::{BTreeMap, HashMap};
58use std::sync::Arc;
59use thiserror::Error;
60
61use crate::config::MapConfig;
62use crate::layers::Layer;
63use crate::projection::{GeoPos, MapProjection};
64
65const TILE_SIZE: u32 = 256;
67pub const MIN_ZOOM: u8 = 0;
69pub const MAX_ZOOM: u8 = 19;
71
72static CLIENT: Lazy<reqwest::blocking::Client> = Lazy::new(|| {
74 reqwest::blocking::Client::builder()
75 .user_agent(format!(
76 "{}/{}",
77 env!("CARGO_PKG_NAME"),
78 env!("CARGO_PKG_VERSION")
79 ))
80 .build()
81 .expect("Failed to build reqwest client")
82});
83
84#[derive(Error, Debug)]
86pub enum MapError {
87 #[error("Connection error")]
89 ConnectionError(#[from] reqwest::Error),
90
91 #[error("A map tile failed to download. HTTP Status: `{0}`")]
93 TileDownloadError(String),
94
95 #[error("Unable to convert downloaded map tile bytes as image")]
97 TileBytesConversionError(#[from] image::ImageError),
98}
99
100#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq)]
102pub struct TileId {
103 pub z: u8,
105
106 pub x: u32,
108
109 pub y: u32,
111}
112
113impl TileId {
114 fn to_url(&self, config: &dyn MapConfig) -> String {
115 config.tile_url(self)
116 }
117}
118
119enum Tile {
121 Loading(Promise<Result<egui::ColorImage, Arc<eyre::Report>>>),
123
124 Loaded(egui::TextureHandle),
126
127 Failed(Arc<eyre::Report>),
129
130 Unknown,
132}
133
134pub struct Map {
136 pub center: GeoPos,
138
139 pub zoom: u8,
141
142 tiles: HashMap<TileId, Tile>,
143
144 pub mouse_pos: Option<GeoPos>,
146
147 config: Box<dyn MapConfig>,
149
150 layers: BTreeMap<String, Box<dyn Layer>>,
152}
153
154impl Map {
155 pub fn new<C: MapConfig + 'static>(config: C) -> Self {
161 let center = GeoPos::from(config.default_center());
162 let zoom = config.default_zoom();
163 Self {
164 tiles: HashMap::new(),
165 mouse_pos: None,
166 config: Box::new(config),
167 center,
168 zoom,
169 layers: BTreeMap::new(),
170 }
171 }
172
173 pub fn add_layer(&mut self, key: impl Into<String>, layer: impl Layer + 'static) {
175 self.layers.insert(key.into(), Box::new(layer));
176 }
177
178 pub fn remove_layer(&mut self, key: &str) -> bool {
180 if self.layers.remove(key).is_some() {
181 true
182 } else {
183 false
184 }
185 }
186
187 pub fn layers(&self) -> &BTreeMap<String, Box<dyn Layer>> {
189 &self.layers
190 }
191
192 pub fn layers_mut(&mut self) -> &mut BTreeMap<String, Box<dyn Layer>> {
194 &mut self.layers
195 }
196
197 pub fn layer<T: Layer>(&self, key: &str) -> Option<&T> {
199 self.layers
200 .get(key)
201 .and_then(|layer| layer.as_any().downcast_ref::<T>())
202 }
203
204 pub fn layer_mut<T: Layer>(&mut self, key: &str) -> Option<&mut T> {
206 self.layers
207 .get_mut(key)
208 .and_then(|layer| layer.as_any_mut().downcast_mut::<T>())
209 }
210
211 fn handle_input(&mut self, ui: &Ui, rect: &Rect, response: &Response) {
213 if response.dragged() {
215 let delta = response.drag_delta();
216 let center_in_tiles_x = lon_to_x(self.center.lon, self.zoom);
217 let center_in_tiles_y = lat_to_y(self.center.lat, self.zoom);
218
219 let mut new_center_x = center_in_tiles_x - (delta.x as f64 / TILE_SIZE as f64);
220 let mut new_center_y = center_in_tiles_y - (delta.y as f64 / TILE_SIZE as f64);
221
222 let world_size_in_tiles = 2.0_f64.powi(self.zoom as i32);
224 let view_size_in_tiles_x = rect.width() as f64 / TILE_SIZE as f64;
225 let view_size_in_tiles_y = rect.height() as f64 / TILE_SIZE as f64;
226
227 let min_center_x = view_size_in_tiles_x / 2.0;
228 let max_center_x = world_size_in_tiles - view_size_in_tiles_x / 2.0;
229 let min_center_y = view_size_in_tiles_y / 2.0;
230 let max_center_y = world_size_in_tiles - view_size_in_tiles_y / 2.0;
231
232 new_center_x = if min_center_x > max_center_x {
234 world_size_in_tiles / 2.0
235 } else {
236 new_center_x.clamp(min_center_x, max_center_x)
237 };
238 new_center_y = if min_center_y > max_center_y {
239 world_size_in_tiles / 2.0
240 } else {
241 new_center_y.clamp(min_center_y, max_center_y)
242 };
243
244 self.center = (
245 x_to_lon(new_center_x, self.zoom),
246 y_to_lat(new_center_y, self.zoom),
247 )
248 .into();
249 }
250
251 if response.double_clicked() {
253 if let Some(pointer_pos) = response.interact_pointer_pos() {
254 let new_zoom = (self.zoom + 1).clamp(MIN_ZOOM, MAX_ZOOM);
255
256 if new_zoom != self.zoom {
257 let mouse_rel = pointer_pos - rect.min;
259 let center_x = lon_to_x(self.center.lon, self.zoom);
260 let center_y = lat_to_y(self.center.lat, self.zoom);
261 let widget_center_x = rect.width() as f64 / 2.0;
262 let widget_center_y = rect.height() as f64 / 2.0;
263
264 let target_x =
265 center_x + (mouse_rel.x as f64 - widget_center_x) / TILE_SIZE as f64;
266 let target_y =
267 center_y + (mouse_rel.y as f64 - widget_center_y) / TILE_SIZE as f64;
268
269 let new_center_lon = x_to_lon(target_x, self.zoom);
270 let new_center_lat = y_to_lat(target_y, self.zoom);
271
272 self.zoom = new_zoom;
274 self.center = (new_center_lon, new_center_lat).into();
275 }
276 }
277 }
278
279 if response.hovered() {
281 if let Some(mouse_pos) = response.hover_pos() {
282 let mouse_rel = mouse_pos - rect.min;
283
284 let center_x = lon_to_x(self.center.lon, self.zoom);
286 let center_y = lat_to_y(self.center.lat, self.zoom);
287 let widget_center_x = rect.width() as f64 / 2.0;
288 let widget_center_y = rect.height() as f64 / 2.0;
289
290 let target_x = center_x + (mouse_rel.x as f64 - widget_center_x) / TILE_SIZE as f64;
291 let target_y = center_y + (mouse_rel.y as f64 - widget_center_y) / TILE_SIZE as f64;
292
293 let scroll = ui.input(|i| i.raw_scroll_delta.y);
294 if scroll != 0.0 {
295 let old_zoom = self.zoom;
296 let mut new_zoom = (self.zoom as i32 + scroll.signum() as i32)
297 .clamp(MIN_ZOOM as i32, MAX_ZOOM as i32)
298 as u8;
299
300 if scroll < 0.0 {
302 let world_pixel_size = 2.0_f64.powi(new_zoom as i32) * TILE_SIZE as f64;
303 if world_pixel_size < rect.width() as f64
305 || world_pixel_size < rect.height() as f64
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 - (mouse_rel.x as f64 - widget_center_x) / TILE_SIZE as f64;
325 let new_center_y = new_target_y
326 - (mouse_rel.y as f64 - widget_center_y) / TILE_SIZE as f64;
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
339 fn draw_attribution(&self, ui: &mut Ui, rect: &Rect) {
341 if !ui.is_rect_visible(*rect) {
343 return;
344 }
345
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); egui::Area::new(ui.id().with("attribution"))
359 .pivot(egui::Align2::LEFT_BOTTOM)
362 .fixed_pos(rect.left_bottom() + egui::vec2(5.0, -5.0))
366 .show(ui.ctx(), |ui| {
367 frame.show(ui, |ui| {
368 ui.style_mut().override_text_style = Some(egui::TextStyle::Small);
369 ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); if let Some(url) = self.config.attribution_url() {
372 ui.hyperlink_to(attribution, url);
373 } else {
374 ui.label(attribution);
375 }
376 });
377 });
378 }
379 }
380}
381
382fn lon_to_x(lon: f64, zoom: u8) -> f64 {
384 (lon + 180.0) / 360.0 * (2.0_f64.powi(zoom as i32))
385}
386
387fn lat_to_y(lat: f64, zoom: u8) -> f64 {
389 (1.0 - lat.to_radians().tan().asinh() / std::f64::consts::PI) / 2.0
390 * (2.0_f64.powi(zoom as i32))
391}
392
393fn x_to_lon(x: f64, zoom: u8) -> f64 {
395 x / (2.0_f64.powi(zoom as i32)) * 360.0 - 180.0
396}
397
398fn y_to_lat(y: f64, zoom: u8) -> f64 {
400 let n = std::f64::consts::PI - 2.0 * std::f64::consts::PI * y / (2.0_f64.powi(zoom as i32));
401 n.sinh().atan().to_degrees()
402}
403
404pub(crate) fn draw_map(
406 tiles: &mut HashMap<TileId, Tile>,
407 config: &dyn MapConfig,
408 painter: &egui::Painter,
409 projection: &MapProjection,
410) {
411 let visible_tiles: Vec<_> = visible_tiles(projection).collect();
412 for (tile_id, tile_pos) in visible_tiles {
413 load_tile(tiles, config, &painter.ctx(), tile_id);
414 draw_tile(tiles, painter, &tile_id, tile_pos, Color32::WHITE);
415 }
416}
417
418pub(crate) fn visible_tiles(
420 projection: &MapProjection,
421) -> impl Iterator<Item = (TileId, egui::Pos2)> {
422 let center_x = lon_to_x(projection.center_lon, projection.zoom);
423 let center_y = lat_to_y(projection.center_lat, projection.zoom);
424
425 let widget_center_x = projection.widget_rect.width() / 2.0;
426 let widget_center_y = projection.widget_rect.height() / 2.0;
427
428 let x_min = (center_x - widget_center_x as f64 / TILE_SIZE as f64).floor() as i32;
429 let y_min = (center_y - widget_center_y as f64 / TILE_SIZE as f64).floor() as i32;
430 let x_max = (center_x + widget_center_x as f64 / TILE_SIZE as f64).ceil() as i32;
431 let y_max = (center_y + widget_center_y as f64 / TILE_SIZE as f64).ceil() as i32;
432
433 let zoom = projection.zoom;
434 let rect_min = projection.widget_rect.min;
435 (x_min..=x_max).flat_map(move |x| {
436 (y_min..=y_max).map(move |y| {
437 let tile_id = TileId {
438 z: zoom,
439 x: x as u32,
440 y: y as u32,
441 };
442 let screen_x = widget_center_x + (x as f64 - center_x) as f32 * TILE_SIZE as f32;
443 let screen_y = widget_center_y + (y as f64 - center_y) as f32 * TILE_SIZE as f32;
444 let tile_pos = rect_min + Vec2::new(screen_x, screen_y);
445 (tile_id, tile_pos)
446 })
447 })
448}
449
450pub(crate) fn load_tile(
452 tiles: &mut HashMap<TileId, Tile>,
453 config: &dyn MapConfig,
454 ctx: &egui::Context,
455 tile_id: TileId,
456) {
457 let tile_state = tiles.entry(tile_id).or_insert_with(|| {
458 let url = tile_id.to_url(config);
459 let promise =
460 Promise::spawn_thread("download_tile", move || -> Result<_, Arc<eyre::Report>> {
461 let result: Result<_, eyre::Report> = (|| {
462 debug!("Downloading tile from {}", &url);
463 let response = CLIENT.get(&url).send().map_err(MapError::from)?;
464
465 if !response.status().is_success() {
466 return Err(MapError::TileDownloadError(response.status().to_string()));
467 }
468
469 let bytes = response.bytes().map_err(MapError::from)?.to_vec();
470 let image = image::load_from_memory(&bytes)
471 .map_err(MapError::from)?
472 .to_rgba8();
473
474 let size = [image.width() as _, image.height() as _];
475 let pixels = image.into_raw();
476 Ok(egui::ColorImage::from_rgba_unmultiplied(size, &pixels))
477 })()
478 .with_context(|| format!("Failed to download tile from {}", &url));
479
480 result.map_err(Arc::new)
481 });
482 Tile::Loading(promise)
483 });
484
485 if let Tile::Loading(promise) = tile_state {
489 if let Some(result) = promise.ready() {
490 match result {
491 Ok(color_image) => {
492 let texture = ctx.load_texture(
493 format!("tile_{}_{}_{}", tile_id.z, tile_id.x, tile_id.y),
494 color_image.clone(),
495 Default::default(),
496 );
497 *tile_state = Tile::Loaded(texture);
498 }
499 Err(e) => {
500 error!("{:?}", e);
501 *tile_state = Tile::Failed(e.clone());
502 }
503 }
504 }
505 }
506}
507
508pub(crate) fn draw_tile(
510 tiles: &HashMap<TileId, Tile>,
511 painter: &egui::Painter,
512 tile_id: &TileId,
513 tile_pos: egui::Pos2,
514 tint: Color32,
515) {
516 let tile_rect = Rect::from_min_size(tile_pos, Vec2::new(TILE_SIZE as f32, TILE_SIZE as f32));
517 let default_state = Tile::Unknown;
518 let tile_state = tiles.get(tile_id).unwrap_or(&default_state);
519 match tile_state {
520 Tile::Loading(_) => {
521 painter.rect_filled(tile_rect, 0.0, Color32::from_gray(220));
523 painter.rect_stroke(
524 tile_rect,
525 0.0,
526 egui::Stroke::new(1.0, Color32::GRAY),
527 egui::StrokeKind::Inside,
528 );
529
530 painter.text(
532 tile_rect.center(),
533 egui::Align2::CENTER_CENTER,
534 "⌛",
535 egui::FontId::proportional(40.0),
536 Color32::ORANGE,
537 );
538
539 painter.ctx().request_repaint();
541 }
542 Tile::Loaded(texture) => {
543 painter.image(
544 texture.id(),
545 tile_rect,
546 Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
547 tint,
548 );
549 }
550 Tile::Failed(e) => {
551 painter.rect_filled(tile_rect, 0.0, Color32::from_gray(220));
553 painter.rect_stroke(
554 tile_rect,
555 0.0,
556 egui::Stroke::new(1.0, Color32::GRAY),
557 egui::StrokeKind::Inside,
558 );
559
560 painter.text(
562 tile_rect.center(),
563 egui::Align2::CENTER_CENTER,
564 "❌",
565 egui::FontId::proportional(40.0),
566 Color32::RED,
567 );
568
569 error!("Failed to load tile: {:?}", e);
571 }
572 Tile::Unknown => {
573 painter.rect_filled(tile_rect, 0.0, Color32::from_gray(220));
575 painter.rect_stroke(
576 tile_rect,
577 0.0,
578 egui::Stroke::new(1.0, Color32::GRAY),
579 egui::StrokeKind::Inside,
580 );
581
582 painter.text(
584 tile_rect.center(),
585 egui::Align2::CENTER_CENTER,
586 "❓",
587 egui::FontId::proportional(40.0),
588 Color32::RED,
589 );
590
591 error!("Tile state not found for {:?}", tile_id);
592 }
593 }
594}
595
596impl Widget for &mut Map {
597 fn ui(self, ui: &mut Ui) -> Response {
598 let desired_size = if ui.layout().main_dir().is_horizontal() {
601 let side = TILE_SIZE as f32;
603 Vec2::splat(side)
604 } else {
605 let mut available_size = ui
607 .available_size()
608 .at_least(Vec2::new(TILE_SIZE as f32, TILE_SIZE as f32));
609 available_size.y = TILE_SIZE as f32;
610 available_size
611 };
612
613 let response = ui.allocate_response(desired_size, Sense::drag().union(Sense::click()));
614 let rect = response.rect;
615
616 let input_projection = MapProjection::new(self.zoom, self.center, rect);
618
619 let mut input_handled_by_layer = false;
620 for layer in self.layers.values_mut() {
621 if layer.handle_input(&response, &input_projection) {
622 input_handled_by_layer = true;
623 break; }
625 }
626
627 if !input_handled_by_layer {
628 self.handle_input(ui, &rect, &response);
629
630 if response.dragged() {
632 ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
633 } else if response.hovered() {
634 ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
635 }
636 }
637
638 self.mouse_pos = response
640 .hover_pos()
641 .map(|pos| input_projection.unproject(pos));
642
643 let draw_projection = MapProjection::new(self.zoom, self.center, rect);
645
646 let painter = ui.painter_at(rect);
647 painter.rect_filled(rect, 0.0, Color32::from_rgb(220, 220, 220)); draw_map(
650 &mut self.tiles,
651 self.config.as_ref(),
652 &painter,
653 &draw_projection,
654 );
655
656 for layer in self.layers.values() {
657 layer.draw(&painter, &draw_projection);
658 }
659
660 self.draw_attribution(ui, &rect);
661
662 response
663 }
664}
665
666#[cfg(test)]
667mod tests {
668 use super::*;
669 use crate::config::OpenStreetMapConfig;
670
671 const EPSILON: f64 = 1e-9;
672
673 #[test]
674 fn test_coord_conversion_roundtrip() {
675 let original_lon = 24.93545;
676 let original_lat = 60.16952;
677 let zoom: u8 = 10;
678
679 let x = lon_to_x(original_lon, zoom);
680 let y = lat_to_y(original_lat, zoom);
681
682 let final_lon = x_to_lon(x, zoom);
683 let final_lat = y_to_lat(y, zoom);
684
685 assert!((original_lon - final_lon).abs() < EPSILON);
686 assert!((original_lat - final_lat).abs() < EPSILON);
687
688 let original_lon = -122.4194;
689 let original_lat = 37.7749;
690
691 let x = lon_to_x(original_lon, zoom);
692 let y = lat_to_y(original_lat, zoom);
693
694 let final_lon = x_to_lon(x, zoom);
695 let final_lat = y_to_lat(y, zoom);
696
697 assert!((original_lon - final_lon).abs() < EPSILON);
698 assert!((original_lat - final_lat).abs() < EPSILON);
699 }
700
701 #[test]
702 fn test_y_to_lat_conversion() {
703 let test_cases = vec![
705 (0.5, 0, 0.0),
707 (128.0, 8, 0.0),
708 (0.0, 0, 85.0511287798),
710 (1.0, 0, -85.0511287798),
711 (0.0, 8, 85.0511287798),
712 (256.0, 8, -85.0511287798),
713 (9.262574089998255, 5, 60.16952),
715 (85.12653378959828, 8, 51.5074),
717 ];
718
719 for (y, zoom, expected_lat) in test_cases {
720 assert!((y_to_lat(y, zoom) - expected_lat).abs() < EPSILON);
721 }
722 }
723
724 #[test]
725 fn test_lat_to_y_conversion() {
726 let test_cases = vec![
728 (0.0, 0, 0.5),
730 (0.0, 8, 128.0),
731 (85.0511287798, 0, 0.0),
733 (-85.0511287798, 0, 1.0),
734 (85.0511287798, 8, 0.0),
735 (-85.0511287798, 8, 256.0),
736 (60.16952, 5, 9.262574089998255),
738 (51.5074, 8, 85.12653378959828),
740 ];
741
742 for (lat, zoom, expected_y) in test_cases {
743 assert!((lat_to_y(lat, zoom) - expected_y).abs() < EPSILON);
744 }
745 }
746
747 #[test]
748 fn test_x_to_lon_conversion() {
749 let test_cases = vec![
751 (0.5, 0, 0.0),
753 (128.0, 8, 0.0),
754 (0.0, 0, -180.0),
756 (1.0, 0, 180.0),
757 (0.0, 8, -180.0),
758 (256.0, 8, 180.0),
759 (18.216484444444444, 5, 24.93545),
761 ];
762
763 for (x, zoom, expected_lon) in test_cases {
764 assert!((x_to_lon(x, zoom) - expected_lon).abs() < EPSILON);
765 }
766 }
767
768 #[test]
769 fn test_lon_to_x_conversion() {
770 let test_cases = vec![
772 (0.0, 0, 0.5),
774 (0.0, 8, 128.0),
775 (-180.0, 0, 0.0),
777 (180.0, 0, 1.0), (-180.0, 8, 0.0),
779 (180.0, 8, 256.0),
780 (24.93545, 5, 18.216484444444444),
782 (-0.1275, 8, 127.90933333333333),
784 ];
785
786 for (lon, zoom, expected_x) in test_cases {
787 assert!((lon_to_x(lon, zoom) - expected_x).abs() < EPSILON);
788 }
789 }
790
791 #[test]
792 fn test_tile_id_to_url() {
793 let config = OpenStreetMapConfig::default();
794 let tile_id = TileId {
795 z: 10,
796 x: 559,
797 y: 330,
798 };
799 let url = tile_id.to_url(&config);
800 assert_eq!(url, "https://tile.openstreetmap.org/10/559/330.png");
801 }
802
803 #[test]
804 fn test_map_new() {
805 let config = OpenStreetMapConfig::default();
806 let default_center = config.default_center();
807 let default_zoom = config.default_zoom();
808
809 let map = Map::new(config);
810
811 assert_eq!(map.center, default_center.into());
812 assert_eq!(map.zoom, default_zoom);
813 assert!(map.mouse_pos.is_none());
814 assert!(map.tiles.is_empty());
815 }
816}