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
131pub struct Map {
133 pub center: GeoPos,
135
136 pub zoom: u8,
138
139 tiles: HashMap<TileId, Tile>,
140
141 pub mouse_pos: Option<GeoPos>,
143
144 config: Box<dyn MapConfig>,
146
147 layers: BTreeMap<String, Box<dyn Layer>>,
149}
150
151impl Map {
152 pub fn new<C: MapConfig + 'static>(config: C) -> Self {
158 let center = GeoPos::from(config.default_center());
159 let zoom = config.default_zoom();
160 Self {
161 tiles: HashMap::new(),
162 mouse_pos: None,
163 config: Box::new(config),
164 center,
165 zoom,
166 layers: BTreeMap::new(),
167 }
168 }
169
170 pub fn add_layer(&mut self, key: impl Into<String>, layer: impl Layer + 'static) {
172 self.layers.insert(key.into(), Box::new(layer));
173 }
174
175 pub fn remove_layer(&mut self, key: &str) -> bool {
177 if self.layers.remove(key).is_some() {
178 true
179 } else {
180 false
181 }
182 }
183
184 pub fn layers(&self) -> &BTreeMap<String, Box<dyn Layer>> {
186 &self.layers
187 }
188
189 pub fn layers_mut(&mut self) -> &mut BTreeMap<String, Box<dyn Layer>> {
191 &mut self.layers
192 }
193
194 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 - (delta.x as f64 / TILE_SIZE as f64);
217 let mut new_center_y = center_in_tiles_y - (delta.y as f64 / TILE_SIZE as f64);
218
219 let world_size_in_tiles = 2.0_f64.powi(self.zoom as i32);
221 let view_size_in_tiles_x = rect.width() as f64 / TILE_SIZE as f64;
222 let view_size_in_tiles_y = rect.height() as f64 / TILE_SIZE as f64;
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 if let Some(pointer_pos) = response.interact_pointer_pos() {
251 let new_zoom = (self.zoom + 1).clamp(MIN_ZOOM, MAX_ZOOM);
252
253 if new_zoom != self.zoom {
254 let mouse_rel = pointer_pos - rect.min;
256 let center_x = lon_to_x(self.center.lon, self.zoom);
257 let center_y = lat_to_y(self.center.lat, self.zoom);
258 let widget_center_x = rect.width() as f64 / 2.0;
259 let widget_center_y = rect.height() as f64 / 2.0;
260
261 let target_x =
262 center_x + (mouse_rel.x as f64 - widget_center_x) / TILE_SIZE as f64;
263 let target_y =
264 center_y + (mouse_rel.y as f64 - widget_center_y) / TILE_SIZE as f64;
265
266 let new_center_lon = x_to_lon(target_x, self.zoom);
267 let new_center_lat = y_to_lat(target_y, self.zoom);
268
269 self.zoom = new_zoom;
271 self.center = (new_center_lon, new_center_lat).into();
272 }
273 }
274 }
275
276 if response.hovered() {
278 if let Some(mouse_pos) = response.hover_pos() {
279 let mouse_rel = mouse_pos - rect.min;
280
281 let center_x = lon_to_x(self.center.lon, self.zoom);
283 let center_y = lat_to_y(self.center.lat, self.zoom);
284 let widget_center_x = rect.width() as f64 / 2.0;
285 let widget_center_y = rect.height() as f64 / 2.0;
286
287 let target_x = center_x + (mouse_rel.x as f64 - widget_center_x) / TILE_SIZE as f64;
288 let target_y = center_y + (mouse_rel.y as f64 - widget_center_y) / TILE_SIZE as f64;
289
290 let scroll = ui.input(|i| i.raw_scroll_delta.y);
291 if scroll != 0.0 {
292 let old_zoom = self.zoom;
293 let mut new_zoom = (self.zoom as i32 + scroll.signum() as i32)
294 .clamp(MIN_ZOOM as i32, MAX_ZOOM as i32)
295 as u8;
296
297 if scroll < 0.0 {
299 let world_pixel_size = 2.0_f64.powi(new_zoom as i32) * TILE_SIZE as f64;
300 if world_pixel_size < rect.width() as f64
302 || world_pixel_size < rect.height() as f64
303 {
304 new_zoom = old_zoom; }
306 }
307
308 if new_zoom != old_zoom {
309 let target_lon = x_to_lon(target_x, old_zoom);
310 let target_lat = y_to_lat(target_y, old_zoom);
311
312 self.zoom = new_zoom;
314
315 let new_target_x = lon_to_x(target_lon, new_zoom);
318 let new_target_y = lat_to_y(target_lat, new_zoom);
319
320 let new_center_x = new_target_x
321 - (mouse_rel.x as f64 - widget_center_x) / TILE_SIZE as f64;
322 let new_center_y = new_target_y
323 - (mouse_rel.y as f64 - widget_center_y) / TILE_SIZE as f64;
324
325 self.center = (
326 x_to_lon(new_center_x, new_zoom),
327 y_to_lat(new_center_y, new_zoom),
328 )
329 .into();
330 }
331 }
332 }
333 }
334 }
335
336 fn draw_map(&mut self, ui: &mut Ui, rect: &Rect) {
338 let painter = ui.painter_at(*rect);
339 painter.rect_filled(*rect, 0.0, Color32::from_rgb(220, 220, 220)); let visible_tiles: Vec<_> = self.visible_tiles(rect).collect();
342 for (tile_id, tile_pos) in visible_tiles {
343 self.draw_tile(ui, &painter, tile_id, tile_pos);
344 }
345 }
346
347 fn draw_attribution(&self, ui: &mut Ui, rect: &Rect) {
349 if !ui.is_rect_visible(*rect) {
351 return;
352 }
353
354 if let Some(attribution) = self.config.attribution() {
355 let (_text_color, bg_color) = if ui.visuals().dark_mode {
356 (Color32::from_gray(230), Color32::from_black_alpha(150))
357 } else {
358 (Color32::from_gray(80), Color32::from_white_alpha(150))
359 };
360
361 let frame = egui::Frame::NONE
362 .inner_margin(egui::Margin::same(5)) .fill(bg_color)
364 .corner_radius(3.0); egui::Area::new(ui.id().with("attribution"))
367 .pivot(egui::Align2::LEFT_BOTTOM)
370 .fixed_pos(rect.left_bottom() + egui::vec2(5.0, -5.0))
374 .show(ui.ctx(), |ui| {
375 frame.show(ui, |ui| {
376 ui.style_mut().override_text_style = Some(egui::TextStyle::Small);
377 ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); if let Some(url) = self.config.attribution_url() {
380 ui.hyperlink_to(attribution, url);
381 } else {
382 ui.label(attribution);
383 }
384 });
385 });
386 }
387 }
388
389 fn visible_tiles(&self, rect: &Rect) -> impl Iterator<Item = (TileId, egui::Pos2)> {
391 let center_x = lon_to_x(self.center.lon, self.zoom);
392 let center_y = lat_to_y(self.center.lat, self.zoom);
393
394 let widget_center_x = rect.width() / 2.0;
395 let widget_center_y = rect.height() / 2.0;
396
397 let x_min = (center_x - widget_center_x as f64 / TILE_SIZE as f64).floor() as i32;
398 let y_min = (center_y - widget_center_y as f64 / TILE_SIZE as f64).floor() as i32;
399 let x_max = (center_x + widget_center_x as f64 / TILE_SIZE as f64).ceil() as i32;
400 let y_max = (center_y + widget_center_y as f64 / TILE_SIZE as f64).ceil() as i32;
401
402 let zoom = self.zoom;
403 let rect_min = rect.min;
404 (x_min..=x_max).flat_map(move |x| {
405 (y_min..=y_max).map(move |y| {
406 let tile_id = TileId {
407 z: zoom,
408 x: x as u32,
409 y: y as u32,
410 };
411 let screen_x = widget_center_x + (x as f64 - center_x) as f32 * TILE_SIZE as f32;
412 let screen_y = widget_center_y + (y as f64 - center_y) as f32 * TILE_SIZE as f32;
413 let tile_pos = rect_min + Vec2::new(screen_x, screen_y);
414 (tile_id, tile_pos)
415 })
416 })
417 }
418
419 fn draw_tile(
421 &mut self,
422 ui: &mut Ui,
423 painter: &egui::Painter,
424 tile_id: TileId,
425 tile_pos: egui::Pos2,
426 ) {
427 let tile_state = self.tiles.entry(tile_id).or_insert_with(|| {
428 let url = tile_id.to_url(self.config.as_ref());
429 let promise =
430 Promise::spawn_thread("download_tile", move || -> Result<_, Arc<eyre::Report>> {
431 let result: Result<_, eyre::Report> = (|| {
432 debug!("Downloading tile from {}", &url);
433 let response = CLIENT.get(&url).send().map_err(MapError::from)?;
434
435 if !response.status().is_success() {
436 return Err(MapError::TileDownloadError(response.status().to_string()));
437 }
438
439 let bytes = response.bytes().map_err(MapError::from)?.to_vec();
440 let image = image::load_from_memory(&bytes)
441 .map_err(MapError::from)?
442 .to_rgba8();
443
444 let size = [image.width() as _, image.height() as _];
445 let pixels = image.into_raw();
446 Ok(egui::ColorImage::from_rgba_unmultiplied(size, &pixels))
447 })()
448 .with_context(|| format!("Failed to download tile from {}", &url));
449
450 result.map_err(Arc::new)
451 });
452 Tile::Loading(promise)
453 });
454
455 if let Tile::Loading(promise) = tile_state {
459 if let Some(result) = promise.ready() {
460 match result {
461 Ok(color_image) => {
462 let texture = ui.ctx().load_texture(
463 format!("tile_{}_{}_{}", tile_id.z, tile_id.x, tile_id.y),
464 color_image.clone(),
465 Default::default(),
466 );
467 *tile_state = Tile::Loaded(texture);
468 }
469 Err(e) => {
470 error!("{:?}", e);
471 *tile_state = Tile::Failed(e.clone());
472 }
473 }
474 }
475 }
476
477 let tile_rect =
478 Rect::from_min_size(tile_pos, Vec2::new(TILE_SIZE as f32, TILE_SIZE as f32));
479
480 match tile_state {
481 Tile::Loading(_) => {
482 painter.rect_filled(tile_rect, 0.0, Color32::from_gray(220));
484 painter.rect_stroke(
485 tile_rect,
486 0.0,
487 egui::Stroke::new(1.0, Color32::GRAY),
488 egui::StrokeKind::Inside,
489 );
490
491 painter.text(
493 tile_rect.center(),
494 egui::Align2::CENTER_CENTER,
495 "?",
496 egui::FontId::proportional(40.0),
497 Color32::ORANGE,
498 );
499
500 ui.ctx().request_repaint();
502 }
503 Tile::Loaded(texture) => {
504 painter.image(
505 texture.id(),
506 tile_rect,
507 Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
508 Color32::WHITE,
509 );
510 }
511 Tile::Failed(e) => {
512 painter.rect_filled(tile_rect, 0.0, Color32::from_gray(220));
514 painter.rect_stroke(
515 tile_rect,
516 0.0,
517 egui::Stroke::new(1.0, Color32::GRAY),
518 egui::StrokeKind::Inside,
519 );
520
521 painter.text(
523 tile_rect.center(),
524 egui::Align2::CENTER_CENTER,
525 "!",
526 egui::FontId::proportional(40.0),
527 Color32::RED,
528 );
529
530 let response = ui.interact(tile_rect, ui.id().with(tile_id), Sense::hover());
531 response.on_hover_text(format!("{}", e));
532 }
533 }
534 }
535}
536
537fn lon_to_x(lon: f64, zoom: u8) -> f64 {
539 (lon + 180.0) / 360.0 * (2.0_f64.powi(zoom as i32))
540}
541
542fn lat_to_y(lat: f64, zoom: u8) -> f64 {
544 (1.0 - lat.to_radians().tan().asinh() / std::f64::consts::PI) / 2.0
545 * (2.0_f64.powi(zoom as i32))
546}
547
548fn x_to_lon(x: f64, zoom: u8) -> f64 {
550 x / (2.0_f64.powi(zoom as i32)) * 360.0 - 180.0
551}
552
553fn y_to_lat(y: f64, zoom: u8) -> f64 {
555 let n = std::f64::consts::PI - 2.0 * std::f64::consts::PI * y / (2.0_f64.powi(zoom as i32));
556 n.sinh().atan().to_degrees()
557}
558
559impl Widget for &mut Map {
560 fn ui(self, ui: &mut Ui) -> Response {
561 let desired_size = if ui.layout().main_dir().is_horizontal() {
564 let side = TILE_SIZE as f32;
566 Vec2::splat(side)
567 } else {
568 let mut available_size = ui
570 .available_size()
571 .at_least(Vec2::new(TILE_SIZE as f32, TILE_SIZE as f32));
572 available_size.y = TILE_SIZE as f32;
573 available_size
574 };
575
576 let response = ui.allocate_response(desired_size, Sense::drag().union(Sense::click()));
577 let rect = response.rect;
578
579 let input_projection = MapProjection::new(self.zoom, self.center, rect);
581
582 let mut input_handled_by_layer = false;
583 for layer in self.layers.values_mut() {
584 if layer.handle_input(&response, &input_projection) {
585 input_handled_by_layer = true;
586 break; }
588 }
589
590 if !input_handled_by_layer {
591 self.handle_input(ui, &rect, &response);
592
593 if response.dragged() {
595 ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
596 } else if response.hovered() {
597 ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
598 }
599 }
600
601 self.mouse_pos = response
603 .hover_pos()
604 .map(|pos| input_projection.unproject(pos));
605
606 self.draw_map(ui, &rect);
607
608 let draw_projection = MapProjection::new(self.zoom, self.center, rect);
610 let painter = ui.painter_at(rect);
611 for layer in self.layers.values() {
612 layer.draw(&painter, &draw_projection);
613 }
614
615 self.draw_attribution(ui, &rect);
616
617 response
618 }
619}
620
621#[cfg(test)]
622mod tests {
623 use super::*;
624 use crate::config::OpenStreetMapConfig;
625
626 const EPSILON: f64 = 1e-9;
627
628 #[test]
629 fn test_coord_conversion_roundtrip() {
630 let original_lon = 24.93545;
631 let original_lat = 60.16952;
632 let zoom: u8 = 10;
633
634 let x = lon_to_x(original_lon, zoom);
635 let y = lat_to_y(original_lat, zoom);
636
637 let final_lon = x_to_lon(x, zoom);
638 let final_lat = y_to_lat(y, zoom);
639
640 assert!((original_lon - final_lon).abs() < EPSILON);
641 assert!((original_lat - final_lat).abs() < EPSILON);
642
643 let original_lon = -122.4194;
644 let original_lat = 37.7749;
645
646 let x = lon_to_x(original_lon, zoom);
647 let y = lat_to_y(original_lat, zoom);
648
649 let final_lon = x_to_lon(x, zoom);
650 let final_lat = y_to_lat(y, zoom);
651
652 assert!((original_lon - final_lon).abs() < EPSILON);
653 assert!((original_lat - final_lat).abs() < EPSILON);
654 }
655
656 #[test]
657 fn test_y_to_lat_conversion() {
658 let test_cases = vec![
660 (0.5, 0, 0.0),
662 (128.0, 8, 0.0),
663 (0.0, 0, 85.0511287798),
665 (1.0, 0, -85.0511287798),
666 (0.0, 8, 85.0511287798),
667 (256.0, 8, -85.0511287798),
668 (9.262574089998255, 5, 60.16952),
670 (85.12653378959828, 8, 51.5074),
672 ];
673
674 for (y, zoom, expected_lat) in test_cases {
675 assert!((y_to_lat(y, zoom) - expected_lat).abs() < EPSILON);
676 }
677 }
678
679 #[test]
680 fn test_lat_to_y_conversion() {
681 let test_cases = vec![
683 (0.0, 0, 0.5),
685 (0.0, 8, 128.0),
686 (85.0511287798, 0, 0.0),
688 (-85.0511287798, 0, 1.0),
689 (85.0511287798, 8, 0.0),
690 (-85.0511287798, 8, 256.0),
691 (60.16952, 5, 9.262574089998255),
693 (51.5074, 8, 85.12653378959828),
695 ];
696
697 for (lat, zoom, expected_y) in test_cases {
698 assert!((lat_to_y(lat, zoom) - expected_y).abs() < EPSILON);
699 }
700 }
701
702 #[test]
703 fn test_x_to_lon_conversion() {
704 let test_cases = vec![
706 (0.5, 0, 0.0),
708 (128.0, 8, 0.0),
709 (0.0, 0, -180.0),
711 (1.0, 0, 180.0),
712 (0.0, 8, -180.0),
713 (256.0, 8, 180.0),
714 (18.216484444444444, 5, 24.93545),
716 ];
717
718 for (x, zoom, expected_lon) in test_cases {
719 assert!((x_to_lon(x, zoom) - expected_lon).abs() < EPSILON);
720 }
721 }
722
723 #[test]
724 fn test_lon_to_x_conversion() {
725 let test_cases = vec![
727 (0.0, 0, 0.5),
729 (0.0, 8, 128.0),
730 (-180.0, 0, 0.0),
732 (180.0, 0, 1.0), (-180.0, 8, 0.0),
734 (180.0, 8, 256.0),
735 (24.93545, 5, 18.216484444444444),
737 (-0.1275, 8, 127.90933333333333),
739 ];
740
741 for (lon, zoom, expected_x) in test_cases {
742 assert!((lon_to_x(lon, zoom) - expected_x).abs() < EPSILON);
743 }
744 }
745
746 #[test]
747 fn test_tile_id_to_url() {
748 let config = OpenStreetMapConfig::default();
749 let tile_id = TileId {
750 z: 10,
751 x: 559,
752 y: 330,
753 };
754 let url = tile_id.to_url(&config);
755 assert_eq!(url, "https://tile.openstreetmap.org/10/559/330.png");
756 }
757
758 #[test]
759 fn test_map_new() {
760 let config = OpenStreetMapConfig::default();
761 let default_center = config.default_center();
762 let default_zoom = config.default_zoom();
763
764 let map = Map::new(config);
765
766 assert_eq!(map.center, default_center.into());
767 assert_eq!(map.zoom, default_zoom);
768 assert!(map.mouse_pos.is_none());
769 assert!(map.tiles.is_empty());
770 }
771}