#![warn(missing_docs)]
pub mod config;
#[cfg(feature = "layers")]
pub mod layers;
pub mod projection;
use eframe::egui;
use egui::{Color32, NumExt, Rect, Response, Sense, Ui, Vec2, Widget, pos2};
use eyre::{Context, Result};
use log::{debug, error};
use poll_promise::Promise;
use std::collections::{BTreeMap, HashMap};
use std::sync::Arc;
use thiserror::Error;
use crate::config::MapConfig;
use crate::layers::Layer;
use crate::projection::{GeoPos, MapProjection};
const TILE_SIZE: u32 = 256;
pub const MIN_ZOOM: u8 = 0;
pub const MAX_ZOOM: u8 = 19;
static CLIENT: std::sync::LazyLock<reqwest::blocking::Client> = std::sync::LazyLock::new(|| {
reqwest::blocking::Client::builder()
.user_agent(format!(
"{}/{}",
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION")
))
.build()
.expect("Failed to build reqwest client")
});
#[derive(Error, Debug)]
pub enum MapError {
#[error("Connection error")]
ConnectionError(#[from] reqwest::Error),
#[error("A map tile failed to download. HTTP Status: `{0}`")]
TileDownloadError(String),
#[error("Unable to convert downloaded map tile bytes as image")]
TileBytesConversionError(#[from] image::ImageError),
}
#[derive(Clone, Copy, Debug, Hash, Eq, PartialEq)]
pub struct TileId {
pub z: u8,
pub x: u32,
pub y: u32,
}
impl TileId {
fn to_url(self, config: &dyn MapConfig) -> String {
config.tile_url(&self)
}
}
enum Tile {
Loading(Promise<Result<egui::ColorImage, Arc<eyre::Report>>>),
Loaded(egui::TextureHandle),
Failed(Arc<eyre::Report>),
Unknown,
}
pub struct Map {
pub center: GeoPos,
pub zoom: u8,
tiles: HashMap<TileId, Tile>,
pub mouse_pos: Option<GeoPos>,
config: Box<dyn MapConfig>,
layers: BTreeMap<String, Box<dyn Layer>>,
}
impl Map {
pub fn new<C: MapConfig + 'static>(config: C) -> Self {
let center = GeoPos::from(config.default_center());
let zoom = config.default_zoom();
Self {
tiles: HashMap::new(),
mouse_pos: None,
config: Box::new(config),
center,
zoom,
layers: BTreeMap::new(),
}
}
pub fn add_layer(&mut self, key: impl Into<String>, layer: impl Layer + 'static) {
self.layers.insert(key.into(), Box::new(layer));
}
pub fn remove_layer(&mut self, key: &str) -> bool {
self.layers.remove(key).is_some()
}
#[must_use]
pub fn layers(&self) -> &BTreeMap<String, Box<dyn Layer>> {
&self.layers
}
pub fn layers_mut(&mut self) -> &mut BTreeMap<String, Box<dyn Layer>> {
&mut self.layers
}
#[must_use]
pub fn layer<T: Layer>(&self, key: &str) -> Option<&T> {
self.layers
.get(key)
.and_then(|layer| layer.as_any().downcast_ref::<T>())
}
pub fn layer_mut<T: Layer>(&mut self, key: &str) -> Option<&mut T> {
self.layers
.get_mut(key)
.and_then(|layer| layer.as_any_mut().downcast_mut::<T>())
}
fn handle_input(&mut self, ui: &Ui, rect: &Rect, response: &Response) {
if response.dragged() {
let delta = response.drag_delta();
let center_in_tiles_x = lon_to_x(self.center.lon, self.zoom);
let center_in_tiles_y = lat_to_y(self.center.lat, self.zoom);
let mut new_center_x = center_in_tiles_x - (f64::from(delta.x) / f64::from(TILE_SIZE));
let mut new_center_y = center_in_tiles_y - (f64::from(delta.y) / f64::from(TILE_SIZE));
let world_size_in_tiles = 2.0_f64.powi(i32::from(self.zoom));
let view_size_in_tiles_x = f64::from(rect.width()) / f64::from(TILE_SIZE);
let view_size_in_tiles_y = f64::from(rect.height()) / f64::from(TILE_SIZE);
let min_center_x = view_size_in_tiles_x / 2.0;
let max_center_x = world_size_in_tiles - view_size_in_tiles_x / 2.0;
let min_center_y = view_size_in_tiles_y / 2.0;
let max_center_y = world_size_in_tiles - view_size_in_tiles_y / 2.0;
new_center_x = if min_center_x > max_center_x {
world_size_in_tiles / 2.0
} else {
new_center_x.clamp(min_center_x, max_center_x)
};
new_center_y = if min_center_y > max_center_y {
world_size_in_tiles / 2.0
} else {
new_center_y.clamp(min_center_y, max_center_y)
};
self.center = (
x_to_lon(new_center_x, self.zoom),
y_to_lat(new_center_y, self.zoom),
)
.into();
}
if response.double_clicked()
&& let Some(pointer_pos) = response.interact_pointer_pos()
{
let new_zoom = (self.zoom + 1).clamp(MIN_ZOOM, MAX_ZOOM);
if new_zoom != self.zoom {
let mouse_rel = pointer_pos - rect.min;
let center_x = lon_to_x(self.center.lon, self.zoom);
let center_y = lat_to_y(self.center.lat, self.zoom);
let widget_center_x = f64::from(rect.width()) / 2.0;
let widget_center_y = f64::from(rect.height()) / 2.0;
let target_x =
center_x + (f64::from(mouse_rel.x) - widget_center_x) / f64::from(TILE_SIZE);
let target_y =
center_y + (f64::from(mouse_rel.y) - widget_center_y) / f64::from(TILE_SIZE);
let new_center_lon = x_to_lon(target_x, self.zoom);
let new_center_lat = y_to_lat(target_y, self.zoom);
self.zoom = new_zoom;
self.center = (new_center_lon, new_center_lat).into();
}
}
if response.hovered()
&& let Some(mouse_pos) = response.hover_pos()
{
let mouse_rel = mouse_pos - rect.min;
let center_x = lon_to_x(self.center.lon, self.zoom);
let center_y = lat_to_y(self.center.lat, self.zoom);
let widget_center_x = f64::from(rect.width()) / 2.0;
let widget_center_y = f64::from(rect.height()) / 2.0;
let target_x =
center_x + (f64::from(mouse_rel.x) - widget_center_x) / f64::from(TILE_SIZE);
let target_y =
center_y + (f64::from(mouse_rel.y) - widget_center_y) / f64::from(TILE_SIZE);
let scroll = ui.input(|i| i.smooth_scroll_delta.y);
if scroll != 0.0 {
let old_zoom = self.zoom;
let mut new_zoom = (i32::from(self.zoom) + scroll.signum() as i32)
.clamp(i32::from(MIN_ZOOM), i32::from(MAX_ZOOM))
as u8;
if scroll < 0.0 {
let world_pixel_size = 2.0_f64.powi(i32::from(new_zoom)) * f64::from(TILE_SIZE);
if world_pixel_size < f64::from(rect.width())
|| world_pixel_size < f64::from(rect.height())
{
new_zoom = old_zoom; }
}
if new_zoom != old_zoom {
let target_lon = x_to_lon(target_x, old_zoom);
let target_lat = y_to_lat(target_y, old_zoom);
self.zoom = new_zoom;
let new_target_x = lon_to_x(target_lon, new_zoom);
let new_target_y = lat_to_y(target_lat, new_zoom);
let new_center_x = new_target_x
- (f64::from(mouse_rel.x) - widget_center_x) / f64::from(TILE_SIZE);
let new_center_y = new_target_y
- (f64::from(mouse_rel.y) - widget_center_y) / f64::from(TILE_SIZE);
self.center = (
x_to_lon(new_center_x, new_zoom),
y_to_lat(new_center_y, new_zoom),
)
.into();
}
}
}
}
fn draw_attribution(&self, ui: &mut Ui, rect: &Rect) {
if let Some(attribution) = self.config.attribution() {
let (_text_color, bg_color) = if ui.visuals().dark_mode {
(Color32::from_gray(230), Color32::from_black_alpha(150))
} else {
(Color32::from_gray(80), Color32::from_white_alpha(150))
};
let frame = egui::Frame::NONE
.inner_margin(egui::Margin::same(5)) .fill(bg_color)
.corner_radius(3.0);
let attribution_pos = rect.left_bottom() + egui::vec2(5.0, -5.0);
let mut child_ui = ui.new_child(
egui::UiBuilder::new()
.max_rect(Rect::from_min_size(
attribution_pos - egui::vec2(0.0, 30.0),
egui::vec2(rect.width() - 10.0, 30.0),
))
.id_salt("attribution"),
);
child_ui.with_layout(egui::Layout::left_to_right(egui::Align::BOTTOM), |ui| {
frame.show(ui, |ui| {
ui.style_mut().override_text_style = Some(egui::TextStyle::Small);
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
if let Some(url) = self.config.attribution_url() {
ui.hyperlink_to(attribution, url);
} else {
ui.label(attribution);
}
});
});
}
}
}
fn lon_to_x(lon: f64, zoom: u8) -> f64 {
(lon + 180.0) / 360.0 * (2.0_f64.powi(i32::from(zoom)))
}
fn lat_to_y(lat: f64, zoom: u8) -> f64 {
(1.0 - lat.to_radians().tan().asinh() / std::f64::consts::PI) / 2.0
* (2.0_f64.powi(i32::from(zoom)))
}
fn x_to_lon(x: f64, zoom: u8) -> f64 {
x / (2.0_f64.powi(i32::from(zoom))) * 360.0 - 180.0
}
fn y_to_lat(y: f64, zoom: u8) -> f64 {
let n = std::f64::consts::PI - 2.0 * std::f64::consts::PI * y / (2.0_f64.powi(i32::from(zoom)));
n.sinh().atan().to_degrees()
}
pub(crate) fn draw_map(
tiles: &mut HashMap<TileId, Tile>,
config: &dyn MapConfig,
painter: &egui::Painter,
projection: &MapProjection,
) {
let visible_tiles: Vec<_> = visible_tiles(projection).collect();
for (tile_id, tile_pos) in visible_tiles {
load_tile(tiles, config, painter.ctx(), tile_id);
draw_tile(tiles, painter, &tile_id, tile_pos, Color32::WHITE);
}
}
pub(crate) fn visible_tiles(
projection: &MapProjection,
) -> impl Iterator<Item = (TileId, egui::Pos2)> {
let center_x = lon_to_x(projection.center_lon, projection.zoom);
let center_y = lat_to_y(projection.center_lat, projection.zoom);
let widget_center_x = projection.widget_rect.width() / 2.0;
let widget_center_y = projection.widget_rect.height() / 2.0;
let x_min = (center_x - f64::from(widget_center_x) / f64::from(TILE_SIZE)).floor() as i32;
let y_min = (center_y - f64::from(widget_center_y) / f64::from(TILE_SIZE)).floor() as i32;
let x_max = (center_x + f64::from(widget_center_x) / f64::from(TILE_SIZE)).ceil() as i32;
let y_max = (center_y + f64::from(widget_center_y) / f64::from(TILE_SIZE)).ceil() as i32;
let zoom = projection.zoom;
let rect_min = projection.widget_rect.min;
(x_min..=x_max).flat_map(move |x| {
(y_min..=y_max).map(move |y| {
let tile_id = TileId {
z: zoom,
x: x as u32,
y: y as u32,
};
let screen_x = widget_center_x + (f64::from(x) - center_x) as f32 * TILE_SIZE as f32;
let screen_y = widget_center_y + (f64::from(y) - center_y) as f32 * TILE_SIZE as f32;
let tile_pos = rect_min + Vec2::new(screen_x, screen_y);
(tile_id, tile_pos)
})
})
}
pub(crate) fn load_tile(
tiles: &mut HashMap<TileId, Tile>,
config: &dyn MapConfig,
ctx: &egui::Context,
tile_id: TileId,
) {
let tile_state = tiles.entry(tile_id).or_insert_with(|| {
let url = tile_id.to_url(config);
let promise =
Promise::spawn_thread("download_tile", move || -> Result<_, Arc<eyre::Report>> {
let result: Result<_, eyre::Report> = (|| {
debug!("Downloading tile from {}", &url);
let response = CLIENT.get(&url).send().map_err(MapError::from)?;
if !response.status().is_success() {
return Err(MapError::TileDownloadError(response.status().to_string()));
}
let bytes = response.bytes().map_err(MapError::from)?.to_vec();
let image = image::load_from_memory(&bytes)
.map_err(MapError::from)?
.to_rgba8();
let size = [image.width() as _, image.height() as _];
let pixels = image.into_raw();
Ok(egui::ColorImage::from_rgba_unmultiplied(size, &pixels))
})()
.with_context(|| format!("Failed to download tile from {}", &url));
result.map_err(Arc::new)
});
Tile::Loading(promise)
});
if let Tile::Loading(promise) = tile_state
&& let Some(result) = promise.ready()
{
match result {
Ok(color_image) => {
let texture = ctx.load_texture(
format!("tile_{}_{}_{}", tile_id.z, tile_id.x, tile_id.y),
color_image.clone(),
Default::default(),
);
*tile_state = Tile::Loaded(texture);
}
Err(e) => {
error!("{e:?}");
*tile_state = Tile::Failed(e.clone());
}
}
}
}
pub(crate) fn draw_tile(
tiles: &HashMap<TileId, Tile>,
painter: &egui::Painter,
tile_id: &TileId,
tile_pos: egui::Pos2,
tint: Color32,
) {
let tile_rect = Rect::from_min_size(tile_pos, Vec2::new(TILE_SIZE as f32, TILE_SIZE as f32));
let default_state = Tile::Unknown;
let tile_state = tiles.get(tile_id).unwrap_or(&default_state);
match tile_state {
Tile::Loading(_) => {
painter.rect_filled(tile_rect, 0.0, Color32::from_gray(220));
painter.rect_stroke(
tile_rect,
0.0,
egui::Stroke::new(1.0, Color32::GRAY),
egui::StrokeKind::Inside,
);
painter.text(
tile_rect.center(),
egui::Align2::CENTER_CENTER,
"⌛",
egui::FontId::proportional(40.0),
Color32::ORANGE,
);
painter.ctx().request_repaint();
}
Tile::Loaded(texture) => {
painter.image(
texture.id(),
tile_rect,
Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)),
tint,
);
}
Tile::Failed(e) => {
painter.rect_filled(tile_rect, 0.0, Color32::from_gray(220));
painter.rect_stroke(
tile_rect,
0.0,
egui::Stroke::new(1.0, Color32::GRAY),
egui::StrokeKind::Inside,
);
painter.text(
tile_rect.center(),
egui::Align2::CENTER_CENTER,
"❌",
egui::FontId::proportional(40.0),
Color32::RED,
);
error!("Failed to load tile: {e:?}");
}
Tile::Unknown => {
painter.rect_filled(tile_rect, 0.0, Color32::from_gray(220));
painter.rect_stroke(
tile_rect,
0.0,
egui::Stroke::new(1.0, Color32::GRAY),
egui::StrokeKind::Inside,
);
painter.text(
tile_rect.center(),
egui::Align2::CENTER_CENTER,
"❓",
egui::FontId::proportional(40.0),
Color32::RED,
);
error!("Tile state not found for {tile_id:?}");
}
}
}
impl Widget for &mut Map {
fn ui(self, ui: &mut Ui) -> Response {
let desired_size = if ui.layout().main_dir().is_horizontal() {
let side = TILE_SIZE as f32;
Vec2::splat(side)
} else {
let mut available_size = ui
.available_size()
.at_least(Vec2::new(TILE_SIZE as f32, TILE_SIZE as f32));
available_size.y = TILE_SIZE as f32;
available_size
};
let response = ui.allocate_response(desired_size, Sense::drag().union(Sense::click()));
let rect = response.rect;
let input_projection = MapProjection::new(self.zoom, self.center, rect);
let mut input_handled_by_layer = false;
for layer in self.layers.values_mut() {
if layer.handle_input(&response, &input_projection) {
input_handled_by_layer = true;
break; }
}
if !input_handled_by_layer {
self.handle_input(ui, &rect, &response);
if response.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
} else if response.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
}
self.mouse_pos = response
.hover_pos()
.map(|pos| input_projection.unproject(pos));
let draw_projection = MapProjection::new(self.zoom, self.center, rect);
let painter = ui.painter_at(rect);
painter.rect_filled(rect, 0.0, Color32::from_rgb(220, 220, 220));
draw_map(
&mut self.tiles,
self.config.as_ref(),
&painter,
&draw_projection,
);
for layer in self.layers.values() {
layer.draw(&painter, &draw_projection);
}
self.draw_attribution(ui, &rect);
response
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::OpenStreetMapConfig;
const EPSILON: f64 = 1e-9;
#[test]
fn test_coord_conversion_roundtrip() {
let original_lon = 24.93545;
let original_lat = 60.16952;
let zoom: u8 = 10;
let x = lon_to_x(original_lon, zoom);
let y = lat_to_y(original_lat, zoom);
let final_lon = x_to_lon(x, zoom);
let final_lat = y_to_lat(y, zoom);
assert!((original_lon - final_lon).abs() < EPSILON);
assert!((original_lat - final_lat).abs() < EPSILON);
let original_lon = -122.4194;
let original_lat = 37.7749;
let x = lon_to_x(original_lon, zoom);
let y = lat_to_y(original_lat, zoom);
let final_lon = x_to_lon(x, zoom);
let final_lat = y_to_lat(y, zoom);
assert!((original_lon - final_lon).abs() < EPSILON);
assert!((original_lat - final_lat).abs() < EPSILON);
}
#[test]
fn test_y_to_lat_conversion() {
let test_cases = vec![
(0.5, 0, 0.0),
(128.0, 8, 0.0),
(0.0, 0, 85.0511287798),
(1.0, 0, -85.0511287798),
(0.0, 8, 85.0511287798),
(256.0, 8, -85.0511287798),
(9.262574089998255, 5, 60.16952),
(85.12653378959828, 8, 51.5074),
];
for (y, zoom, expected_lat) in test_cases {
assert!((y_to_lat(y, zoom) - expected_lat).abs() < EPSILON);
}
}
#[test]
fn test_lat_to_y_conversion() {
let test_cases = vec![
(0.0, 0, 0.5),
(0.0, 8, 128.0),
(85.0511287798, 0, 0.0),
(-85.0511287798, 0, 1.0),
(85.0511287798, 8, 0.0),
(-85.0511287798, 8, 256.0),
(60.16952, 5, 9.262574089998255),
(51.5074, 8, 85.12653378959828),
];
for (lat, zoom, expected_y) in test_cases {
assert!((lat_to_y(lat, zoom) - expected_y).abs() < EPSILON);
}
}
#[test]
fn test_x_to_lon_conversion() {
let test_cases = vec![
(0.5, 0, 0.0),
(128.0, 8, 0.0),
(0.0, 0, -180.0),
(1.0, 0, 180.0),
(0.0, 8, -180.0),
(256.0, 8, 180.0),
(18.216484444444444, 5, 24.93545),
];
for (x, zoom, expected_lon) in test_cases {
assert!((x_to_lon(x, zoom) - expected_lon).abs() < EPSILON);
}
}
#[test]
fn test_lon_to_x_conversion() {
let test_cases = vec![
(0.0, 0, 0.5),
(0.0, 8, 128.0),
(-180.0, 0, 0.0),
(180.0, 0, 1.0), (-180.0, 8, 0.0),
(180.0, 8, 256.0),
(24.93545, 5, 18.216484444444444),
(-0.1275, 8, 127.90933333333333),
];
for (lon, zoom, expected_x) in test_cases {
assert!((lon_to_x(lon, zoom) - expected_x).abs() < EPSILON);
}
}
#[test]
fn test_tile_id_to_url() {
let config = OpenStreetMapConfig::default();
let tile_id = TileId {
z: 10,
x: 559,
y: 330,
};
let url = tile_id.to_url(&config);
assert_eq!(url, "https://tile.openstreetmap.org/10/559/330.png");
}
#[test]
fn test_map_new() {
let config = OpenStreetMapConfig::default();
let default_center = config.default_center();
let default_zoom = config.default_zoom();
let map = Map::new(config);
assert_eq!(map.center, default_center.into());
assert_eq!(map.zoom, default_zoom);
assert!(map.mouse_pos.is_none());
assert!(map.tiles.is_empty());
}
}