use crate::compat::HashMap;
use crate::core::{Color, HorizontalAlignment, Point, Rect};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::signal::Signal1;
use crate::style::{MotionSlot, PropertyDriver};
use crate::widget::capability::coercion::expect_f32;
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::metrics::effective_font;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
use alloc::rc::Rc;
use core::cell::{Ref, RefCell};
const MAP_MARKER_SIZE: u32 = 6;
const MAP_MARKER_COLOR: Color = Color::rgb(59, 114, 190);
const MAP_MARKER_SELECTED_COLOR: Color = Color::rgb(230, 88, 76);
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MarkerStyle {
pub color: Color,
pub size: u32,
pub icon: Option<crate::widget::IconSource>,
}
impl Default for MarkerStyle {
fn default() -> Self {
Self { color: MAP_MARKER_COLOR, size: MAP_MARKER_SIZE, icon: None }
}
}
impl MarkerStyle {
pub fn new(color: Color, size: u32) -> Self {
Self { color, size: size.max(2), icon: None }
}
pub fn with_icon(mut self, icon: crate::widget::IconSource) -> Self {
self.icon = Some(icon);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MapProjection {
#[default]
Linear,
Mercator,
}
impl MapProjection {
pub fn token(self) -> &'static str {
match self {
Self::Linear => "linear",
Self::Mercator => "mercator",
}
}
pub fn from_token(token: &str) -> Option<Self> {
match token {
"linear" => Some(Self::Linear),
"mercator" => Some(Self::Mercator),
_ => None,
}
}
}
const MERCATOR_MAX_LATITUDE: f32 = 85.051_13;
fn next_map_key() -> u64 {
use core::sync::atomic::{AtomicU64, Ordering};
static NEXT: AtomicU64 = AtomicU64::new(1);
NEXT.fetch_add(1, Ordering::Relaxed)
}
fn color_key(color: Color) -> u64 {
(u64::from(color.r) << 24)
| (u64::from(color.g) << 16)
| (u64::from(color.b) << 8)
| u64::from(color.a)
}
#[derive(Debug, Clone, PartialEq)]
pub struct MapMarker {
pub id: String,
pub label: String,
pub x: f32,
pub y: f32,
pub style: MarkerStyle,
}
impl MapMarker {
pub fn new(id: impl Into<String>, label: impl Into<String>, x: f32, y: f32) -> Self {
Self { id: id.into(), label: label.into(), x, y, style: MarkerStyle::default() }
}
pub fn with_style(mut self, style: MarkerStyle) -> Self {
self.style = style;
self
}
}
pub const MAP_MARKER_SCHEMA: crate::widget::capability::RecordSchema =
crate::widget::capability::RecordSchema {
fields: &[
crate::widget::capability::RecordField::required(
"id",
crate::widget::capability::PropertyValueKind::String,
),
crate::widget::capability::RecordField::required(
"label",
crate::widget::capability::PropertyValueKind::String,
),
crate::widget::capability::RecordField::required(
"x",
crate::widget::capability::PropertyValueKind::Float,
),
crate::widget::capability::RecordField::required(
"y",
crate::widget::capability::PropertyValueKind::Float,
),
crate::widget::capability::RecordField::optional(
"color",
crate::widget::capability::PropertyValueKind::Color,
),
crate::widget::capability::RecordField::optional(
"size",
crate::widget::capability::PropertyValueKind::UInt,
),
crate::widget::capability::RecordField::optional(
"icon_source",
crate::widget::capability::PropertyValueKind::IconSpec,
),
],
id_field: "id",
ids_unique: true,
ids_required: true,
};
fn map_marker_record(
marker: &MapMarker,
) -> Result<crate::widget::capability::RecordValue, CapabilityAccessError> {
let mut record = crate::widget::capability::RecordValue::new()
.with("id", CapabilityValue::String(marker.id.clone()))
.with("label", CapabilityValue::String(marker.label.clone()))
.with("x", CapabilityValue::Float(f64::from(marker.x)))
.with("y", CapabilityValue::Float(f64::from(marker.y)))
.with("color", CapabilityValue::Color(marker.style.color))
.with("size", CapabilityValue::UInt(u64::from(marker.style.size)));
match marker.style.icon {
None => {}
Some(crate::widget::IconSource::Builtin(name)) => {
record = record.with(
"icon_source",
CapabilityValue::IconSpec(crate::widget::icon_source::IconSpec::Builtin(name)),
);
}
Some(crate::widget::IconSource::StaticGeometry(_)) => {
return Err(CapabilityAccessError::IconSourceNotPersistable)
}
}
Ok(record)
}
fn map_marker_from_record(
record: &crate::widget::capability::RecordValue,
) -> Result<MapMarker, CapabilityAccessError> {
let text = |name: &str| match record.get(name) {
Some(CapabilityValue::String(value)) => Ok(value.clone()),
_ => Err(CapabilityAccessError::TypeMismatch),
};
let number = |name: &str| match record.get(name) {
Some(CapabilityValue::Float(value)) => Ok(*value as f32),
Some(CapabilityValue::Int(value)) => Ok(*value as f32),
Some(CapabilityValue::UInt(value)) => Ok(*value as f32),
_ => Err(CapabilityAccessError::TypeMismatch),
};
let mut style = MarkerStyle::default();
match record.get("color") {
None | Some(CapabilityValue::Null) => {}
Some(CapabilityValue::Color(color)) => style.color = *color,
Some(_) => return Err(CapabilityAccessError::TypeMismatch),
}
match record.get("size") {
None | Some(CapabilityValue::Null) => {}
Some(CapabilityValue::UInt(size)) => {
style.size = u32::try_from(*size).map_err(|_| CapabilityAccessError::TypeMismatch)?;
}
Some(_) => return Err(CapabilityAccessError::TypeMismatch),
}
match record.get("icon_source") {
None | Some(CapabilityValue::Null) => {}
Some(CapabilityValue::IconSpec(crate::widget::icon_source::IconSpec::Builtin(name))) => {
style.icon = Some(crate::widget::IconSource::Builtin(*name));
}
Some(CapabilityValue::IconSpec(crate::widget::icon_source::IconSpec::Resource {
..
})) => return Err(CapabilityAccessError::ResourceResolutionRequired),
Some(_) => return Err(CapabilityAccessError::TypeMismatch),
}
Ok(MapMarker {
id: text("id")?,
label: text("label")?,
x: number("x")?,
y: number("y")?,
style,
})
}
#[derive(Clone)]
struct MapCluster {
indices: Vec<usize>,
screen: (f32, f32),
world: (f32, f32),
}
pub struct MapView {
base: BaseWidget,
center_x: f32,
center_y: f32,
zoom: f32,
markers: Vec<MapMarker>,
selected_marker: Option<usize>,
zoom_t: PropertyDriver,
zoom_from: f32,
last_pointer: Option<Point>,
drag: Option<(Point, f32, f32)>,
cluster_markers: bool,
projection: MapProjection,
pub center_changed: Signal1<(f32, f32)>,
pub zoom_changed: Signal1<f32>,
pub marker_selected: Signal1<String>,
pub marker_clicked: Signal1<String>,
pub cluster_clicked: Signal1<(f32, f32)>,
pub tile_requested: Signal1<TileRequest>,
tile_loader: TileLoader,
zoom_control: ZoomControl,
scale_bar: ScaleBar,
compass: Compass,
show_map_controls: bool,
prefetch_on_paint: bool,
markers_revision: u64,
cluster_cache: RefCell<Option<(ClusterKey, Rc<Vec<MapCluster>>)>>,
status_cache: RefCell<Option<StatusCache>>,
controls_layout: Option<Rect>,
grid_cache_key: u64,
}
struct StatusCache {
key: ((f32, f32), f32),
text: String,
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct ClusterKey {
painted_zoom: f32,
center: (f32, f32),
rect: Rect,
markers_revision: u64,
projection: MapProjection,
}
impl MapView {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::Canvas, geometry, "MapView"),
center_x: 0.0,
center_y: 0.0,
zoom: 1.0,
zoom_t: PropertyDriver::at(1.0, MotionSlot::Normal),
zoom_from: 1.0,
last_pointer: None,
drag: None,
cluster_markers: false,
projection: MapProjection::Linear,
markers: Vec::new(),
selected_marker: None,
center_changed: Signal1::new(),
zoom_changed: Signal1::new(),
marker_selected: Signal1::new(),
marker_clicked: Signal1::new(),
cluster_clicked: Signal1::new(),
tile_requested: Signal1::new(),
tile_loader: TileLoader::new(Rect::new(0, 0, 0, 0)),
zoom_control: ZoomControl::new(Rect::new(0, 0, 0, 0)),
scale_bar: ScaleBar::new(Rect::new(0, 0, 0, 0)),
compass: Compass::new(Rect::new(0, 0, 0, 0)),
show_map_controls: true,
prefetch_on_paint: true,
markers_revision: 1,
cluster_cache: RefCell::new(None),
status_cache: RefCell::new(None),
controls_layout: None,
grid_cache_key: next_map_key(),
}
}
fn invalidate_status(&mut self) {
*self.status_cache.get_mut() = None;
}
pub fn tile_loader(&self) -> &TileLoader {
&self.tile_loader
}
pub fn tile_loader_mut(&mut self) -> &mut TileLoader {
&mut self.tile_loader
}
pub fn zoom_control(&self) -> &ZoomControl {
&self.zoom_control
}
pub fn zoom_control_mut(&mut self) -> &mut ZoomControl {
&mut self.zoom_control
}
pub fn scale_bar(&self) -> &ScaleBar {
&self.scale_bar
}
pub fn scale_bar_mut(&mut self) -> &mut ScaleBar {
&mut self.scale_bar
}
pub fn compass(&self) -> &Compass {
&self.compass
}
pub fn compass_mut(&mut self) -> &mut Compass {
&mut self.compass
}
pub fn map_controls_visible(&self) -> bool {
self.show_map_controls
}
pub fn set_map_controls_visible(&mut self, visible: bool) {
if self.show_map_controls == visible {
return;
}
self.show_map_controls = visible;
self.base.request_redraw();
}
pub fn prefetch_on_paint(&self) -> bool {
self.prefetch_on_paint
}
pub fn set_prefetch_on_paint(&mut self, enabled: bool) {
self.prefetch_on_paint = enabled;
}
pub fn deliver_tile(&mut self, key: TileKey, payload: Vec<u8>) -> bool {
let delivered = self.tile_loader.deliver_tile(key, payload);
if delivered {
self.base.request_redraw();
}
delivered
}
pub fn reject_tile(&mut self, key: TileKey) -> bool {
let rejected = self.tile_loader.reject_tile(key);
if rejected {
self.base.request_redraw();
}
rejected
}
pub fn tile_viewport(&self) -> TileViewport {
let rect = self.geometry();
let painted = self.painted_zoom().max(0.0001);
let raw_zoom = (painted.log2().round() as i32)
.clamp(self.tile_loader.min_zoom() as i32, self.tile_loader.max_zoom() as i32);
let zoom = raw_zoom as u8;
let scale = 2f64.powi(zoom as i32);
let tile_world = (self.tile_loader.tile_size().max(1) as f64 / scale).max(1.0);
let world_w = rect.width as f64 / painted as f64;
let world_h = rect.height as f64 / painted as f64;
let x = (self.center_x as f64 - world_w / 2.0) / tile_world;
let y = (self.project_world_y(self.center_y) as f64 - world_h / 2.0) / tile_world;
TileViewport::new(zoom, x, y, rect.width, rect.height)
}
pub fn prefetch_tiles(&mut self) -> Vec<TileRequest> {
let viewport = self.tile_viewport();
let requests = self.tile_loader.prefetch(viewport);
for request in &requests {
if !request.cached {
self.tile_requested.emit(request.clone());
}
}
requests
}
fn layout_map_controls(&mut self) {
let rect = self.geometry();
if self.controls_layout == Some(rect) {
return;
}
self.controls_layout = Some(rect);
let zoom_size = self.zoom_control.size_hint();
let compass_size = self.compass.size_hint();
let scale_size = self.scale_bar.size_hint();
let zoom_rect = Rect::new(
rect.x + rect.width as i32 - zoom_size.width as i32 - 8,
rect.y + 8,
zoom_size.width,
zoom_size.height,
);
let compass_rect = Rect::new(
zoom_rect.x + (zoom_rect.width as i32 - compass_size.width as i32) / 2,
zoom_rect.y + zoom_rect.height as i32 + 6,
compass_size.width,
compass_size.height,
);
let scale_rect = Rect::new(
rect.x + 8,
rect.y + rect.height as i32 - scale_size.height as i32 - 8,
scale_size.width,
scale_size.height,
);
if self.zoom_control.base().geometry() != zoom_rect {
self.zoom_control.base_mut().set_geometry(zoom_rect);
}
if self.compass.base().geometry() != compass_rect {
self.compass.base_mut().set_geometry(compass_rect);
}
if self.scale_bar.base().geometry() != scale_rect {
self.scale_bar.base_mut().set_geometry(scale_rect);
}
}
fn sync_map_controls(&mut self) {
if (self.zoom_control.zoom_level() - self.zoom).abs() > f32::EPSILON {
self.zoom_control.set_zoom(self.zoom);
}
let meters_per_pixel = 1.0 / self.painted_zoom().max(0.0001) as f64;
self.scale_bar.set_scale(meters_per_pixel);
}
pub fn center(&self) -> (f32, f32) {
(self.center_x, self.center_y)
}
pub fn set_center(&mut self, x: f32, y: f32) {
if !x.is_finite() || !y.is_finite() {
return;
}
if (self.center_x - x).abs() < f32::EPSILON && (self.center_y - y).abs() < f32::EPSILON {
return;
}
self.center_x = x;
self.center_y = y;
self.invalidate_status();
self.center_changed.emit((x, y));
self.refresh_tiles();
self.base.request_redraw();
}
fn refresh_tiles(&mut self) {
if self.prefetch_on_paint {
let _ = self.prefetch_tiles();
}
}
pub fn pan_by(&mut self, dx: f32, dy: f32) {
if !dx.is_finite() || !dy.is_finite() {
return;
}
self.set_center(self.center_x + dx, self.center_y + dy);
}
pub fn zoom(&self) -> f32 {
self.zoom
}
pub fn set_cluster_markers(&mut self, cluster: bool) {
if self.cluster_markers == cluster {
return;
}
self.cluster_markers = cluster;
self.base.request_redraw();
}
pub fn cluster_markers(&self) -> bool {
self.cluster_markers
}
fn project_world_y(&self, y: f32) -> f32 {
match self.projection {
MapProjection::Linear => y,
MapProjection::Mercator => {
let latitude = y.clamp(-MERCATOR_MAX_LATITUDE, MERCATOR_MAX_LATITUDE).to_radians();
(core::f32::consts::FRAC_PI_4 + latitude / 2.0).tan().ln().to_degrees()
}
}
}
fn unproject_world_y(&self, projected: f32) -> f32 {
match self.projection {
MapProjection::Linear => projected,
MapProjection::Mercator => {
let ordinate = projected.to_radians();
(2.0 * ordinate.exp().atan() - core::f32::consts::FRAC_PI_2).to_degrees()
}
}
}
pub fn projection(&self) -> MapProjection {
self.projection
}
pub fn set_projection(&mut self, projection: MapProjection) {
if self.projection == projection {
return;
}
self.projection = projection;
self.base.request_redraw();
}
pub fn add_marker(&mut self, marker: MapMarker) -> usize {
let index = self.markers.len();
self.markers.push(marker);
self.markers_revision = self.markers_revision.wrapping_add(1);
self.base.request_redraw();
index
}
pub fn remove_marker(&mut self, id: &str) -> Option<MapMarker> {
let index = self.markers.iter().position(|marker| marker.id == id)?;
let removed = self.markers.remove(index);
self.markers_revision = self.markers_revision.wrapping_add(1);
self.selected_marker = match self.selected_marker {
Some(selected) if selected == index => None,
Some(selected) if selected > index => Some(selected - 1),
other => other,
};
self.base.request_redraw();
Some(removed)
}
pub fn set_marker_style(&mut self, id: &str, style: MarkerStyle) -> bool {
let Some(marker) = self.markers.iter_mut().find(|marker| marker.id == id) else {
return false;
};
marker.style = style;
self.markers_revision = self.markers_revision.wrapping_add(1);
self.base.request_redraw();
true
}
fn marker_size(&self, index: usize) -> u32 {
self.markers.get(index).map_or(MAP_MARKER_SIZE, |marker| marker.style.size.max(2))
}
fn zoom_at(&mut self, factor: f32, anchor: Point) {
let rect = self.geometry();
let zoom_before = self.painted_zoom().max(0.0001);
let center_px = rect.x as f32 + rect.width as f32 / 2.0;
let center_py = rect.y as f32 + rect.height as f32 / 2.0;
let world_x = self.center_x + (anchor.x as f32 - center_px) / zoom_before;
let world_y =
self.project_world_y(self.center_y) + (anchor.y as f32 - center_py) / zoom_before;
let before = self.zoom;
self.zoom_by(factor);
if (self.zoom - before).abs() < f32::EPSILON {
return;
}
let zoom_after = self.painted_zoom().max(0.0001);
self.center_x = world_x - (anchor.x as f32 - center_px) / zoom_after;
self.center_y =
self.unproject_world_y(world_y - (anchor.y as f32 - center_py) / zoom_after);
self.invalidate_status();
self.center_changed.emit((self.center_x, self.center_y));
self.refresh_tiles();
self.base.request_redraw();
}
pub fn set_zoom(&mut self, zoom: f32) {
if !zoom.is_finite() {
return;
}
let next = zoom.clamp(0.25, 8.0);
if (self.zoom - next).abs() < f32::EPSILON {
return;
}
self.zoom_from = self.painted_zoom();
self.zoom = next;
self.zoom_t.jump_to(0.0);
self.zoom_t.set_target(1.0);
self.invalidate_status();
self.zoom_changed.emit(next);
self.refresh_tiles();
self.base.request_redraw();
}
pub fn zoom_by(&mut self, factor: f32) {
if !factor.is_finite() || factor <= 0.0 {
return;
}
self.set_zoom(self.zoom * factor);
}
pub fn set_markers(&mut self, markers: Vec<MapMarker>) {
self.markers = markers;
self.markers_revision = self.markers_revision.wrapping_add(1);
if let Some(index) = self.selected_marker {
if index >= self.markers.len() {
self.selected_marker = None;
}
}
self.base.request_redraw();
}
pub fn markers(&self) -> &[MapMarker] {
&self.markers
}
pub fn selected_marker_id(&self) -> Option<&str> {
let index = self.selected_marker?;
self.markers.get(index).map(|marker| marker.id.as_str())
}
fn status_line(&self) -> Ref<'_, str> {
let key = ((self.center_x, self.center_y), self.zoom);
let stale = !matches!(self.status_cache.borrow().as_ref(), Some(cache) if cache.key == key);
if stale {
let text = format!(
"Center ({:.1}, {:.1}) Zoom {:.2}x",
self.center_x, self.center_y, self.zoom
);
*self.status_cache.borrow_mut() = Some(StatusCache { key, text });
}
Ref::map(self.status_cache.borrow(), |cache| match cache.as_ref() {
Some(cache) => cache.text.as_str(),
None => "",
})
}
fn draw_tiles(&self, context: &mut RenderContext, rect: Rect) {
if rect.width == 0 || rect.height == 0 {
return;
}
let viewport = self.tile_viewport();
let zoom = viewport.zoom;
let per_axis = TileKey::tiles_per_axis(zoom) as i64;
if per_axis <= 0 {
return;
}
let tile_size = self.tile_loader.tile_size().max(1) as f64;
let tile_world = tile_size / 2f64.powi(i32::from(zoom));
let painted = self.painted_zoom().max(0.0001) as f64;
let center_proj_y = self.project_world_y(self.center_y) as f64;
let center_screen_x = rect.x as f64 + rect.width as f64 / 2.0;
let center_screen_y = rect.y as f64 + rect.height as f64 / 2.0;
let tile_px = (tile_world * painted).round().max(1.0) as u32;
let first_col = viewport.x.floor() as i64;
let first_row = viewport.y.floor() as i64;
let cols = (viewport.width as f64 / tile_size).ceil() as i64 + 2;
let rows = (viewport.height as f64 / tile_size).ceil() as i64 + 2;
let mut painted_parents: Vec<TileKey> = Vec::new();
for row in 0..rows {
for col in 0..cols {
let x = first_col + col;
let y = first_row + row;
if x < 0 || y < 0 || x >= per_axis || y >= per_axis {
continue;
}
let screen_x =
center_screen_x + (x as f64 * tile_world - f64::from(self.center_x)) * painted;
let screen_y = center_screen_y + (y as f64 * tile_world - center_proj_y) * painted;
let screen_x = screen_x.round() as i32;
let screen_y = screen_y.round() as i32;
let key = TileKey::new(zoom, x as u32, y as u32);
if let Some(pixels) = self.tile_loader.tile_pixels(key) {
context.draw_image(screen_x, screen_y, tile_px, tile_px, pixels);
continue;
}
if zoom == 0 {
continue;
}
let parent = TileKey::new(zoom - 1, (x / 2) as u32, (y / 2) as u32);
if painted_parents.contains(&parent) {
continue;
}
let Some(pixels) = self.tile_loader.tile_pixels(parent) else {
continue;
};
painted_parents.push(parent);
let parent_world = tile_world * 2.0;
let parent_x = center_screen_x
+ (f64::from(parent.x) * parent_world - f64::from(self.center_x)) * painted;
let parent_y = center_screen_y
+ (f64::from(parent.y) * parent_world - center_proj_y) * painted;
let size = (tile_px as f64 * 2.0).round().max(1.0) as u32;
context.draw_image(
parent_x.round() as i32,
parent_y.round() as i32,
size,
size,
pixels,
);
}
}
}
fn painted_zoom(&self) -> f32 {
let t = self.zoom_t.value();
self.zoom_from + (self.zoom - self.zoom_from) * t
}
fn world_to_screen(&self, world_x: f32, world_y: f32) -> (f32, f32) {
let rect = self.geometry();
let zoom = self.painted_zoom();
let sx = rect.x as f32 + rect.width as f32 / 2.0 + (world_x - self.center_x) * zoom;
let sy = rect.y as f32
+ rect.height as f32 / 2.0
+ (self.project_world_y(world_y) - self.project_world_y(self.center_y)) * zoom;
(sx, sy)
}
fn marker_is_inside(&self, index: usize) -> bool {
let rect = self.geometry();
let Some(marker) = self.markers.get(index) else {
return false;
};
let (sx, sy) = self.world_to_screen(marker.x, marker.y);
let (px, py) = (sx.round() as i32, sy.round() as i32);
let half = self.marker_size(index) as i32 / 2;
px - half >= rect.x
&& (px + half) < rect.x + rect.width as i32
&& py - half >= rect.y
&& (py + half) < rect.y + rect.height as i32
}
fn hit_marker_index(&self, pos: Point) -> Option<usize> {
let px = pos.x as f32;
let py = pos.y as f32;
for index in 0..self.markers.len() {
if !self.marker_is_inside(index) {
continue;
}
let Some(marker) = self.markers.get(index) else {
continue;
};
let (sx, sy) = self.world_to_screen(marker.x, marker.y);
let dx = sx - px;
let dy = sy - py;
let reach = (self.marker_size(index) as f32 / 2.0).max(2.0) + 2.0;
if dx * dx + dy * dy <= reach * reach {
return Some(index);
}
}
None
}
fn clusters(&self) -> Rc<Vec<MapCluster>> {
let key = ClusterKey {
painted_zoom: self.painted_zoom(),
center: (self.center_x, self.center_y),
rect: self.geometry(),
markers_revision: self.markers_revision,
projection: self.projection,
};
if let Some((cached, clusters)) = self.cluster_cache.borrow().as_ref() {
if *cached == key {
return Rc::clone(clusters);
}
}
let clusters = Rc::new(self.compute_clusters());
*self.cluster_cache.borrow_mut() = Some((key, Rc::clone(&clusters)));
clusters
}
fn compute_clusters(&self) -> Vec<MapCluster> {
const CELL: f32 = 36.0;
let mut order: Vec<Vec<usize>> = Vec::new();
let mut cells: HashMap<(i32, i32), usize> = HashMap::new();
for index in 0..self.markers.len() {
if !self.marker_is_inside(index) {
continue;
}
let Some(marker) = self.markers.get(index) else {
continue;
};
let (sx, sy) = self.world_to_screen(marker.x, marker.y);
let key = ((sx / CELL).floor() as i32, (sy / CELL).floor() as i32);
match cells.get(&key).copied() {
Some(bucket) => order[bucket].push(index),
None => {
cells.insert(key, order.len());
order.push(vec![index]);
}
}
}
order
.into_iter()
.filter_map(|indices| {
let mut screen = (0.0f32, 0.0f32);
let mut world = (0.0f32, 0.0f32);
for index in &indices {
let marker = self.markers.get(*index)?;
let (sx, sy) = self.world_to_screen(marker.x, marker.y);
screen.0 += sx;
screen.1 += sy;
world.0 += marker.x;
world.1 += marker.y;
}
let count = indices.len() as f32;
Some(MapCluster {
indices,
screen: (screen.0 / count, screen.1 / count),
world: (world.0 / count, world.1 / count),
})
})
.collect()
}
fn cluster_at(&self, pos: Point) -> Option<MapCluster> {
if !self.cluster_markers {
return None;
}
let px = pos.x as f32;
let py = pos.y as f32;
self.clusters().iter().find_map(|cluster| {
if cluster.indices.len() < 2 {
return None;
}
let dx = cluster.screen.0 - px;
let dy = cluster.screen.1 - py;
(dx * dx + dy * dy <= 12.0 * 12.0).then(|| cluster.clone())
})
}
fn select_marker(&mut self, index: usize) -> bool {
if index >= self.markers.len() {
return false;
}
if self.selected_marker == Some(index) {
if let Some(marker) = self.markers.get(index) {
self.marker_clicked.emit(marker.id.clone());
}
return true;
}
self.selected_marker = Some(index);
if let Some(marker) = self.markers.get(index) {
self.marker_selected.emit(marker.id.clone());
self.marker_clicked.emit(marker.id.clone());
}
self.base.request_redraw();
true
}
}
impl MapView {
fn draw_marker(
&self,
context: &mut RenderContext,
index: usize,
style: &crate::style::WidgetStyle,
ink: Color,
) {
if !self.marker_is_inside(index) {
return;
}
let Some(marker) = self.markers.get(index) else {
return;
};
let rect = self.geometry();
let (sx, sy) = self.world_to_screen(marker.x, marker.y);
let size = self.marker_size(index);
let half = size as i32 / 2;
let color = if self.selected_marker == Some(index) {
MAP_MARKER_SELECTED_COLOR
} else {
marker.style.color
};
if let Some(icon) = marker.style.icon {
let slot = Rect::new(sx as i32 - half, sy as i32 - half, size, size);
if let Err(error) = crate::widget::paint_icon(context, icon.into(), slot, color) {
log::warn!("map marker '{}' icon failed: {error:?}", marker.id);
}
} else {
let head = Point::new(sx as i32, sy as i32 - half);
context.fill_circle(head, half.max(2) as u32, color);
let tip = [
Point::new(sx as i32 - half / 2, sy as i32 - 1),
Point::new(sx as i32 + half / 2, sy as i32 - 1),
Point::new(sx as i32, sy as i32 + half),
];
context.draw_path(&tip, true, color, true, 0);
}
let label_left = (sx as i32) + half + 3;
let label_width = (rect.x + rect.width as i32 - label_left).max(0) as u32;
if label_width > 0 {
context.draw_text_fitted_cow(
Rect::new(label_left, sy as i32 - half, label_width, 14),
&marker.label,
effective_font(style),
ink,
HorizontalAlignment::Left,
);
}
}
}
impl Widget for MapView {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> crate::core::Size {
crate::core::Size::new(320, 240)
}
fn tick(&mut self, delta_ms: u32) -> bool {
self.zoom_t.tick(delta_ms)
}
fn is_animating(&self) -> bool {
self.zoom_t.is_moving()
}
impl_draw_bridge!();
impl_widget_property_hooks!();
fn event_signal_dyn(&self, name: &str) -> Option<crate::signal::EventSignalRef> {
use crate::signal::EventSignalRef;
match name {
"center_changed" => {
Some(EventSignalRef::mapped("center_changed", &self.center_changed, |v| {
CapabilityValue::Tuple(alloc::vec![
CapabilityValue::Float(v.0 as f64),
CapabilityValue::Float(v.1 as f64),
])
}))
}
"zoom_changed" => {
Some(EventSignalRef::mapped("zoom_changed", &self.zoom_changed, |v| {
CapabilityValue::Float(*v as f64)
}))
}
"marker_selected" => {
Some(EventSignalRef::mapped("marker_selected", &self.marker_selected, |v| {
CapabilityValue::String(v.clone())
}))
}
"marker_clicked" => {
Some(EventSignalRef::mapped("marker_clicked", &self.marker_clicked, |v| {
CapabilityValue::String(v.clone())
}))
}
"cluster_clicked" => {
Some(EventSignalRef::mapped("cluster_clicked", &self.cluster_clicked, |v| {
CapabilityValue::Tuple(alloc::vec![
CapabilityValue::Float(v.0 as f64),
CapabilityValue::Float(v.1 as f64),
])
}))
}
"tile_requested" => {
Some(EventSignalRef::mapped("tile_requested", &self.tile_requested, |request| {
CapabilityValue::Tuple(alloc::vec![
CapabilityValue::UInt(request.key.zoom as u64),
CapabilityValue::UInt(request.key.x as u64),
CapabilityValue::UInt(request.key.y as u64),
CapabilityValue::String(request.url.clone()),
])
}))
}
_ => None,
}
}
}
impl WidgetProperties for MapView {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"markers" => {
Ok(CapabilityValue::Records(crate::widget::capability::RecordList::from_items(
&MAP_MARKER_SCHEMA,
self.markers()
.iter()
.map(map_marker_record)
.collect::<Result<Vec<_>, CapabilityAccessError>>()?,
)))
}
"center_x" => Ok(CapabilityValue::Float(self.center().0 as f64)),
"center_y" => Ok(CapabilityValue::Float(self.center().1 as f64)),
"zoom" => Ok(CapabilityValue::Float(self.zoom() as f64)),
"marker_count" => Ok(CapabilityValue::UInt(self.markers().len() as u64)),
"selected_marker_id" => match self.selected_marker_id() {
Some(id) => Ok(CapabilityValue::String(id.to_string())),
None => Ok(CapabilityValue::Null),
},
"projection" => Ok(CapabilityValue::String(self.projection().token().to_string())),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"center_x" => {
let x = expect_f32(value)?;
let (_, y) = self.center();
self.set_center(x, y);
Ok(())
}
"center_y" => {
let y = expect_f32(value)?;
let (x, _) = self.center();
self.set_center(x, y);
Ok(())
}
"zoom" => {
self.set_zoom(expect_f32(value)?);
Ok(())
}
"projection" => {
let token = match value {
CapabilityValue::String(token) => token,
_ => return Err(CapabilityAccessError::TypeMismatch),
};
let projection =
MapProjection::from_token(&token).ok_or(CapabilityAccessError::TypeMismatch)?;
self.set_projection(projection);
Ok(())
}
"marker_count" | "selected_marker_id" => Err(CapabilityAccessError::ReadOnlyProperty),
"markers" => {
let CapabilityValue::Records(list) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
list.validate_against(&MAP_MARKER_SCHEMA).map_err(|violation| {
log::warn!("map_view.markers refused: {violation}");
CapabilityAccessError::ConstraintViolation {
property: crate::compat::String::from("markers"),
reason: crate::compat::format!("{violation}"),
}
})?;
let markers = list.items().iter().map(map_marker_from_record).collect::<Result<
Vec<_>,
CapabilityAccessError,
>>(
)?;
self.set_markers(markers);
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"markers",
"center_x",
"center_y",
"zoom",
"marker_count",
"selected_marker_id",
"projection",
BASE_PROPERTY_NAMES
]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"set_markers" => Err(CapabilityAccessError::OutOfRange),
_ => self.default_command(name),
}
}
}
impl EventHandler for MapView {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
self.layout_map_controls();
match event {
Event::Wheel { delta, .. } => {
let anchor = self.last_pointer.unwrap_or_else(|| {
let rect = self.geometry();
Point::new(rect.x + rect.width as i32 / 2, rect.y + rect.height as i32 / 2)
});
if delta.y < 0 {
self.zoom_at(1.15, anchor);
} else if delta.y > 0 {
self.zoom_at(0.87, anchor);
}
}
Event::MouseMove { pos } => {
self.last_pointer = Some(*pos);
if let Some((origin, center_x, center_y)) = self.drag {
let zoom = self.painted_zoom().max(0.0001);
self.set_center(
center_x - (pos.x - origin.x) as f32 / zoom,
self.unproject_world_y(center_y - (pos.y - origin.y) as f32 / zoom),
);
}
}
Event::MouseRelease { button: 1, .. } => {
self.drag = None;
}
Event::MouseLeave { .. } => {
self.drag = None;
}
Event::KeyPress { key, modifiers: _ } => match *key {
37 => self.pan_by(-20.0 / self.zoom, 0.0),
38 => self.pan_by(0.0, -20.0 / self.zoom),
39 => self.pan_by(20.0 / self.zoom, 0.0),
40 => self.pan_by(0.0, 20.0 / self.zoom),
187 | 107 => self.zoom_by(1.12),
189 | 109 => self.zoom_by(0.9),
_ => { }
},
Event::MousePress { pos, button: 1, .. } => {
if self.show_map_controls {
if self.zoom_control.base().geometry().contains_point(*pos) {
let before = self.zoom_control.zoom_level();
self.zoom_control.handle_event(event);
let after = self.zoom_control.zoom_level();
if (after - before).abs() > f32::EPSILON {
self.set_zoom(after);
}
return;
}
if self.compass.base().geometry().contains_point(*pos) {
self.compass.handle_event(event);
self.compass.set_bearing(0.0);
return;
}
}
if let Some(cluster) = self.cluster_at(*pos) {
self.cluster_clicked.emit(cluster.world);
self.base.request_redraw();
return;
}
if let Some(index) = self.hit_marker_index(*pos) {
let _ = self.select_marker(index);
} else {
self.drag = Some((*pos, self.center_x, self.project_world_y(self.center_y)));
}
}
_ => { }
}
}
}
impl Draw for MapView {
fn draw(&mut self, context: &mut RenderContext) {
let rect = self.geometry();
let style = self.base.style();
let theme = crate::style::resolved_theme_style_shared("map_view");
let ink = style
.text_color
.or_else(|| theme.as_ref().and_then(|t| t.text_color))
.unwrap_or(Color::rgb(45, 58, 74));
let border = style
.border_color
.or_else(|| theme.as_ref().and_then(|t| t.border_color))
.unwrap_or(Color::rgb(168, 184, 201));
let window_fill =
crate::style::theme_manager().current_theme().map(|active| active.colors.background);
let surface = match window_fill {
Some(window_fill) if window_fill == ink => ink.blend(&border, 0.5),
_ => style
.background_color
.or_else(|| theme.as_ref().and_then(|t| t.background_color))
.unwrap_or(Color::rgb(235, 243, 250)),
};
let surface = match window_fill {
Some(window_fill) if surface == window_fill => window_fill.blend(&ink, 0.08),
_ => surface,
};
let grid = surface.blend(&ink, 0.12);
context.face_with_gradient_corners(
rect,
surface,
self.style().background_gradient.as_ref(),
self.style().surface.unwrap_or_default(),
self.style().resolved_corner_radii().to_array(),
Color::BLACK,
);
crate::widget::draw::stroke_surface_border(
context,
rect,
self.style().resolved_corner_radii().to_array(),
border,
1,
);
let step = (40.0 * self.painted_zoom().clamp(0.5, 8.0)) as i32;
if step > 8 {
let key =
self.grid_cache_key ^ color_key(grid) ^ color_key(surface) ^ ((step as u64) << 44);
context.push_layer_group(rect, None, 1.0, Some(key));
let mut x = rect.x;
while x < rect.x + rect.width as i32 {
context.draw_line(
Point::new(x, rect.y),
Point::new(x, rect.y + rect.height as i32),
grid,
);
x += step;
}
let mut y = rect.y;
while y < rect.y + rect.height as i32 {
context.draw_line(
Point::new(rect.x, y),
Point::new(rect.x + rect.width as i32, y),
grid,
);
y += step;
}
context.pop_layer();
}
self.draw_tiles(context, rect);
if self.cluster_markers {
let clusters = self.clusters();
for cluster in clusters.iter() {
let (sx, sy) = cluster.screen;
if cluster.indices.len() > 1 {
context.fill_circle(Point::new(sx as i32, sy as i32), 9, MAP_MARKER_COLOR);
let count = cluster.indices.len().to_string();
let metrics = context.measure_text(&count, effective_font(style));
context.draw_text(
Point::new(sx as i32 - metrics.width as i32 / 2, sy as i32 - 6),
&count,
effective_font(style),
Color::WHITE,
HorizontalAlignment::Left,
);
} else if let Some(index) = cluster.indices.first().copied() {
self.draw_marker(context, index, style, ink);
}
}
} else {
for index in 0..self.markers.len() {
self.draw_marker(context, index, style, ink);
}
}
{
let status = self.status_line();
context.draw_text_fitted_cow(
Rect::new(rect.x + 8, rect.y + 4, rect.width.saturating_sub(16), 14),
&status,
effective_font(style),
ink,
HorizontalAlignment::Left,
);
}
self.layout_map_controls();
self.sync_map_controls();
if self.show_map_controls {
self.zoom_control.draw(context);
self.compass.draw(context);
self.scale_bar.draw(context);
}
}
}
#[path = "map_view_companions.rs"]
mod companions;
pub use companions::{
Compass, ScaleBar, ScaleBarStyle, ScaleUnits, TileKey, TileLoadError, TileLoader, TileRequest,
TileViewport, ZoomButton, ZoomControl, MAX_TILE_ZOOM,
};
#[path = "map_marker_detail.rs"]
mod marker_detail;
pub use marker_detail::{DetailLine, MapMarkerDetail};
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
#[test]
fn pan_and_zoom_update_state() {
let mut map = MapView::new(Rect::new(0, 0, 400, 240));
map.pan_by(15.0, -8.0);
map.zoom_by(1.5);
let (cx, cy) = map.center();
assert!((cx - 15.0).abs() < 0.001);
assert!((cy + 8.0).abs() < 0.001);
assert!((map.zoom() - 1.5).abs() < 0.001);
}
#[test]
fn wheel_event_changes_zoom() {
let mut map = MapView::new(Rect::new(0, 0, 400, 240));
let before = map.zoom();
map.handle_event(&Event::wheel(0, -120, 0));
let after_in = map.zoom();
assert!(after_in > before);
map.handle_event(&Event::wheel(0, 120, 0));
let after_out = map.zoom();
assert!(after_out < after_in);
}
#[test]
fn marker_selection_emits_signal() {
let mut map = MapView::new(Rect::new(0, 0, 400, 240));
map.set_markers(vec![MapMarker::new("m1", "Alpha", 0.0, 0.0)]);
let selected = Arc::new(Mutex::new(Vec::<String>::new()));
let sink = selected.clone();
map.marker_selected.connect(move |id| {
if let Ok(mut guard) = sink.lock() {
guard.push(id.as_ref().clone());
}
});
map.handle_event(&Event::mouse_press(200, 120, 1));
assert_eq!(map.selected_marker_id(), Some("m1"));
let got = selected.lock().ok().map(|guard| guard.clone()).unwrap_or_default();
assert_eq!(got, vec!["m1".to_string()]);
}
#[test]
fn new_creates_default_state() {
let map = MapView::new(Rect::new(0, 0, 800, 600));
assert_eq!(map.center(), (0.0, 0.0));
assert_eq!(map.zoom(), 1.0);
assert!(map.markers().is_empty());
assert_eq!(map.selected_marker_id(), None);
}
#[test]
fn set_center_delta_guard() {
let mut map = MapView::new(Rect::new(0, 0, 800, 600));
map.set_center(10.0, 20.0);
assert_eq!(map.center(), (10.0, 20.0));
let emitted = Arc::new(Mutex::new(Vec::<String>::new()));
let sink = emitted.clone();
map.center_changed.connect(move |_| {
if let Ok(mut guard) = sink.lock() {
guard.push("changed".to_string());
}
});
map.set_center(10.0, 20.0);
let got = emitted.lock().ok().map(|g| g.clone()).unwrap_or_default();
assert_eq!(got.len(), 0);
}
#[test]
fn pan_by_zero_is_no_op() {
let mut map = MapView::new(Rect::new(0, 0, 800, 600));
map.set_center(5.0, 5.0);
map.pan_by(0.0, 0.0);
assert_eq!(map.center(), (5.0, 5.0));
}
#[test]
fn set_zoom_clamps_lower_bound() {
let mut map = MapView::new(Rect::new(0, 0, 800, 600));
map.set_zoom(0.1);
assert!((map.zoom() - 0.25).abs() < f32::EPSILON);
}
#[test]
fn set_zoom_clamps_upper_bound() {
let mut map = MapView::new(Rect::new(0, 0, 800, 600));
map.set_zoom(12.0);
assert!((map.zoom() - 8.0).abs() < f32::EPSILON);
}
#[test]
fn set_zoom_guard_no_op() {
let mut map = MapView::new(Rect::new(0, 0, 800, 600));
map.set_zoom(2.0);
let emitted = Arc::new(Mutex::new(Vec::<f32>::new()));
let sink = emitted.clone();
map.zoom_changed.connect(move |z| {
if let Ok(mut guard) = sink.lock() {
guard.push(*z);
}
});
map.set_zoom(2.0);
let got = emitted.lock().ok().map(|g| g.clone()).unwrap_or_default();
assert_eq!(got.len(), 0);
}
#[test]
fn zoom_by_negative_factor_does_nothing() {
let mut map = MapView::new(Rect::new(0, 0, 800, 600));
let before = map.zoom();
map.zoom_by(-1.0);
assert!((map.zoom() - before).abs() < f32::EPSILON);
}
#[test]
fn set_markers_replaces_and_clears_invalid_selection() {
let mut map = MapView::new(Rect::new(0, 0, 800, 600));
map.set_markers(vec![MapMarker::new("m1", "A", 0.0, 0.0)]);
let _ = map.select_marker(0);
assert_eq!(map.selected_marker_id(), Some("m1"));
map.set_markers(vec![MapMarker::new("m2", "B", 10.0, 10.0)]);
assert_eq!(map.selected_marker_id(), Some("m2"));
}
#[test]
fn empty_markers_no_crash() {
let mut map = MapView::new(Rect::new(0, 0, 800, 600));
assert!(map.markers().is_empty());
assert_eq!(map.selected_marker_id(), None);
assert!(!map.select_marker(0));
}
#[test]
fn select_marker_invalid_index() {
let mut map = MapView::new(Rect::new(0, 0, 800, 600));
map.set_markers(vec![MapMarker::new("m1", "A", 0.0, 0.0)]);
assert!(!map.select_marker(5));
assert_eq!(map.selected_marker_id(), None);
}
#[test]
fn center_changed_signal_emits_on_pan() {
let mut map = MapView::new(Rect::new(0, 0, 800, 600));
let centers = Arc::new(Mutex::new(Vec::<(f32, f32)>::new()));
let sink = centers.clone();
map.center_changed.connect(move |c| {
if let Ok(mut guard) = sink.lock() {
guard.push(*c);
}
});
map.pan_by(10.0, -5.0);
let got = centers.lock().ok().map(|g| g.clone()).unwrap_or_default();
assert_eq!(got, vec![(10.0, -5.0)]);
}
#[test]
fn zoom_changed_signal_emits_on_set() {
let mut map = MapView::new(Rect::new(0, 0, 800, 600));
let zooms = Arc::new(Mutex::new(Vec::<f32>::new()));
let sink = zooms.clone();
map.zoom_changed.connect(move |z| {
if let Ok(mut guard) = sink.lock() {
guard.push(*z);
}
});
map.set_zoom(2.5);
let got = zooms.lock().ok().map(|g| g.clone()).unwrap_or_default();
assert!((got[0] - 2.5).abs() < f32::EPSILON);
}
#[test]
fn a_marker_outside_the_pane_has_neither_pin_nor_hit_box() {
let mut map = MapView::new(Rect::new(0, 0, 400, 240));
map.set_zoom(1.0);
map.set_markers(vec![
MapMarker::new("centre", "Centre", 0.0, 0.0),
MapMarker::new("far", "Far away", 5000.0, -4000.0),
]);
assert!(map.marker_is_inside(0), "the centred marker is in the pane");
assert!(!map.marker_is_inside(1), "the far marker is not");
let (sx, sy) = map.world_to_screen(5000.0, -4000.0);
assert!(
map.hit_marker_index(Point::new(sx as i32, sy as i32)).is_none(),
"a marker outside the pane must not be selectable"
);
}
#[test]
fn non_finite_inputs_rejected() {
let mut map = MapView::new(Rect::new(0, 0, 400, 240));
map.set_center(f32::NAN, 10.0);
assert_eq!(map.center(), (0.0, 0.0));
map.set_center(10.0, f32::INFINITY);
assert_eq!(map.center(), (0.0, 0.0));
map.set_zoom(f32::NAN);
assert_eq!(map.zoom(), 1.0);
map.set_zoom(f32::INFINITY);
assert_eq!(map.zoom(), 1.0);
map.zoom_by(f32::NAN);
assert_eq!(map.zoom(), 1.0);
map.pan_by(f32::NAN, 0.0);
assert_eq!(map.center(), (0.0, 0.0));
}
#[test]
fn add_and_remove_marker_keeps_the_selected_id() {
let mut map = MapView::new(Rect::new(0, 0, 400, 240));
map.add_marker(MapMarker::new("a", "A", 0.0, 0.0));
map.add_marker(MapMarker::new("b", "B", 20.0, 0.0));
assert!(map.select_marker(1));
assert_eq!(map.selected_marker_id(), Some("b"));
let removed = map.remove_marker("a").expect("a exists");
assert_eq!(removed.id, "a");
assert_eq!(map.selected_marker_id(), Some("b"));
assert_eq!(map.markers().len(), 1);
assert!(map.remove_marker("b").is_some());
assert_eq!(map.selected_marker_id(), None);
}
#[test]
fn marker_click_emits_even_when_already_selected() {
let mut map = MapView::new(Rect::new(0, 0, 400, 240));
map.set_markers(vec![MapMarker::new("m1", "Alpha", 0.0, 0.0)]);
let clicks = Arc::new(Mutex::new(Vec::<String>::new()));
let sink = clicks.clone();
map.marker_clicked.connect(move |id| {
if let Ok(mut guard) = sink.lock() {
guard.push(id.as_ref().clone());
}
});
map.handle_event(&Event::mouse_press(200, 120, 1));
map.handle_event(&Event::mouse_press(200, 120, 1));
let got = clicks.lock().ok().map(|guard| guard.clone()).unwrap_or_default();
assert_eq!(got, vec!["m1".to_string(), "m1".to_string()]);
}
#[test]
fn mercator_projection_pushes_northern_latitudes_away_from_the_centre() {
let mut map = MapView::new(Rect::new(0, 0, 400, 240));
map.set_markers(vec![MapMarker::new("north", "North", 0.0, 45.0)]);
let (_, linear_y) = map.world_to_screen(0.0, 45.0);
map.set_projection(MapProjection::Mercator);
let (_, mercator_y) = map.world_to_screen(0.0, 45.0);
assert!(
mercator_y > linear_y,
"mercator must stretch the y axis: linear {linear_y}, mercator {mercator_y}"
);
map.set_center(0.0, 45.0);
let (_, centred_y) = map.world_to_screen(0.0, 45.0);
assert!((centred_y - 120.0).abs() < 0.5, "centred marker y was {centred_y}");
}
#[test]
fn projection_property_round_trips() {
let mut map = MapView::new(Rect::new(0, 0, 400, 240));
assert_eq!(map.get("projection").unwrap(), CapabilityValue::String("linear".into()));
map.set("projection", CapabilityValue::String("mercator".into()))
.expect("mercator is a published token");
assert_eq!(map.projection(), MapProjection::Mercator);
assert!(map.set("projection", CapabilityValue::String("globe".into())).is_err());
}
#[test]
fn cluster_badge_reports_its_world_centre() {
let mut map = MapView::new(Rect::new(0, 0, 400, 240));
map.set_markers(vec![
MapMarker::new("a", "A", 0.0, 0.0),
MapMarker::new("b", "B", 10.0, 10.0),
]);
map.set_cluster_markers(true);
let clusters = map.clusters();
assert_eq!(clusters.len(), 1, "nearby pins must form one bucket");
assert_eq!(clusters[0].indices.len(), 2);
let centres = Arc::new(Mutex::new(Vec::<(f32, f32)>::new()));
let sink = centres.clone();
map.cluster_clicked.connect(move |centre| {
if let Ok(mut guard) = sink.lock() {
guard.push(*centre);
}
});
let (sx, sy) = clusters[0].screen;
map.handle_event(&Event::mouse_press(sx as i32, sy as i32, 1));
let got = centres.lock().ok().map(|guard| guard.clone()).unwrap_or_default();
assert_eq!(got.len(), 1);
assert!((got[0].0 - 5.0).abs() < 0.01 && (got[0].1 - 5.0).abs() < 0.01);
}
#[test]
fn marker_style_size_widens_the_hit_box() {
let mut map = MapView::new(Rect::new(0, 0, 400, 240));
map.set_markers(vec![MapMarker::new("big", "Big", 0.0, 0.0)
.with_style(MarkerStyle::new(Color::rgb(1, 2, 3), 24))]);
assert!(map.hit_marker_index(Point::new(210, 120)).is_some());
assert!(map.set_marker_style("big", MarkerStyle::new(Color::rgb(1, 2, 3), 6)));
assert!(map.hit_marker_index(Point::new(210, 120)).is_none());
}
#[test]
fn tile_loader_validates_zoom_and_world_bounds() {
let mut loader = TileLoader::new(Rect::new(0, 0, 160, 24));
assert_eq!(TileLoader::new(Rect::new(0, 0, 1, 1)).max_zoom(), 19);
assert_eq!(loader.load_tile(25, 0, 0), Err(TileLoadError::ZoomOutOfRange));
assert_eq!(loader.load_tile(2, 4, 0), Err(TileLoadError::TileOutOfWorld));
assert_eq!(loader.load_tile(2, 0, 4), Err(TileLoadError::TileOutOfWorld));
assert!(loader.load_tile(2, 3, 3).is_ok());
loader.set_zoom_range(5, 3);
assert_eq!((loader.min_zoom(), loader.max_zoom()), (5, 5));
}
#[test]
fn tile_loader_formats_urls_and_reports_cache_hits() {
let mut loader = TileLoader::new(Rect::new(0, 0, 160, 24));
loader.set_url_template("https://example.test/{z}/{x}/{y}.png");
let first = loader.load_tile(2, 1, 3).expect("zoom 2 is in range");
assert_eq!(first.url, "https://example.test/2/1/3.png");
assert!(!first.cached);
let second = loader.load_tile(2, 1, 3).expect("repeat request");
assert!(second.cached, "a pending tile is not requested twice");
assert_eq!(loader.cache_len(), 1);
assert!(!loader.is_cached(second.key));
let loaded = Arc::new(Mutex::new(Vec::<TileKey>::new()));
let sink = loaded.clone();
loader.tile_loaded.connect(move |key| {
if let Ok(mut guard) = sink.lock() {
guard.push(*key);
}
});
assert!(loader.deliver_tile(second.key, vec![1, 2, 3]));
assert!(loader.is_cached(second.key));
assert_eq!(
loaded.lock().ok().map(|guard| guard.clone()).unwrap_or_default(),
vec![TileKey::new(2, 1, 3)]
);
assert!(!loader.deliver_tile(TileKey::new(2, 0, 0), vec![0]));
}
#[test]
fn tile_loader_reports_failures_and_clears_the_cache() {
let mut loader = TileLoader::new(Rect::new(0, 0, 160, 24));
let failed = Arc::new(Mutex::new(Vec::<TileKey>::new()));
let sink = failed.clone();
loader.tile_failed.connect(move |key| {
if let Ok(mut guard) = sink.lock() {
guard.push(*key);
}
});
let request = loader.load_tile(1, 0, 0).expect("zoom 1 is in range");
assert!(loader.reject_tile(request.key));
assert_eq!(
failed.lock().ok().map(|guard| guard.clone()).unwrap_or_default(),
vec![TileKey::new(1, 0, 0)]
);
assert_eq!(loader.cache_len(), 0);
assert!(!loader.reject_tile(request.key));
let _ = loader.load_tile(1, 1, 1);
loader.clear_cache();
assert_eq!(loader.cache_len(), 0);
}
#[test]
fn tile_loader_evicts_least_recently_used_beyond_the_limit() {
let mut loader = TileLoader::new(Rect::new(0, 0, 160, 24));
loader.set_cache_limit(2);
assert!(!loader.load_tile(2, 0, 0).expect("in range").cached);
assert!(!loader.load_tile(2, 1, 0).expect("in range").cached);
assert!(loader.load_tile(2, 0, 0).expect("in range").cached);
assert!(!loader.load_tile(2, 2, 0).expect("in range").cached);
assert_eq!(loader.cache_len(), 2);
assert!(
loader.load_tile(2, 0, 0).expect("in range").cached,
"the most recently used tile survived"
);
assert!(
!loader.load_tile(2, 1, 0).expect("in range").cached,
"the least recently used tile was evicted"
);
}
#[test]
fn tile_loader_prefetch_covers_the_viewport() {
let mut loader = TileLoader::new(Rect::new(0, 0, 160, 24));
loader.set_tile_size(256);
let requests = loader.prefetch(TileViewport::new(2, 0.5, 0.5, 256, 256));
assert_eq!(requests.len(), 4);
assert!(requests.iter().all(|request| request.key.zoom == 2));
let mut keys: Vec<(u32, u32)> =
requests.iter().map(|request| (request.key.x, request.key.y)).collect();
keys.sort_unstable();
assert_eq!(keys, vec![(0, 0), (0, 1), (1, 0), (1, 1)]);
assert!(loader.prefetch(TileViewport::new(2, 4.0, 4.0, 256, 256)).is_empty());
assert!(loader.prefetch(TileViewport::new(2, 0.0, 0.0, 0, 256)).is_empty());
}
#[test]
fn tile_loader_properties_round_trip() {
let mut loader = TileLoader::new(Rect::new(0, 0, 160, 24));
loader
.set("url_template", CapabilityValue::String("t/{z}/{x}/{y}".into()))
.expect("string template");
loader.set("tile_size", CapabilityValue::UInt(512)).expect("size");
loader.set("cache_limit", CapabilityValue::UInt(4)).expect("limit");
assert_eq!(
loader.get("url_template").unwrap(),
CapabilityValue::String("t/{z}/{x}/{y}".into())
);
assert_eq!(loader.get("tile_size").unwrap(), CapabilityValue::UInt(512));
assert_eq!(loader.get("cache_limit").unwrap(), CapabilityValue::UInt(4));
assert!(loader.set("cache_size", CapabilityValue::UInt(1)).is_err());
assert!(loader.command("set_tile_size").is_err(), "a command carries no payload");
assert!(loader.command("clear_cache").is_ok());
}
#[test]
fn zoom_control_steps_and_reports() {
let mut control = ZoomControl::new(Rect::new(0, 0, 96, 28));
let requested = Arc::new(Mutex::new(Vec::<f32>::new()));
let sink = requested.clone();
control.zoom_requested.connect(move |level| {
if let Ok(mut guard) = sink.lock() {
guard.push(*level);
}
});
assert!(control.zoom_in());
assert!((control.zoom_level() - 2.0).abs() < f32::EPSILON);
assert!(control.zoom_out());
assert!((control.zoom_level() - 1.0).abs() < f32::EPSILON);
assert!(control.set_zoom(4.0));
assert!(control.reset());
assert!((control.zoom_level() - 1.0).abs() < f32::EPSILON);
assert!(!control.reset(), "resetting an unchanged level is a no-op");
assert!(!control.set_zoom(f32::NAN), "non-finite levels are refused");
let got = requested.lock().ok().map(|guard| guard.clone()).unwrap_or_default();
assert_eq!(got, vec![2.0, 1.0, 4.0, 1.0]);
}
#[test]
fn zoom_control_clamps_level_to_its_range() {
let mut control = ZoomControl::new(Rect::new(0, 0, 96, 28));
control.set_zoom(100.0);
assert!((control.zoom_level() - 8.0).abs() < f32::EPSILON);
control.set_zoom(0.01);
assert!((control.zoom_level() - 0.25).abs() < f32::EPSILON);
control.set_min(1.0);
assert!((control.min() - 1.0).abs() < f32::EPSILON);
control.set_max(0.5);
assert!((control.max() - 1.0).abs() < f32::EPSILON, "the range never inverts");
assert!((control.zoom_level() - 1.0).abs() < f32::EPSILON);
}
#[test]
fn zoom_control_buttons_are_hit_and_do_their_jobs() {
let mut control = ZoomControl::new(Rect::new(0, 0, 96, 28));
assert!(matches!(control.hit_button(Point::new(5, 14)), Some(ZoomButton::Out)));
assert!(matches!(control.hit_button(Point::new(48, 14)), Some(ZoomButton::Reset)));
assert!(matches!(control.hit_button(Point::new(92, 14)), Some(ZoomButton::In)));
assert!(control.hit_button(Point::new(200, 14)).is_none());
control.handle_event(&Event::mouse_press(92, 14, 1));
assert!((control.zoom_level() - 2.0).abs() < f32::EPSILON);
control.handle_event(&Event::mouse_press(5, 14, 1));
assert!((control.zoom_level() - 1.0).abs() < f32::EPSILON);
}
#[test]
fn scale_bar_picks_a_round_distance() {
let mut bar = ScaleBar::new(Rect::new(0, 0, 400, 28));
bar.set_scale(10.0);
assert!((bar.bar_distance(384.0) - 2000.0).abs() < f64::EPSILON);
assert_eq!(bar.label(), "2 km");
assert_eq!(bar.bar_pixels(), 200);
bar.set_units(ScaleUnits::Imperial);
assert!(bar.label().ends_with("ft") || bar.label().ends_with("mi"));
bar.set_scale(0.0);
assert!((bar.meters_per_pixel() - 10.0).abs() < f64::EPSILON, "invalid scales are ignored");
bar.set_scale(f64::NAN);
assert!((bar.meters_per_pixel() - 10.0).abs() < f64::EPSILON);
}
#[test]
fn scale_bar_properties_round_trip() {
let mut bar = ScaleBar::new(Rect::new(0, 0, 200, 28));
bar.set("meters_per_pixel", CapabilityValue::Float(2.5)).expect("scale");
bar.set("units", CapabilityValue::String("imperial".into())).expect("units");
bar.set("style", CapabilityValue::String("boxed".into())).expect("style");
assert_eq!(bar.units(), ScaleUnits::Imperial);
assert_eq!(bar.style(), ScaleBarStyle::Boxed);
assert!((bar.meters_per_pixel() - 2.5).abs() < f64::EPSILON);
assert!(bar.set("units", CapabilityValue::String("nautical".into())).is_err());
}
#[test]
fn compass_emits_a_reset_request_only_when_clickable() {
let mut compass = Compass::new(Rect::new(0, 0, 48, 48));
let resets = Arc::new(Mutex::new(0usize));
let sink = resets.clone();
compass.reset_north_requested.connect(move || {
if let Ok(mut guard) = sink.lock() {
*guard += 1;
}
});
compass.handle_event(&Event::mouse_press(24, 24, 1));
assert_eq!(resets.lock().map(|guard| *guard).unwrap_or(0), 1);
assert!((compass.bearing_deg() - 0.0).abs() < f32::EPSILON, "the host owns the bearing");
compass.set_clickable(false);
compass.handle_event(&Event::mouse_press(24, 24, 1));
assert_eq!(resets.lock().map(|guard| *guard).unwrap_or(0), 1);
compass.set_clickable(true);
compass.handle_event(&Event::mouse_press(-50, -50, 1));
assert_eq!(resets.lock().map(|guard| *guard).unwrap_or(0), 1, "outside the dial");
}
#[test]
fn compass_normalises_its_bearing() {
let mut compass = Compass::new(Rect::new(0, 0, 48, 48));
compass.set_bearing(-90.0);
assert!((compass.bearing_deg() - 270.0).abs() < 0.001);
compass.set_bearing(450.0);
assert!((compass.bearing_deg() - 90.0).abs() < 0.001);
compass.set_bearing(f32::NAN);
assert!((compass.bearing_deg() - 90.0).abs() < 0.001);
compass.set("bearing_deg", CapabilityValue::Float(180.0)).expect("bearing property");
assert!((compass.bearing_deg() - 180.0).abs() < 0.001);
}
#[test]
fn map_controls_are_mounted_and_drive_the_map() {
let mut map = MapView::new(Rect::new(0, 0, 400, 300));
assert!(map.map_controls_visible());
let before = map.zoom();
map.handle_event(&Event::mouse_press(370, 12, 1));
assert!(map.zoom() > before, "the + cell zooms the map");
assert!((map.zoom_control().zoom_level() - map.zoom()).abs() < 0.0001);
map.compass_mut().set_bearing(90.0);
let dial = map.compass().base().geometry();
map.handle_event(&Event::mouse_press(
dial.x + dial.width as i32 / 2,
dial.y + dial.height as i32 / 2,
1,
));
assert_eq!(map.compass().bearing_deg(), 0.0, "the needle returns to north");
assert!(map.scale_bar().meters_per_pixel() > 0.0);
}
#[test]
fn map_prefetches_tiles_and_accepts_host_delivery() {
let mut map = MapView::new(Rect::new(0, 0, 400, 300));
let requests = map.prefetch_tiles();
assert!(!requests.is_empty(), "the viewport covers at least one tile");
assert!(requests.iter().all(|request| !request.url.is_empty()));
let first = requests[0].clone();
assert!(map.deliver_tile(first.key, vec![1, 2, 3]));
assert!(map.tile_loader().is_cached(first.key));
assert!(!map.reject_tile(TileKey::new(19, 0, 0)), "an unrequested tile is refused");
map.set_map_controls_visible(false);
assert!(!map.map_controls_visible());
}
#[test]
fn markers_round_trip_through_the_property() {
use crate::widget::capability::{RecordList, RecordValue, EMPTY_RECORD_SCHEMA};
let mut map = MapView::new(Rect::new(0, 0, 320, 240));
let list = RecordList::from_items(
&EMPTY_RECORD_SCHEMA,
vec![
RecordValue::new()
.with("id", CapabilityValue::String(String::from("a")))
.with("label", CapabilityValue::String(String::from("Alpha")))
.with("x", CapabilityValue::Float(12.5))
.with("y", CapabilityValue::Float(-3.0))
.with("size", CapabilityValue::UInt(18)),
RecordValue::new()
.with("id", CapabilityValue::String(String::from("b")))
.with("label", CapabilityValue::String(String::from("Beta")))
.with("x", CapabilityValue::Float(1.0))
.with("y", CapabilityValue::Float(2.0))
.with("color", CapabilityValue::Color(Color::rgb(10, 20, 30))),
],
);
map.set("markers", CapabilityValue::Records(list)).expect("well-formed markers");
assert_eq!(map.markers().len(), 2);
assert_eq!(map.markers()[0].label, "Alpha");
assert_eq!(map.markers()[0].style.size, 18);
assert_eq!(map.markers()[1].style.color, Color::rgb(10, 20, 30));
let CapabilityValue::Records(read_back) = map.get("markers").expect("reads back") else {
panic!("`markers` must read as a record list");
};
assert_eq!(read_back.len(), 2);
assert!(matches!(
read_back.items()[0].get("x"),
Some(CapabilityValue::Float(x)) if (*x - 12.5).abs() < f64::EPSILON
));
let broken = RecordList::from_items(
&EMPTY_RECORD_SCHEMA,
vec![RecordValue::new().with("id", CapabilityValue::String(String::from("a")))],
);
assert!(matches!(
map.set("markers", CapabilityValue::Records(broken)),
Err(CapabilityAccessError::ConstraintViolation { .. })
));
assert_eq!(map.markers().len(), 2, "a refused write changes nothing");
}
}