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 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    /// Adds a layer to the map.
175    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    /// Remove a layer from the map
180    pub fn remove_layer(&mut self, key: &str) -> bool {
181        self.layers.remove(key).is_some()
182    }
183
184    /// Get a reference to the layers.
185    #[must_use]
186    pub fn layers(&self) -> &BTreeMap<String, Box<dyn Layer>> {
187        &self.layers
188    }
189
190    /// Get a mutable reference to the layers.
191    pub fn layers_mut(&mut self) -> &mut BTreeMap<String, Box<dyn Layer>> {
192        &mut self.layers
193    }
194
195    /// Get a reference to a specific layer.
196    #[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    /// Get a mutable reference to a specific layer.
204    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    /// Handles user input for panning and zooming.
211    fn handle_input(&mut self, ui: &Ui, rect: &Rect, response: &Response) {
212        // Handle panning
213        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            // Clamp the new center to the map boundaries.
222            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            // If the map is smaller than the viewport, center it. Otherwise, clamp the center.
232            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        // Handle double-click to zoom and center
251        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                // Determine the geo-coordinate under the mouse cursor before the zoom
260                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                // Set the new zoom level and center the map on the clicked location
275                self.zoom = new_zoom;
276                self.center = (new_center_lon, new_center_lat).into();
277            }
278        }
279
280        // Handle scroll-to-zoom
281        if response.hovered()
282            && let Some(mouse_pos) = response.hover_pos()
283        {
284            let mouse_rel = mouse_pos - rect.min;
285
286            // Determine the geo-coordinate under the mouse cursor.
287            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 we are zooming out, check if the new zoom level is valid.
307                if scroll < 0.0 {
308                    let world_pixel_size = 2.0_f64.powi(i32::from(new_zoom)) * f64::from(TILE_SIZE);
309                    // If the world size would become smaller than the widget size, reject the zoom.
310                    if world_pixel_size < f64::from(rect.width())
311                        || world_pixel_size < f64::from(rect.height())
312                    {
313                        new_zoom = old_zoom; // Effectively cancel the zoom by reverting to the old value.
314                    }
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                    // Set the new zoom level
322                    self.zoom = new_zoom;
323
324                    // Adjust the map center so the geo-coordinate under the mouse remains the
325                    // same
326                    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    /// Draws the attribution text.
345    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)) // A bit of padding around the label/URL element
355                .fill(bg_color)
356                .corner_radius(3.0); // Round the edges
357
358            // We use a child UI instead of egui::Area to ensure the attribution
359            // stays on the same layer as the map widget. This fixes issues where
360            // the attribution would disappear when the map is inside a Window
361            // and the user interacts with it.
362            let attribution_pos = rect.left_bottom() + egui::vec2(5.0, -5.0);
363
364            // Allocate a small area for the attribution.
365            // We use a large enough width but it will be constrained by the content.
366            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); // Don't wrap attribution text.
379
380                    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
391/// Converts longitude to the x-coordinate of a tile at a given zoom level.
392fn lon_to_x(lon: f64, zoom: u8) -> f64 {
393    (lon + 180.0) / 360.0 * (2.0_f64.powi(i32::from(zoom)))
394}
395
396/// Converts latitude to the y-coordinate of a tile at a given zoom level.
397fn 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
402/// Converts the x-coordinate of a tile to longitude at a given zoom level.
403fn x_to_lon(x: f64, zoom: u8) -> f64 {
404    x / (2.0_f64.powi(i32::from(zoom))) * 360.0 - 180.0
405}
406
407/// Converts the y-coordinate of a tile to latitude at a given zoom level.
408fn 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
413/// Draws the map tiles.
414pub(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
427/// Returns an iterator over the visible tiles.
428pub(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
459/// map loads tile as a texture
460pub(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 the tile is loading, check if the promise is ready and update the state.
495    // This is done before matching on the state, so that we can immediately draw
496    // the tile if it has just finished loading.
497    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
517/// Draws a single map tile.
518pub(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            // Draw a gray background and a border for the placeholder.
531            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            // Draw a question mark in the center.
540            painter.text(
541                tile_rect.center(),
542                egui::Align2::CENTER_CENTER,
543                "⌛",
544                egui::FontId::proportional(40.0),
545                Color32::ORANGE,
546            );
547
548            // The tile is still loading, so we need to tell egui to repaint.
549            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            // Draw a gray background and a border for the placeholder.
561            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            // Draw a red exclamation mark in the center.
570            painter.text(
571                tile_rect.center(),
572                egui::Align2::CENTER_CENTER,
573                "❌",
574                egui::FontId::proportional(40.0),
575                Color32::RED,
576            );
577
578            // Log the error message
579            error!("Failed to load tile: {e:?}");
580        }
581        Tile::Unknown => {
582            // Draw a gray background and a border for the placeholder.
583            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            // Draw a question mark in the center.
592            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        // Give it a minimum size so that it does not become too small
608        // in a horizontal layout. Use tile size as minimum.
609        let desired_size = if ui.layout().main_dir().is_horizontal() {
610            // In a horizontal layout, we want to be a square of a reasonable size.
611            let side = TILE_SIZE as f32;
612            Vec2::splat(side)
613        } else {
614            // In a vertical layout, we want to fill the available space, but only width
615            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        // Create a projection for input handling, based on the state before any changes.
626        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; // Stop after the first layer handles the input.
633            }
634        }
635
636        if !input_handled_by_layer {
637            self.handle_input(ui, &rect, &response);
638
639            // Change the cursor icon when dragging or hovering over the map.
640            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        // Update mouse position.
648        self.mouse_pos = response
649            .hover_pos()
650            .map(|pos| input_projection.unproject(pos));
651
652        // Create a new projection for drawing, with the updated map state.
653        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)); // Background
657
658        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        // y, zoom, expected_lat
713        let test_cases = vec![
714            // Equator
715            (0.5, 0, 0.0),
716            (128.0, 8, 0.0),
717            // Near poles (Mercator projection limits)
718            (0.0, 0, 85.0511287798),
719            (1.0, 0, -85.0511287798),
720            (0.0, 8, 85.0511287798),
721            (256.0, 8, -85.0511287798),
722            // Helsinki
723            (9.262574089998255, 5, 60.16952),
724            // London
725            (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        // lat, zoom, expected_y
736        let test_cases = vec![
737            // Equator
738            (0.0, 0, 0.5),
739            (0.0, 8, 128.0),
740            // Near poles (Mercator projection limits)
741            (85.0511287798, 0, 0.0),
742            (-85.0511287798, 0, 1.0),
743            (85.0511287798, 8, 0.0),
744            (-85.0511287798, 8, 256.0),
745            // Helsinki
746            (60.16952, 5, 9.262574089998255),
747            // London
748            (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        // x, zoom, expected_lon
759        let test_cases = vec![
760            // Center of the map
761            (0.5, 0, 0.0),
762            (128.0, 8, 0.0),
763            // Edges of the map
764            (0.0, 0, -180.0),
765            (1.0, 0, 180.0),
766            (0.0, 8, -180.0),
767            (256.0, 8, 180.0),
768            // Helsinki
769            (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        // lon, zoom, expected_x
780        let test_cases = vec![
781            // Center of the map
782            (0.0, 0, 0.5),
783            (0.0, 8, 128.0),
784            // Edges of the map
785            (-180.0, 0, 0.0),
786            (180.0, 0, 1.0), // upper bound is exclusive for tiles, but not for coordinate space
787            (-180.0, 8, 0.0),
788            (180.0, 8, 256.0),
789            // Helsinki
790            (24.93545, 5, 18.216484444444444),
791            // London
792            (-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}