Skip to main content

egui_map_view/
lib.rs

1#![warn(missing_docs)]
2
3//! A simple map view widget for `egui`.
4//!
5//! This crate provides a `Map` widget that can be used to display a map from a tile server.
6//! It supports panning, zooming, and displaying the current mouse position in geographical coordinates.
7//!
8//! # Example
9//!
10//! ```no_run
11//! use eframe::egui;
12//! use egui_map_view::{Map, config::OpenStreetMapConfig};
13//!
14//! struct MyApp {
15//!     map: Map,
16//! }
17//!
18//! impl Default for MyApp {
19//!     fn default() -> Self {
20//!         Self {
21//!             map: Map::new(OpenStreetMapConfig::default()),
22//!         }
23//!     }
24//! }
25//!
26//! impl eframe::App for MyApp {
27//!     fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
28//!         egui::CentralPanel::default()
29//!             .frame(egui::Frame::NONE)
30//!             .show_inside(ui, |ui| {
31//!                if ui.add(&mut self.map).clicked() {
32//!                    if let Some(pos) = self.map.mouse_pos {
33//!                        println!("Map clicked at {} x {}", pos.lon, pos.lat);
34//!                    }
35//!                };
36//!             });
37//!     }
38//! }
39//! ```
40
41/// Configuration traits and types for the map widget.
42pub mod config;
43
44/// Map layers.
45#[cfg(feature = "layers")]
46pub mod layers;
47
48/// Map projection.
49pub 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
64// The size of a map tile in pixels.
65const TILE_SIZE: u32 = 256;
66/// The minimum zoom level.
67pub const MIN_ZOOM: u8 = 0;
68/// The maximum zoom level.
69pub const MAX_ZOOM: u8 = 19;
70
71// Reuse the reqwest client for all tile downloads by making it a static variable.
72static 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/// Errors that can occur while using the map widget.
84#[derive(Error, Debug)]
85pub enum MapError {
86    /// An error occurred while making a web request.
87    #[error("Connection error")]
88    ConnectionError(#[from] reqwest::Error),
89
90    /// A map tile failed to download.
91    #[error("A map tile failed to download. HTTP Status: `{0}`")]
92    TileDownloadError(String),
93
94    /// The downloaded tile bytes could not be converted to an image.
95    #[error("Unable to convert downloaded map tile bytes as image")]
96    TileBytesConversionError(#[from] image::ImageError),
97}
98
99/// A unique identifier for a map tile.
100#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq)]
101pub struct TileId {
102    /// The zoom level.
103    pub z: u8,
104
105    /// The x-coordinate of the tile.
106    pub x: u32,
107
108    /// The y-coordinate of the tile.
109    pub y: u32,
110}
111
112impl TileId {
113    fn to_url(self, config: &dyn MapConfig) -> String {
114        config.tile_url(&self)
115    }
116}
117
118/// The state of a tile in the cache.
119enum Tile {
120    /// The tile is being downloaded.
121    Loading(Promise<Result<egui::ColorImage, Arc<eyre::Report>>>),
122
123    /// The tile is in memory.
124    Loaded(egui::TextureHandle),
125
126    /// The tile failed to download.
127    Failed(Arc<eyre::Report>),
128
129    /// The tile state is unknown.
130    Unknown,
131}
132
133/// The map widget.
134pub struct Map {
135    /// The geographical center of the map. (longitude, latitude)
136    pub center: GeoPos,
137
138    /// The zoom level of the map.
139    pub zoom: u8,
140
141    tiles: HashMap<TileId, Tile>,
142
143    /// The geographical position under the mouse pointer, if any. (longitude, latitude)
144    pub mouse_pos: Option<GeoPos>,
145
146    /// Configuration for the map, such as the tile server URL.
147    config: Box<dyn MapConfig>,
148
149    /// Layers to be drawn on top of the base map.
150    layers: BTreeMap<String, Box<dyn Layer>>,
151}
152
153impl Map {
154    /// Creates a new `Map` widget.
155    ///
156    /// # Arguments
157    ///
158    /// * `config` - A type that implements `MapConfig`, which provides configuration for the map.
159    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    /// Adds a layer to the map.
173    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    /// Remove a layer from the map
178    pub fn remove_layer(&mut self, key: &str) -> bool {
179        self.layers.remove(key).is_some()
180    }
181
182    /// Get a reference to the layers.
183    #[must_use]
184    pub fn layers(&self) -> &BTreeMap<String, Box<dyn Layer>> {
185        &self.layers
186    }
187
188    /// Get a mutable reference to the layers.
189    pub fn layers_mut(&mut self) -> &mut BTreeMap<String, Box<dyn Layer>> {
190        &mut self.layers
191    }
192
193    /// Get a reference to a specific layer.
194    #[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    /// Get a mutable reference to a specific layer.
202    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    /// Handles user input for panning and zooming.
209    fn handle_input(&mut self, ui: &Ui, rect: &Rect, response: &Response) {
210        // Handle panning
211        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            // Clamp the new center to the map boundaries.
220            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            // If the map is smaller than the viewport, center it. Otherwise, clamp the center.
230            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        // Handle double-click to zoom and center
249        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                // Determine the geo-coordinate under the mouse cursor before the zoom
256                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                // Set the new zoom level and center the map on the clicked location
271                self.zoom = new_zoom;
272                self.center = (new_center_lon, new_center_lat).into();
273            }
274        }
275
276        // Handle scroll-to-zoom
277        if response.hovered()
278            && let Some(mouse_pos) = response.hover_pos()
279        {
280            let mouse_rel = mouse_pos - rect.min;
281
282            // Determine the geo-coordinate under the mouse cursor.
283            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 we are zooming out, check if the new zoom level is valid.
301                if scroll < 0.0 {
302                    let world_pixel_size = 2.0_f64.powi(i32::from(new_zoom)) * f64::from(TILE_SIZE);
303                    // If the world size would become smaller than the widget size, reject the zoom.
304                    if world_pixel_size < f64::from(rect.width())
305                        || world_pixel_size < f64::from(rect.height())
306                    {
307                        new_zoom = old_zoom; // Effectively cancel the zoom by reverting to the old value.
308                    }
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                    // Set the new zoom level
316                    self.zoom = new_zoom;
317
318                    // Adjust the map center so the geo-coordinate under the mouse remains the
319                    // same
320                    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    /// Draws the attribution text.
339    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)) // A bit of padding around the label/URL element
349                .fill(bg_color)
350                .corner_radius(3.0); // Round the edges
351
352            // We use a child UI instead of egui::Area to ensure the attribution
353            // stays on the same layer as the map widget. This fixes issues where
354            // the attribution would disappear when the map is inside a Window
355            // and the user interacts with it.
356            let attribution_pos = rect.left_bottom() + egui::vec2(5.0, -5.0);
357
358            // Allocate a small area for the attribution.
359            // We use a large enough width but it will be constrained by the content.
360            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); // Don't wrap attribution text.
373
374                    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
385/// Converts longitude to the x-coordinate of a tile at a given zoom level.
386fn lon_to_x(lon: f64, zoom: u8) -> f64 {
387    (lon + 180.0) / 360.0 * (2.0_f64.powi(i32::from(zoom)))
388}
389
390/// Converts latitude to the y-coordinate of a tile at a given zoom level.
391fn 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
396/// Converts the x-coordinate of a tile to longitude at a given zoom level.
397fn x_to_lon(x: f64, zoom: u8) -> f64 {
398    x / (2.0_f64.powi(i32::from(zoom))) * 360.0 - 180.0
399}
400
401/// Converts the y-coordinate of a tile to latitude at a given zoom level.
402fn 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
407/// Draws the map tiles.
408pub(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
421/// Returns an iterator over the visible tiles.
422pub(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
453/// map loads tile as a texture
454pub(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 the tile is loading, check if the promise is ready and update the state.
489    // This is done before matching on the state, so that we can immediately draw
490    // the tile if it has just finished loading.
491    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
511/// Draws a single map tile.
512pub(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            // Draw a gray background and a border for the placeholder.
525            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            // Draw a question mark in the center.
534            painter.text(
535                tile_rect.center(),
536                egui::Align2::CENTER_CENTER,
537                "⌛",
538                egui::FontId::proportional(40.0),
539                Color32::ORANGE,
540            );
541
542            // The tile is still loading, so we need to tell egui to repaint.
543            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            // Draw a gray background and a border for the placeholder.
555            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            // Draw a red exclamation mark in the center.
564            painter.text(
565                tile_rect.center(),
566                egui::Align2::CENTER_CENTER,
567                "❌",
568                egui::FontId::proportional(40.0),
569                Color32::RED,
570            );
571
572            // Log the error message
573            error!("Failed to load tile: {e:?}");
574        }
575        Tile::Unknown => {
576            // Draw a gray background and a border for the placeholder.
577            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            // Draw a question mark in the center.
586            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        // Give it a minimum size so that it does not become too small
602        // in a horizontal layout. Use tile size as minimum.
603        let desired_size = if ui.layout().main_dir().is_horizontal() {
604            // In a horizontal layout, we want to be a square of a reasonable size.
605            let side = TILE_SIZE as f32;
606            Vec2::splat(side)
607        } else {
608            // In a vertical layout, we want to fill the available space, but only width
609            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        // Create a projection for input handling, based on the state before any changes.
620        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; // Stop after the first layer handles the input.
627            }
628        }
629
630        if !input_handled_by_layer {
631            self.handle_input(ui, &rect, &response);
632
633            // Change the cursor icon when dragging or hovering over the map.
634            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        // Update mouse position.
642        self.mouse_pos = response
643            .hover_pos()
644            .map(|pos| input_projection.unproject(pos));
645
646        // Create a new projection for drawing, with the updated map state.
647        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)); // Background
651
652        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        // y, zoom, expected_lat
707        let test_cases = vec![
708            // Equator
709            (0.5, 0, 0.0),
710            (128.0, 8, 0.0),
711            // Near poles (Mercator projection limits)
712            (0.0, 0, 85.0511287798),
713            (1.0, 0, -85.0511287798),
714            (0.0, 8, 85.0511287798),
715            (256.0, 8, -85.0511287798),
716            // Helsinki
717            (9.262574089998255, 5, 60.16952),
718            // London
719            (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        // lat, zoom, expected_y
730        let test_cases = vec![
731            // Equator
732            (0.0, 0, 0.5),
733            (0.0, 8, 128.0),
734            // Near poles (Mercator projection limits)
735            (85.0511287798, 0, 0.0),
736            (-85.0511287798, 0, 1.0),
737            (85.0511287798, 8, 0.0),
738            (-85.0511287798, 8, 256.0),
739            // Helsinki
740            (60.16952, 5, 9.262574089998255),
741            // London
742            (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        // x, zoom, expected_lon
753        let test_cases = vec![
754            // Center of the map
755            (0.5, 0, 0.0),
756            (128.0, 8, 0.0),
757            // Edges of the map
758            (0.0, 0, -180.0),
759            (1.0, 0, 180.0),
760            (0.0, 8, -180.0),
761            (256.0, 8, 180.0),
762            // Helsinki
763            (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        // lon, zoom, expected_x
774        let test_cases = vec![
775            // Center of the map
776            (0.0, 0, 0.5),
777            (0.0, 8, 128.0),
778            // Edges of the map
779            (-180.0, 0, 0.0),
780            (180.0, 0, 1.0), // upper bound is exclusive for tiles, but not for coordinate space
781            (-180.0, 8, 0.0),
782            (180.0, 8, 256.0),
783            // Helsinki
784            (24.93545, 5, 18.216484444444444),
785            // London
786            (-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}