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 update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
28//!         egui::CentralPanel::default()
29//!             .frame(egui::Frame::NONE)
30//!             .show(ctx, |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 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
65// The size of a map tile in pixels.
66const TILE_SIZE: u32 = 256;
67/// The minimum zoom level.
68pub const MIN_ZOOM: u8 = 0;
69/// The maximum zoom level.
70pub const MAX_ZOOM: u8 = 19;
71
72// Reuse the reqwest client for all tile downloads by making it a static variable.
73static 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/// Errors that can occur while using the map widget.
85#[derive(Error, Debug)]
86pub enum MapError {
87    /// An error occurred while making a web request.
88    #[error("Connection error")]
89    ConnectionError(#[from] reqwest::Error),
90
91    /// A map tile failed to download.
92    #[error("A map tile failed to download. HTTP Status: `{0}`")]
93    TileDownloadError(String),
94
95    /// The downloaded tile bytes could not be converted to an image.
96    #[error("Unable to convert downloaded map tile bytes as image")]
97    TileBytesConversionError(#[from] image::ImageError),
98}
99
100/// A unique identifier for a map tile.
101#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq)]
102pub struct TileId {
103    /// The zoom level.
104    pub z: u8,
105
106    /// The x-coordinate of the tile.
107    pub x: u32,
108
109    /// The y-coordinate of the tile.
110    pub y: u32,
111}
112
113impl TileId {
114    fn to_url(&self, config: &dyn MapConfig) -> String {
115        config.tile_url(self)
116    }
117}
118
119/// The state of a tile in the cache.
120enum Tile {
121    /// The tile is being downloaded.
122    Loading(Promise<Result<egui::ColorImage, Arc<eyre::Report>>>),
123
124    /// The tile is in memory.
125    Loaded(egui::TextureHandle),
126
127    /// The tile failed to download.
128    Failed(Arc<eyre::Report>),
129
130    /// The tile state is unknown.
131    Unknown,
132}
133
134/// The map widget.
135pub struct Map {
136    /// The geographical center of the map. (longitude, latitude)
137    pub center: GeoPos,
138
139    /// The zoom level of the map.
140    pub zoom: u8,
141
142    tiles: HashMap<TileId, Tile>,
143
144    /// The geographical position under the mouse pointer, if any. (longitude, latitude)
145    pub mouse_pos: Option<GeoPos>,
146
147    /// Configuration for the map, such as the tile server URL.
148    config: Box<dyn MapConfig>,
149
150    /// Layers to be drawn on top of the base map.
151    layers: BTreeMap<String, Box<dyn Layer>>,
152}
153
154impl Map {
155    /// Creates a new `Map` widget.
156    ///
157    /// # Arguments
158    ///
159    /// * `config` - A type that implements `MapConfig`, which provides configuration for the map.
160    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    /// Adds a layer to the map.
174    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    /// Remove a layer from the map
179    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    /// Get a reference to the layers.
188    pub fn layers(&self) -> &BTreeMap<String, Box<dyn Layer>> {
189        &self.layers
190    }
191
192    /// Get a mutable reference to the layers.
193    pub fn layers_mut(&mut self) -> &mut BTreeMap<String, Box<dyn Layer>> {
194        &mut self.layers
195    }
196
197    /// Get a reference to a specific layer.
198    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    /// Get a mutable reference to a specific layer.
205    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    /// Handles user input for panning and zooming.
212    fn handle_input(&mut self, ui: &Ui, rect: &Rect, response: &Response) {
213        // Handle panning
214        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            // Clamp the new center to the map boundaries.
223            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            // If the map is smaller than the viewport, center it. Otherwise, clamp the center.
233            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        // Handle double-click to zoom and center
252        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                    // Determine the geo-coordinate under the mouse cursor before the zoom
258                    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                    // Set the new zoom level and center the map on the clicked location
273                    self.zoom = new_zoom;
274                    self.center = (new_center_lon, new_center_lat).into();
275                }
276            }
277        }
278
279        // Handle scroll-to-zoom
280        if response.hovered() {
281            if let Some(mouse_pos) = response.hover_pos() {
282                let mouse_rel = mouse_pos - rect.min;
283
284                // Determine the geo-coordinate under the mouse cursor.
285                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 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(new_zoom as i32) * TILE_SIZE as f64;
303                        // If the world size would become smaller than the widget size, reject the zoom.
304                        if world_pixel_size < rect.width() as f64
305                            || world_pixel_size < rect.height() as f64
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                            - (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    /// Draws the attribution text.
340    fn draw_attribution(&self, ui: &mut Ui, rect: &Rect) {
341        // Check if the widget is scrolled out of view or clipped.
342        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)) // A bit of padding around the label/URL element
355                .fill(bg_color)
356                .corner_radius(3.0); // Round the edges
357
358            egui::Area::new(ui.id().with("attribution"))
359                // pivot(egui::Align2::LEFT_BOTTOM) tells the Area that its position should be
360                //  calculated from its bottom-left corner, not its top-left.
361                .pivot(egui::Align2::LEFT_BOTTOM)
362                // fixed_pos(rect.left_bottom() + egui::vec2(5.0, -5.0)) now positions the Area's
363                //  bottom-left corner at the map's bottom-left corner, with a small margin to bring
364                //  it nicely inside the map's bounds.
365                .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); // Don't wrap attribution text.
370
371                        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
382/// Converts longitude to the x-coordinate of a tile at a given zoom level.
383fn lon_to_x(lon: f64, zoom: u8) -> f64 {
384    (lon + 180.0) / 360.0 * (2.0_f64.powi(zoom as i32))
385}
386
387/// Converts latitude to the y-coordinate of a tile at a given zoom level.
388fn 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
393/// Converts the x-coordinate of a tile to longitude at a given zoom level.
394fn x_to_lon(x: f64, zoom: u8) -> f64 {
395    x / (2.0_f64.powi(zoom as i32)) * 360.0 - 180.0
396}
397
398/// Converts the y-coordinate of a tile to latitude at a given zoom level.
399fn 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
404/// Draws the map tiles.
405pub(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
418/// Returns an iterator over the visible tiles.
419pub(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
450/// map loads tile as a texture
451pub(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 the tile is loading, check if the promise is ready and update the state.
486    // This is done before matching on the state, so that we can immediately draw
487    // the tile if it has just finished loading.
488    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
508/// Draws a single map tile.
509pub(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            // Draw a gray background and a border for the placeholder.
522            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            // Draw a question mark in the center.
531            painter.text(
532                tile_rect.center(),
533                egui::Align2::CENTER_CENTER,
534                "⌛",
535                egui::FontId::proportional(40.0),
536                Color32::ORANGE,
537            );
538
539            // The tile is still loading, so we need to tell egui to repaint.
540            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            // Draw a gray background and a border for the placeholder.
552            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            // Draw a red exclamation mark in the center.
561            painter.text(
562                tile_rect.center(),
563                egui::Align2::CENTER_CENTER,
564                "❌",
565                egui::FontId::proportional(40.0),
566                Color32::RED,
567            );
568
569            // Log the error message
570            error!("Failed to load tile: {:?}", e);
571        }
572        Tile::Unknown => {
573            // Draw a gray background and a border for the placeholder.
574            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            // Draw a question mark in the center.
583            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        // Give it a minimum size so that it does not become too small
599        // in a horizontal layout. Use tile size as minimum.
600        let desired_size = if ui.layout().main_dir().is_horizontal() {
601            // In a horizontal layout, we want to be a square of a reasonable size.
602            let side = TILE_SIZE as f32;
603            Vec2::splat(side)
604        } else {
605            // In a vertical layout, we want to fill the available space, but only width
606            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        // Create a projection for input handling, based on the state before any changes.
617        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; // Stop after the first layer handles the input.
624            }
625        }
626
627        if !input_handled_by_layer {
628            self.handle_input(ui, &rect, &response);
629
630            // Change the cursor icon when dragging or hovering over the map.
631            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        // Update mouse position.
639        self.mouse_pos = response
640            .hover_pos()
641            .map(|pos| input_projection.unproject(pos));
642
643        // Create a new projection for drawing, with the updated map state.
644        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)); // Background
648
649        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        // y, zoom, expected_lat
704        let test_cases = vec![
705            // Equator
706            (0.5, 0, 0.0),
707            (128.0, 8, 0.0),
708            // Near poles (Mercator projection limits)
709            (0.0, 0, 85.0511287798),
710            (1.0, 0, -85.0511287798),
711            (0.0, 8, 85.0511287798),
712            (256.0, 8, -85.0511287798),
713            // Helsinki
714            (9.262574089998255, 5, 60.16952),
715            // London
716            (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        // lat, zoom, expected_y
727        let test_cases = vec![
728            // Equator
729            (0.0, 0, 0.5),
730            (0.0, 8, 128.0),
731            // Near poles (Mercator projection limits)
732            (85.0511287798, 0, 0.0),
733            (-85.0511287798, 0, 1.0),
734            (85.0511287798, 8, 0.0),
735            (-85.0511287798, 8, 256.0),
736            // Helsinki
737            (60.16952, 5, 9.262574089998255),
738            // London
739            (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        // x, zoom, expected_lon
750        let test_cases = vec![
751            // Center of the map
752            (0.5, 0, 0.0),
753            (128.0, 8, 0.0),
754            // Edges of the map
755            (0.0, 0, -180.0),
756            (1.0, 0, 180.0),
757            (0.0, 8, -180.0),
758            (256.0, 8, 180.0),
759            // Helsinki
760            (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        // lon, zoom, expected_x
771        let test_cases = vec![
772            // Center of the map
773            (0.0, 0, 0.5),
774            (0.0, 8, 128.0),
775            // Edges of the map
776            (-180.0, 0, 0.0),
777            (180.0, 0, 1.0), // upper bound is exclusive for tiles, but not for coordinate space
778            (-180.0, 8, 0.0),
779            (180.0, 8, 256.0),
780            // Helsinki
781            (24.93545, 5, 18.216484444444444),
782            // London
783            (-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}