use alloc::collections::btree_map::BTreeMap;
use azul_core::callbacks::{
VirtualViewCallback, VirtualViewCallbackInfo, VirtualViewReturn,
};
use azul_core::dom::{DatasetMergeCallbackType, Dom, OptionDom};
use azul_core::refany::{OptionRefAny, RefAny};
use azul_css::dynamic_selector::CssPropertyWithConditionsVec;
use azul_css::impl_option_inner; use azul_css::AzString;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[repr(C)]
pub struct MapTileId {
pub z: u8,
pub x: u32,
pub y: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[repr(C)]
pub struct MapTileLayer {
pub url_template: AzString,
pub min_zoom: u8,
pub max_zoom: u8,
pub attribution: AzString,
pub style_css: AzString,
}
impl Default for MapTileLayer {
fn default() -> Self {
Self {
url_template: AzString::from(
"https://tiles.openfreemap.org/planet/20260531_080002_pt/{z}/{x}/{y}.pbf",
),
min_zoom: 0,
max_zoom: 14,
attribution: AzString::from(
"© OpenFreeMap © OpenMapTiles · Data © OpenStreetMap contributors",
),
style_css: AzString::from(""),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[repr(C)]
pub struct MapViewport {
pub centre_lat_deg: f64,
pub centre_lon_deg: f64,
pub zoom: f32,
pub bearing_deg: f32,
pub pitch_deg: f32,
}
impl Default for MapViewport {
fn default() -> Self {
Self {
centre_lat_deg: 0.0,
centre_lon_deg: 0.0,
zoom: 2.0,
bearing_deg: 0.0,
pitch_deg: 0.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[repr(C)]
pub struct MapLatLon {
pub lat_deg: f64,
pub lon_deg: f64,
}
#[derive(Debug, Clone, PartialEq)]
#[repr(C)]
pub struct MapWidget {
pub layer: MapTileLayer,
pub viewport: MapViewport,
pub container_style: CssPropertyWithConditionsVec,
pub on_viewport_changed: OptionMapViewportChanged,
pub on_pin_tap: OptionMapPinTap,
}
impl MapWidget {
#[must_use] pub fn create(layer: MapTileLayer) -> Self {
Self {
layer,
viewport: MapViewport::default(),
container_style: CssPropertyWithConditionsVec::from_const_slice(&[]),
on_viewport_changed: OptionMapViewportChanged::None,
on_pin_tap: OptionMapPinTap::None,
}
}
#[must_use] pub const fn with_viewport(mut self, viewport: MapViewport) -> Self {
self.viewport = viewport;
self
}
#[must_use] pub fn with_container_style(mut self, css: CssPropertyWithConditionsVec) -> Self {
self.container_style = css;
self
}
pub fn set_on_viewport_changed<C: Into<MapViewportChangedCallback>>(
&mut self,
data: RefAny,
callback: C,
) {
self.on_viewport_changed = Some(MapViewportChanged {
refany: data,
callback: callback.into(),
})
.into();
}
#[must_use]
pub fn with_on_viewport_changed<C: Into<MapViewportChangedCallback>>(
mut self,
data: RefAny,
callback: C,
) -> Self {
self.set_on_viewport_changed(data, callback);
self
}
pub fn set_on_pin_tap<C: Into<MapPinTapCallback>>(&mut self, data: RefAny, callback: C) {
self.on_pin_tap = Some(MapPinTap {
refany: data,
callback: callback.into(),
})
.into();
}
#[must_use]
pub fn with_on_pin_tap<C: Into<MapPinTapCallback>>(
mut self,
data: RefAny,
callback: C,
) -> Self {
self.set_on_pin_tap(data, callback);
self
}
#[allow(clippy::suboptimal_flops)] #[must_use] pub fn latlon_at_px(
viewport: MapViewport,
px: azul_core::geom::LogicalPosition,
container: azul_core::geom::LogicalSize,
) -> MapLatLon {
let world = 256.0_f64 * 2.0_f64.powf(f64::from(viewport.zoom));
let dx = f64::from(px.x - container.width * 0.5);
let dy = f64::from(px.y - container.height * 0.5);
let lon = (viewport.centre_lon_deg + dx * 360.0 / world).clamp(-180.0, 180.0);
let cos_lat = viewport.centre_lat_deg.to_radians().cos();
let lat = (viewport.centre_lat_deg - dy * 360.0 / world * cos_lat).clamp(-85.0, 85.0);
MapLatLon {
lat_deg: lat,
lon_deg: lon,
}
}
#[allow(clippy::suboptimal_flops)] #[allow(clippy::cast_possible_truncation)] #[must_use] pub fn px_at_latlon(
viewport: MapViewport,
coord: MapLatLon,
container: azul_core::geom::LogicalSize,
) -> azul_core::geom::LogicalPosition {
let world = 256.0_f64 * 2.0_f64.powf(f64::from(viewport.zoom));
let cos_lat = viewport.centre_lat_deg.to_radians().cos();
let px = f64::from(container.width) * 0.5
+ (coord.lon_deg - viewport.centre_lon_deg) * world / 360.0;
let py = f64::from(container.height) * 0.5
- (coord.lat_deg - viewport.centre_lat_deg) * world / (360.0 * cos_lat);
azul_core::geom::LogicalPosition::new(px as f32, py as f32)
}
#[must_use] pub fn dom(self) -> Dom {
self.build_dom(None)
}
#[must_use] pub fn dom_with_fetch(self, cb: crate::thread::ThreadCallback) -> Dom {
self.build_dom(Some(cb))
}
fn build_dom(self, fetch_cb: Option<crate::thread::ThreadCallback>) -> Dom {
use azul_core::dom::{ComponentEventFilter, EventFilter, HoverEventFilter};
let mut cache = MapTileCache::new(self.layer.clone(), self.viewport);
cache.fetch_callback = fetch_cb;
cache.on_viewport_changed = self.on_viewport_changed;
cache.on_pin_tap = self.on_pin_tap;
let dataset = RefAny::new(cache);
let virtual_view_data = dataset.clone();
let root = Dom::create_div()
.with_css("position: absolute; top: 0; left: 0; right: 0; bottom: 0; overflow: hidden;")
.with_dataset(OptionRefAny::Some(dataset.clone()))
.with_merge_callback(azul_core::dom::DatasetMergeCallback::from_ptr(merge_map_tile_cache))
.with_callback(
EventFilter::Component(ComponentEventFilter::AfterMount),
dataset.clone(),
crate::callbacks::Callback::from_ptr(map_on_after_mount),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::MouseDown),
dataset.clone(),
crate::callbacks::Callback::from_ptr(map_on_pointer_down),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::MouseOver),
dataset.clone(),
crate::callbacks::Callback::from_ptr(map_on_pointer_move),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::MouseUp),
dataset.clone(),
crate::callbacks::Callback::from_ptr(map_on_pointer_up),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::MouseLeave),
dataset.clone(),
crate::callbacks::Callback::from_ptr(map_on_pointer_up),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::TouchStart),
dataset.clone(),
crate::callbacks::Callback::from_ptr(map_on_pointer_down),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::TouchMove),
dataset.clone(),
crate::callbacks::Callback::from_ptr(map_on_pointer_move),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::TouchEnd),
dataset.clone(),
crate::callbacks::Callback::from_ptr(map_on_pointer_up),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::TouchCancel),
dataset.clone(),
crate::callbacks::Callback::from_ptr(map_on_pointer_up),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::PinchIn),
dataset.clone(),
crate::callbacks::Callback::from_ptr(map_on_pointer_move),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::PinchOut),
dataset,
crate::callbacks::Callback::from_ptr(map_on_pointer_move),
)
.with_child(
Dom::create_virtual_view(
virtual_view_data,
azul_core::callbacks::VirtualViewCallback::create(map_widget_render),
)
.with_css("width: 100%; height: 100%; overflow: hidden;"),
);
if self.container_style.as_slice().is_empty() {
root
} else {
root.with_css_props(self.container_style)
}
}
}
#[derive(Debug)]
pub struct MapTileCache {
pub layer: MapTileLayer,
pub viewport: MapViewport,
pub tiles: BTreeMap<MapTileId, TileEntry>,
pub fetch_callback: Option<crate::thread::ThreadCallback>,
pub drag_anchor: Option<azul_core::geom::LogicalPosition>,
pub pinch_anchor: Option<f32>,
pub on_viewport_changed: OptionMapViewportChanged,
pub press_origin: Option<azul_core::geom::LogicalPosition>,
pub on_pin_tap: OptionMapPinTap,
}
impl MapTileCache {
#[must_use] pub const fn new(layer: MapTileLayer, viewport: MapViewport) -> Self {
Self {
layer,
viewport,
tiles: BTreeMap::new(),
fetch_callback: None,
drag_anchor: None,
pinch_anchor: None,
press_origin: None,
on_viewport_changed: OptionMapViewportChanged::None,
on_pin_tap: OptionMapPinTap::None,
}
}
pub fn mark_tile_ready(&mut self, tile: MapTileId, svg: AzString) {
self.tiles.insert(tile, TileEntry::Ready { svg });
}
pub fn mark_tile_failed(&mut self, tile: MapTileId, error: AzString) {
self.tiles.insert(tile, TileEntry::Failed { error });
}
#[allow(clippy::suboptimal_flops)] #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] pub fn prune_distant_tiles(&mut self) {
const MAX_CACHED_TILES: usize = 192;
if self.tiles.len() <= MAX_CACHED_TILES {
return;
}
let z = (self.viewport.zoom.floor() as i32)
.clamp(i32::from(self.layer.min_zoom), i32::from(self.layer.max_zoom))
as u8;
let tile_count = 1u32 << u32::from(z);
let cx = lon_to_tile_x(self.viewport.centre_lon_deg, f64::from(tile_count));
let cy = lat_to_tile_y(self.viewport.centre_lat_deg, f64::from(tile_count));
let score = |id: &MapTileId| -> f64 {
let zt_count = 1u32 << u32::from(id.z);
let scale = f64::from(tile_count) / f64::from(zt_count);
let tx = (f64::from(id.x) + 0.5) * scale;
let ty = (f64::from(id.y) + 0.5) * scale;
let dz = f64::from((i32::from(id.z) - i32::from(z)).abs());
let dx = tx - cx;
let dy = ty - cy;
dz * 10_000.0 + dx * dx + dy * dy
};
let mut evictable: Vec<(f64, MapTileId)> = self
.tiles
.iter()
.filter(|(_, e)| !matches!(e, TileEntry::Pending | TileEntry::Fetching))
.map(|(id, _)| (score(id), *id))
.collect();
evictable.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(core::cmp::Ordering::Equal));
let mut to_remove = self.tiles.len().saturating_sub(MAX_CACHED_TILES);
for (_, id) in evictable {
if to_remove == 0 {
break;
}
self.tiles.remove(&id);
to_remove -= 1;
}
}
}
#[derive(Debug, Clone)]
pub enum TileEntry {
Pending,
Fetching,
Ready { svg: AzString },
Failed { error: AzString },
}
#[derive(Debug, Clone)]
pub struct TileFetchInit {
pub tile: MapTileId,
pub url: AzString,
pub style_css: AzString,
}
#[derive(Debug, Clone)]
pub struct TileReadyMsg {
pub tile: MapTileId,
pub svg: AzString,
pub error: AzString,
}
extern "C" fn merge_map_tile_cache(mut new_data: RefAny, mut old_data: RefAny) -> RefAny {
{
let new_g = new_data.downcast_ref::<MapTileCache>();
let old_guard = old_data.downcast_mut::<MapTileCache>();
if let (Some(new_g), Some(mut old_g)) = (new_g, old_guard) {
if old_g.fetch_callback.is_none() {
old_g.fetch_callback.clone_from(&new_g.fetch_callback);
}
old_g.viewport = new_g.viewport;
old_g.layer = new_g.layer.clone();
old_g.on_viewport_changed = new_g.on_viewport_changed.clone();
}
}
old_data
}
use crate::callbacks::CallbackInfo;
use azul_core::callbacks::Update;
use azul_core::callbacks::TimerCallbackReturn;
use azul_core::task::{Duration, SystemTimeDiff, TerminateTimer, TimerId};
use crate::timer::{Timer, TimerCallback, TimerCallbackInfo};
pub type MapViewportChangedCallbackType =
extern "C" fn(RefAny, CallbackInfo, MapViewport) -> Update;
impl_widget_callback!(
MapViewportChanged,
OptionMapViewportChanged,
MapViewportChangedCallback,
MapViewportChangedCallbackType
);
azul_core::impl_managed_callback! {
wrapper: MapViewportChangedCallback,
info_ty: CallbackInfo,
return_ty: Update,
default_ret: Update::DoNothing,
invoker_static: MAP_VIEWPORT_CHANGED_INVOKER,
invoker_ty: AzMapViewportChangedCallbackInvoker,
thunk_fn: az_map_viewport_changed_callback_thunk,
setter_fn: AzApp_setMapViewportChangedCallbackInvoker,
from_handle_fn: AzMapViewportChangedCallback_createFromHostHandle,
extra_args: [ viewport: MapViewport ],
}
fn invoke_viewport_changed(
hook: &OptionMapViewportChanged,
info: &CallbackInfo,
viewport: MapViewport,
) -> Update {
match hook {
OptionMapViewportChanged::Some(h) => {
(h.callback.cb)(h.refany.clone(), *info, viewport)
}
OptionMapViewportChanged::None => Update::DoNothing,
}
}
pub type MapPinTapCallbackType = extern "C" fn(RefAny, CallbackInfo, MapLatLon) -> Update;
impl_widget_callback!(
MapPinTap,
OptionMapPinTap,
MapPinTapCallback,
MapPinTapCallbackType
);
azul_core::impl_managed_callback! {
wrapper: MapPinTapCallback,
info_ty: CallbackInfo,
return_ty: Update,
default_ret: Update::DoNothing,
invoker_static: MAP_PIN_TAP_INVOKER,
invoker_ty: AzMapPinTapCallbackInvoker,
thunk_fn: az_map_pin_tap_callback_thunk,
setter_fn: AzApp_setMapPinTapCallbackInvoker,
from_handle_fn: AzMapPinTapCallback_createFromHostHandle,
extra_args: [ coord: MapLatLon ],
}
fn invoke_pin_tap(hook: &OptionMapPinTap, info: &CallbackInfo, coord: MapLatLon) -> Update {
match hook {
OptionMapPinTap::Some(h) => (h.callback.cb)(h.refany.clone(), *info, coord),
OptionMapPinTap::None => Update::DoNothing,
}
}
extern "C" fn map_on_pointer_down(mut data: RefAny, info: CallbackInfo) -> Update {
#[cfg(feature = "std")]
if std::env::var("AZ_MAP_DEBUG").is_ok() {
eprintln!("[map] pointer_down fired");
}
let pos = match info.get_cursor_relative_to_node().into_option() {
Some(p) => azul_core::geom::LogicalPosition::new(p.x, p.y),
None => return Update::DoNothing,
};
if let Some(mut cache) = data.downcast_mut::<MapTileCache>() {
cache.drag_anchor = Some(pos);
cache.press_origin = Some(pos);
}
Update::DoNothing
}
#[allow(clippy::similar_names)] extern "C" fn map_on_pointer_move(mut data: RefAny, mut info: CallbackInfo) -> Update {
#[cfg(feature = "std")]
if std::env::var("AZ_MAP_DEBUG").is_ok() {
let dragging = data
.downcast_ref::<MapTileCache>()
.is_some_and(|c| c.drag_anchor.is_some());
eprintln!("[map] pointer_move fired (dragging={dragging})");
}
if let Some(pinch) = info.get_pinch().into_option() {
let Some(mut cache) = data.downcast_mut::<MapTileCache>() else {
return Update::DoNothing;
};
let anchor = *cache.pinch_anchor.get_or_insert(pinch.current_distance);
if anchor > 1.0 && pinch.current_distance > 1.0 {
let dz = (pinch.current_distance / anchor).log2();
let min = f32::from(cache.layer.min_zoom);
let max = f32::from(cache.layer.max_zoom);
cache.viewport.zoom = (cache.viewport.zoom + dz).clamp(min, max);
}
cache.pinch_anchor = Some(pinch.current_distance);
cache.drag_anchor = None;
let hook = cache.on_viewport_changed.clone();
let vp = cache.viewport;
drop(cache);
invoke_viewport_changed(&hook, &info, vp);
info.trigger_all_virtual_view_rerender();
return Update::DoNothing;
}
let pos = match info.get_cursor_relative_to_node().into_option() {
Some(p) => azul_core::geom::LogicalPosition::new(p.x, p.y),
None => return Update::DoNothing,
};
let Some(mut cache_guard) = data.downcast_mut::<MapTileCache>() else {
return Update::DoNothing;
};
let Some(anchor) = cache_guard.drag_anchor else {
return Update::DoNothing; };
let dx_px = f64::from(pos.x - anchor.x);
let dy_px = f64::from(pos.y - anchor.y);
if dx_px.abs() < 0.5 && dy_px.abs() < 0.5 {
return Update::DoNothing;
}
let (new_lon, new_lat) = pan_viewport(
cache_guard.viewport.centre_lat_deg,
cache_guard.viewport.centre_lon_deg,
f64::from(cache_guard.viewport.zoom),
dx_px,
dy_px,
);
cache_guard.viewport.centre_lon_deg = new_lon;
cache_guard.viewport.centre_lat_deg = new_lat;
cache_guard.drag_anchor = Some(pos);
let hook = cache_guard.on_viewport_changed.clone();
let vp = cache_guard.viewport;
drop(cache_guard);
invoke_viewport_changed(&hook, &info, vp);
info.trigger_all_virtual_view_rerender();
Update::DoNothing
}
#[allow(clippy::suboptimal_flops)] extern "C" fn map_on_pointer_up(mut data: RefAny, mut info: CallbackInfo) -> Update {
let up_pos = info
.get_cursor_relative_to_node()
.into_option()
.map(|p| azul_core::geom::LogicalPosition::new(p.x, p.y));
let container = info
.get_hit_node_rect()
.map_or(azul_core::geom::LogicalSize::new(0.0, 0.0), |r| r.size);
let (press, viewport, hook) = data.downcast_mut::<MapTileCache>().map_or_else(|| (None, MapViewport::default(), OptionMapPinTap::None), |mut cache| {
let out = (cache.press_origin, cache.viewport, cache.on_pin_tap.clone());
cache.drag_anchor = None;
cache.pinch_anchor = None;
cache.press_origin = None;
out
});
if let (Some(origin), Some(up)) = (press, up_pos) {
let dx = f64::from(up.x - origin.x);
let dy = f64::from(up.y - origin.y);
if dx * dx + dy * dy < 36.0 {
let coord = MapWidget::latlon_at_px(viewport, up, container);
invoke_pin_tap(&hook, &info, coord);
}
}
spawn_pending_tile_fetches(&mut data, &mut info);
info.trigger_all_virtual_view_rerender();
Update::DoNothing
}
extern "C" fn map_on_scroll(mut data: RefAny, mut info: CallbackInfo) -> Update {
let dy: f32 = {
let hn = info.get_hit_node();
hn.node.into_crate_internal().map_or(0.0, |nid| info.get_scroll_delta(hn.dom, nid).map_or(0.0, |d| d.y))
};
#[cfg(feature = "std")]
if std::env::var("AZ_MAP_DEBUG").is_ok() {
eprintln!("[map] scroll fired dy={dy}");
}
if dy == 0.0 {
return Update::DoNothing;
}
let bounds = info
.get_hit_node_rect()
.map_or(azul_core::geom::LogicalSize::new(0.0, 0.0), |r| r.size);
let (vp, hook) = {
let Some(mut cache) = data.downcast_mut::<MapTileCache>() else {
return Update::DoNothing;
};
let min = f32::from(cache.layer.min_zoom);
let max = f32::from(cache.layer.max_zoom);
let dz = dy.signum() * 0.5;
cache.viewport.zoom = (cache.viewport.zoom + dz).clamp(min, max);
let vp = cache.viewport;
let layer = cache.layer.clone();
for t in map_visible_tiles(&vp, bounds, &layer) {
cache.tiles.entry(t).or_insert(TileEntry::Pending);
}
(vp, cache.on_viewport_changed.clone())
};
invoke_viewport_changed(&hook, &info, vp);
spawn_pending_tile_fetches(&mut data, &mut info);
info.trigger_all_virtual_view_rerender();
Update::DoNothing
}
fn wrap_lon(lon: f64) -> f64 {
(lon + 180.0).rem_euclid(360.0) - 180.0
}
fn lon_to_tile_x(lon_deg: f64, tile_count: f64) -> f64 {
(lon_deg + 180.0) / 360.0 * tile_count
}
fn lat_to_tile_y(lat_deg: f64, tile_count: f64) -> f64 {
let lat_rad = lat_deg.to_radians();
let mercator =
(1.0 - (lat_rad.tan() + 1.0 / lat_rad.cos()).ln() / core::f64::consts::PI) / 2.0;
mercator * tile_count
}
#[allow(dead_code)]
#[allow(clippy::suboptimal_flops)] fn tile_x_to_lon(x: f64, tile_count: f64) -> f64 {
x / tile_count * 360.0 - 180.0
}
#[allow(dead_code)]
fn tile_y_to_lat(y: f64, tile_count: f64) -> f64 {
let n = core::f64::consts::PI * (1.0 - 2.0 * y / tile_count);
n.sinh().atan().to_degrees()
}
#[allow(clippy::suboptimal_flops)] #[allow(clippy::similar_names)] fn pan_viewport(
centre_lat_deg: f64,
centre_lon_deg: f64,
zoom: f64,
dx_px: f64,
dy_px: f64,
) -> (f64, f64) {
let world_px = 256.0 * (2.0_f64).powf(zoom);
let d_lon = -dx_px * 360.0 / world_px;
let d_lat = dy_px * 360.0 / world_px * centre_lat_deg.to_radians().cos();
let new_lon = wrap_lon(centre_lon_deg + d_lon);
let new_lat = (centre_lat_deg + d_lat).clamp(-85.0, 85.0);
(new_lon, new_lat)
}
#[cfg(all(feature = "xml", feature = "cpurender"))]
#[must_use] pub fn svg_string_to_dom(svg: &str) -> Option<Dom> {
let img = crate::cpurender::render_svg_to_imageref(svg.as_bytes(), 256, 256).ok()?;
Some(
Dom::create_image(img)
.with_css("position: absolute; left: 0; top: 0; width: 100%; height: 100%;"),
)
}
#[cfg(all(feature = "xml", not(feature = "cpurender")))]
pub fn svg_string_to_dom(svg: &str) -> Option<Dom> {
use azul_core::xml::{str_to_dom_unstyled, ComponentMap};
let wrapped = alloc::format!("<html><body>{}</body></html>", svg);
let nodes = crate::xml::parse_xml_string(&wrapped).ok()?;
let component_map = ComponentMap::default();
str_to_dom_unstyled(nodes.as_ref(), &component_map).ok()
}
#[cfg(not(feature = "xml"))]
fn svg_string_to_dom(_svg: &str) -> Option<Dom> {
None
}
extern "C" fn map_on_after_mount(mut data: RefAny, mut info: CallbackInfo) -> Update {
#[cfg(feature = "std")]
if std::env::var("AZ_MAP_DEBUG").is_ok() {
eprintln!("[map] after_mount fired");
}
spawn_pending_tile_fetches(&mut data, &mut info);
let sweep = Timer::create(
data.clone(),
TimerCallback::create(map_fetch_sweep_tick),
info.get_system_time_fn(),
)
.with_interval(Duration::System(SystemTimeDiff::from_millis(250)));
info.add_timer(TimerId::unique(), sweep);
info.trigger_all_virtual_view_rerender();
Update::DoNothing
}
fn spawn_pending_tile_fetches(data: &mut RefAny, info: &mut CallbackInfo) {
use crate::thread::Thread;
use azul_core::task::ThreadId;
const MAX_SPAWN_PER_CALL: usize = 16;
let mut to_spawn: Vec<TileFetchInit> = Vec::new();
{
let Some(mut cache) = data.downcast_mut::<MapTileCache>() else {
return;
};
if cache.fetch_callback.is_none() {
return; }
let template = cache.layer.url_template.as_str().to_string();
let style_css = cache.layer.style_css.clone();
let pending: Vec<MapTileId> = cache
.tiles
.iter()
.filter(|(_, e)| matches!(e, TileEntry::Pending))
.map(|(id, _)| *id)
.take(MAX_SPAWN_PER_CALL)
.collect();
for tile in pending {
let url = build_tile_url(&template, tile);
cache.tiles.insert(tile, TileEntry::Fetching);
to_spawn.push(TileFetchInit {
tile,
url: AzString::from(url),
style_css: style_css.clone(),
});
}
cache.prune_distant_tiles();
}
let cb = {
let Some(cache) = data.downcast_ref::<MapTileCache>() else {
return;
};
match cache.fetch_callback.as_ref() {
Some(cb) => cb.clone(),
None => return,
}
};
#[cfg(feature = "std")]
let spawn_count = to_spawn.len();
for init in to_spawn {
let init_data = RefAny::new(init);
let writeback_data = data.clone(); let thread = Thread::create(init_data, writeback_data, cb.clone());
info.add_thread(ThreadId::unique(), thread);
}
#[cfg(feature = "std")]
if std::env::var("AZ_MAP_DEBUG").is_ok() {
eprintln!("[map] spawn_pending: {spawn_count} thread(s) spawned");
}
}
extern "C" fn map_fetch_sweep_tick(
mut data: RefAny,
mut info: TimerCallbackInfo,
) -> TimerCallbackReturn {
spawn_pending_tile_fetches(&mut data, &mut info.callback_info);
TimerCallbackReturn {
should_update: Update::DoNothing,
should_terminate: TerminateTimer::Continue,
}
}
fn build_tile_url(template: &str, tile: MapTileId) -> String {
use alloc::string::ToString;
template
.replace("{z}", &tile.z.to_string())
.replace("{x}", &tile.x.to_string())
.replace("{y}", &tile.y.to_string())
}
#[must_use] pub extern "C" fn map_tile_writeback(
mut cache_dataset: RefAny,
mut incoming: RefAny,
mut info: CallbackInfo,
) -> Update {
let msg = match incoming.downcast_ref::<TileReadyMsg>() {
Some(m) => (m.tile, m.svg.clone(), m.error.clone()),
None => return Update::DoNothing,
};
{
let Some(mut cache) = cache_dataset.downcast_mut::<MapTileCache>() else {
return Update::DoNothing;
};
#[cfg(feature = "std")]
if std::env::var("AZ_MAP_DEBUG").is_ok() {
eprintln!(
"[map] writeback tile=({},{},{}) ok={} svg_len={} err={:?}",
msg.0.z, msg.0.x, msg.0.y,
msg.2.as_str().is_empty(), msg.1.as_str().len(), msg.2.as_str()
);
}
if msg.2.as_str().is_empty() {
cache.mark_tile_ready(msg.0, msg.1);
} else {
cache.mark_tile_failed(msg.0, msg.2);
}
}
info.trigger_all_virtual_view_rerender();
Update::DoNothing
}
#[allow(clippy::suboptimal_flops)] #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] fn visible_tile_range(
centre_x: f32,
centre_y: f32,
width_px: f32,
height_px: f32,
zoom_scale: f32,
tile_count: u32,
) -> (i32, i32, i32, i32) {
let tile_px = 256.0 * zoom_scale;
let half_w = (width_px / tile_px).abs() * 0.5 + 1.0;
let half_h = (height_px / tile_px).abs() * 0.5 + 1.0;
let max_idx = tile_count as i32 - 1;
let x_min = (centre_x - half_w).floor() as i32;
let x_max = (centre_x + half_w).ceil() as i32;
let y_min = ((centre_y - half_h).floor() as i32).max(0);
let y_max = ((centre_y + half_h).ceil() as i32).min(max_idx);
(x_min, x_max, y_min, y_max)
}
#[allow(clippy::cast_possible_wrap)] fn wrap_tile_x(x: i32, tile_count: u32) -> u32 {
x.rem_euclid(tile_count.max(1) as i32) as u32
}
#[allow(clippy::suboptimal_flops)] #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] fn map_visible_tiles(
viewport: &MapViewport,
bounds: azul_core::geom::LogicalSize,
layer: &MapTileLayer,
) -> Vec<MapTileId> {
let z_int =
(viewport.zoom.floor() as i32).clamp(i32::from(layer.min_zoom), i32::from(layer.max_zoom)) as u8;
let tile_count = 1u32 << u32::from(z_int);
let frac_zoom = viewport.zoom - f32::from(z_int);
let zoom_scale = 2.0_f32.powf(frac_zoom);
let centre_x = lon_to_tile_x(viewport.centre_lon_deg, f64::from(tile_count)) as f32;
let centre_y = lat_to_tile_y(viewport.centre_lat_deg, f64::from(tile_count)) as f32;
let (x_min, x_max, y_min, y_max) =
visible_tile_range(centre_x, centre_y, bounds.width, bounds.height, zoom_scale, tile_count);
let mut tiles = Vec::new();
for x in x_min..=x_max {
for y in y_min..=y_max {
tiles.push(MapTileId { z: z_int, x: wrap_tile_x(x, tile_count), y: y as u32 });
}
}
tiles
}
#[allow(clippy::suboptimal_flops)] #[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)] #[allow(clippy::too_many_lines)] extern "C" fn map_widget_render(
data: RefAny,
info: VirtualViewCallbackInfo,
) -> VirtualViewReturn {
enum TileDisplay {
Glyph(&'static str),
Svg(AzString),
}
let mut data = data;
let bounds = info.get_bounds();
let bounds_logical = bounds.get_logical_size();
let width_px = bounds_logical.width;
let height_px = bounds_logical.height;
if !width_px.is_finite() || !height_px.is_finite() || width_px <= 0.0 || height_px <= 0.0 {
if std::env::var("AZ_MAP_DEBUG").is_ok() {
eprintln!("[map] non-finite bounds {width_px}x{height_px} — skipping render");
}
return VirtualViewReturn {
dom: OptionDom::None,
scroll_size: bounds_logical,
scroll_offset: azul_core::geom::LogicalPosition::zero(),
virtual_scroll_size: bounds_logical,
virtual_scroll_offset: azul_core::geom::LogicalPosition::zero(),
};
}
let (layer, viewport) = match data.downcast_ref::<MapTileCache>() {
Some(c) => (c.layer.clone(), c.viewport),
None => {
return VirtualViewReturn {
dom: OptionDom::None,
scroll_size: bounds_logical,
scroll_offset: azul_core::geom::LogicalPosition::zero(),
virtual_scroll_size: bounds_logical,
virtual_scroll_offset: azul_core::geom::LogicalPosition::zero(),
};
}
};
let z_int = (viewport.zoom.floor() as i32)
.clamp(i32::from(layer.min_zoom), i32::from(layer.max_zoom))
as u8;
let tile_count = 1u32 << u32::from(z_int);
let frac_zoom = viewport.zoom - f32::from(z_int);
let zoom_scale = 2.0_f32.powf(frac_zoom);
let centre_x = lon_to_tile_x(viewport.centre_lon_deg, f64::from(tile_count)) as f32;
let centre_y = lat_to_tile_y(viewport.centre_lat_deg, f64::from(tile_count)) as f32;
let tile_px = 256.0 * zoom_scale;
let (x_min, x_max, y_min, y_max) =
visible_tile_range(centre_x, centre_y, width_px, height_px, zoom_scale, tile_count);
if std::env::var("AZ_MAP_DEBUG").is_ok() {
eprintln!(
"[map] render bounds={:.0}x{:.0} z={} centre_tile=({:.2},{:.2}) tiles x{}..{} y{}..{} = {}",
width_px, height_px, z_int, centre_x, centre_y, x_min, x_max, y_min, y_max,
(x_max - x_min + 1).max(0) * (y_max - y_min + 1).max(0)
);
}
if let Some(mut cache) = data.downcast_mut::<MapTileCache>() {
for x in x_min..=x_max {
for y in y_min..=y_max {
let id = MapTileId {
z: z_int,
x: wrap_tile_x(x, tile_count),
y: y as u32,
};
cache.tiles.entry(id).or_insert(TileEntry::Pending);
}
}
}
let states: BTreeMap<MapTileId, TileDisplay> = data
.downcast_ref::<MapTileCache>()
.map_or_else(BTreeMap::new, |c| {
c.tiles
.iter()
.map(|(id, e)| {
let disp = match e {
TileEntry::Pending => TileDisplay::Glyph("…"),
TileEntry::Fetching => TileDisplay::Glyph("⟳"),
TileEntry::Ready { svg } => TileDisplay::Svg(svg.clone()),
TileEntry::Failed { .. } => TileDisplay::Glyph("✗"),
};
(*id, disp)
})
.collect()
});
let mut grid = Dom::create_div().with_css(
"position: absolute; left: 0; top: 0; width: 100%; height: 100%; overflow: hidden;",
);
{
use crate::callbacks::{Callback, CallbackType};
use azul_core::dom::{EventFilter, HoverEventFilter};
grid = grid
.with_callback(
EventFilter::Hover(HoverEventFilter::MouseDown),
data.clone(),
Callback::from_ptr(map_on_pointer_down),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::MouseOver),
data.clone(),
Callback::from_ptr(map_on_pointer_move),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::MouseUp),
data.clone(),
Callback::from_ptr(map_on_pointer_up),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::MouseLeave),
data.clone(),
Callback::from_ptr(map_on_pointer_up),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::Scroll),
data.clone(),
Callback::from_ptr(map_on_scroll),
);
}
for x in x_min..=x_max {
for y in y_min..=y_max {
let id = MapTileId {
z: z_int,
x: wrap_tile_x(x, tile_count),
y: y as u32,
};
let proj = |coord: f32, centre: f32, span_px: f32| {
((coord - centre) * tile_px + span_px * 0.5).round() as i32
};
let screen_x = proj(x as f32, centre_x, width_px);
let screen_y = proj(y as f32, centre_y, height_px);
let size_w = proj(x as f32 + 1.0, centre_x, width_px)
.saturating_sub(screen_x)
.max(1);
let size_h = proj(y as f32 + 1.0, centre_y, height_px)
.saturating_sub(screen_y)
.max(1);
let is_ready = matches!(states.get(&id), Some(TileDisplay::Svg(_)));
let chrome = if is_ready {
""
} else {
"background: #e7e9ec; border: 1px solid #d0d4d9;"
};
let style = alloc::format!(
"position: absolute; left: {screen_x}px; top: {screen_y}px; \
width: {size_w}px; height: {size_h}px; {chrome}"
);
let mut tile_div = Dom::create_div().with_css(style.as_str());
match states.get(&id) {
Some(TileDisplay::Svg(svg)) => match svg_string_to_dom(svg.as_str()) {
Some(svg_dom) => {
tile_div = tile_div.with_child(svg_dom);
}
None => {
tile_div = tile_div.with_child(
Dom::create_text(alloc::format!("✓? z{z_int}/{x}/{y}"))
.with_css("position: absolute; left: 4px; top: 4px; font-size: 11px; color: #888;"),
);
}
},
other => {
let state_tag = match other {
Some(TileDisplay::Glyph(g)) => *g,
_ => "",
};
tile_div = tile_div.with_child(
Dom::create_text(alloc::format!("{state_tag} z{z_int}/{x}/{y}"))
.with_css("position: absolute; left: 4px; top: 4px; font-size: 11px; color: #888;"),
);
}
}
grid = grid.with_child(tile_div);
}
}
VirtualViewReturn {
dom: OptionDom::Some(grid),
scroll_size: bounds_logical,
scroll_offset: azul_core::geom::LogicalPosition::zero(),
virtual_scroll_size: bounds_logical,
virtual_scroll_offset: azul_core::geom::LogicalPosition::zero(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn approx(a: f64, b: f64, eps: f64) {
assert!((a - b).abs() < eps, "expected {a} ≈ {b} (within {eps})");
}
#[test]
fn wrap_lon_keeps_in_range() {
approx(wrap_lon(0.0), 0.0, 1e-9);
approx(wrap_lon(179.0), 179.0, 1e-9);
approx(wrap_lon(-179.0), -179.0, 1e-9);
approx(wrap_lon(181.0), -179.0, 1e-9);
approx(wrap_lon(-181.0), 179.0, 1e-9);
approx(wrap_lon(540.0), -180.0, 1e-9);
for raw in [-1234.5, -360.0, 360.0, 999.9] {
let w = wrap_lon(raw);
assert!((-180.0..=180.0).contains(&w), "{raw} → {w} out of range");
}
}
#[test]
fn build_tile_url_substitutes_zxy() {
let tile = MapTileId { z: 11, x: 327, y: 791 };
assert_eq!(
build_tile_url("https://t.example/{z}/{x}/{y}.pbf", tile),
"https://t.example/11/327/791.pbf"
);
assert_eq!(
build_tile_url("{y}-{x}-{z}-{z}", MapTileId { z: 3, x: 4, y: 5 }),
"5-4-3-3"
);
}
#[test]
fn lon_tile_endpoints() {
approx(lon_to_tile_x(-180.0, 1.0), 0.0, 1e-9);
approx(lon_to_tile_x(180.0, 1.0), 1.0, 1e-9);
approx(lon_to_tile_x(0.0, 1.0), 0.5, 1e-9);
approx(lon_to_tile_x(0.0, 2.0), 1.0, 1e-9);
}
#[test]
fn lat_tile_equator_and_symmetry() {
approx(lat_to_tile_y(0.0, 1.0), 0.5, 1e-9);
let north = lat_to_tile_y(45.0, 1.0);
let south = lat_to_tile_y(-45.0, 1.0);
assert!(north < 0.5 && south > 0.5);
approx(north + south, 1.0, 1e-9);
}
#[test]
#[allow(clippy::cast_precision_loss)] fn projection_round_trips() {
let points = [
(37.7749, -122.4194), (51.5074, -0.1278), (-33.8688, 151.2093), (0.0, 0.0), ];
for z in [0u32, 5, 11, 18] {
let tc = (1u64 << z) as f64;
for (lat, lon) in points {
let x = lon_to_tile_x(lon, tc);
let y = lat_to_tile_y(lat, tc);
approx(tile_x_to_lon(x, tc), lon, 1e-6);
approx(tile_y_to_lat(y, tc), lat, 1e-6);
}
}
}
#[test]
fn pan_zero_drag_is_identity() {
let (lon, lat) = pan_viewport(37.0, -122.0, 11.0, 0.0, 0.0);
approx(lon, -122.0, 1e-9);
approx(lat, 37.0, 1e-9);
}
#[test]
fn pan_right_decreases_longitude() {
let (lon, _) = pan_viewport(0.0, 0.0, 0.0, 100.0, 0.0);
assert!(lon < 0.0, "drag right should lower longitude, got {lon}");
let (lon_left, _) = pan_viewport(0.0, 0.0, 0.0, -100.0, 0.0);
approx(lon_left, -lon, 1e-9);
}
#[test]
fn pan_step_scales_inversely_with_zoom() {
let (lon_z0, _) = pan_viewport(0.0, 0.0, 0.0, 50.0, 0.0);
let (lon_z1, _) = pan_viewport(0.0, 0.0, 1.0, 50.0, 0.0);
approx(lon_z1, lon_z0 / 2.0, 1e-9);
}
#[test]
fn pan_clamps_latitude_to_mercator_limit() {
let (_, lat_north) = pan_viewport(84.0, 0.0, 0.0, 0.0, 1.0e6);
assert!((-85.0..=85.0).contains(&lat_north));
let (_, lat_south) = pan_viewport(-84.0, 0.0, 0.0, 0.0, -1.0e6);
assert!((-85.0..=85.0).contains(&lat_south));
}
#[test]
fn pan_wraps_longitude_across_antimeridian() {
let (lon, _) = pan_viewport(0.0, 179.0, 0.0, -100.0, 0.0);
assert!((-180.0..180.0).contains(&lon), "lon {lon} out of range");
}
fn viewport_at(zoom: f32) -> MapViewport {
MapViewport {
centre_lat_deg: 0.0,
centre_lon_deg: 0.0,
zoom,
bearing_deg: 0.0,
pitch_deg: 0.0,
}
}
#[test]
fn merge_shares_old_cache_so_worker_writebacks_survive_relayout() {
let tile = MapTileId { z: 5, x: 1, y: 2 };
let old_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(5.0));
let old_ref = RefAny::new(old_cache);
let mut worker_handle = old_ref.clone();
let new_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(9.0));
let mut merged = merge_map_tile_cache(RefAny::new(new_cache), old_ref);
worker_handle
.downcast_mut::<MapTileCache>()
.unwrap()
.mark_tile_ready(tile, AzString::from("<svg/>"));
let g = merged.downcast_ref::<MapTileCache>().unwrap();
assert!(
g.tiles.contains_key(&tile),
"a worker writeback after relayout must reach the rendered cache"
);
}
#[test]
fn merge_adopts_build_viewport_but_keeps_tiles() {
let mut old_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(5.0));
old_cache.viewport.zoom = 7.0; let tile = MapTileId { z: 2, x: 1, y: 1 };
old_cache.tiles.insert(tile, TileEntry::Ready { svg: "<svg/>".into() });
let new_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(2.0));
let mut merged =
merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(old_cache));
let g = merged.downcast_ref::<MapTileCache>().unwrap();
approx(f64::from(g.viewport.zoom), f64::from(viewport_at(2.0).zoom), 1e-6);
assert!(
g.tiles.contains_key(&tile),
"fetched tiles must survive the merge (workers write into this cache)"
);
}
#[test]
fn tile_range_covers_centre_with_margin() {
let (x0, x1, y0, y1) = visible_tile_range(8.0, 8.0, 512.0, 512.0, 1.0, 16);
assert_eq!((x0, x1), (6, 10));
assert_eq!((y0, y1), (6, 10));
}
#[test]
fn wrap_tile_x_wraps_both_directions() {
assert_eq!(wrap_tile_x(-1, 4), 3);
assert_eq!(wrap_tile_x(0, 4), 0);
assert_eq!(wrap_tile_x(3, 4), 3);
assert_eq!(wrap_tile_x(4, 4), 0);
assert_eq!(wrap_tile_x(-5, 4), 3);
assert_eq!(wrap_tile_x(7, 1), 0);
assert_eq!(wrap_tile_x(-3, 1), 0);
}
#[test]
fn tile_range_y_clamps_but_x_wraps_at_zoom0() {
let (x0, x1, y0, y1) = visible_tile_range(0.5, 0.5, 256.0, 256.0, 1.0, 1);
assert_eq!((y0, y1), (0, 0));
for x in x0..=x1 {
assert_eq!(wrap_tile_x(x, 1), 0);
}
}
#[test]
fn tile_range_widens_with_viewport() {
let (nx0, nx1, ..) = visible_tile_range(8.0, 8.0, 512.0, 512.0, 1.0, 16);
let (wx0, wx1, ..) = visible_tile_range(8.0, 8.0, 1024.0, 512.0, 1.0, 16);
assert!(
(wx1 - wx0) > (nx1 - nx0),
"a wider viewport must request more columns"
);
}
#[test]
fn tile_range_clamps_y_but_wraps_x_at_edges() {
let (x0, _, y0, _) = visible_tile_range(0.0, 0.0, 512.0, 512.0, 1.0, 16);
assert!(y0 >= 0);
let (_, x1, _, y1) = visible_tile_range(15.0, 15.0, 512.0, 512.0, 1.0, 16);
assert!(y1 <= 15);
assert!(x0 < 0, "west-edge viewport should over-scan into wrapped columns");
assert!(x1 > 15, "east-edge viewport should over-scan into wrapped columns");
assert_eq!(wrap_tile_x(x0, 16), x0.rem_euclid(16) as u32);
assert_eq!(wrap_tile_x(x1, 16), x1.rem_euclid(16) as u32);
}
fn test_cache() -> MapTileCache {
let layer = MapTileLayer {
url_template: AzString::from("{z}/{x}/{y}"),
min_zoom: 0,
max_zoom: 19,
attribution: AzString::from(""),
style_css: AzString::from(""),
};
let viewport = MapViewport {
centre_lat_deg: 0.0,
centre_lon_deg: 0.0,
zoom: 4.0,
bearing_deg: 0.0,
pitch_deg: 0.0,
};
MapTileCache::new(layer, viewport)
}
#[test]
fn prune_evicts_distant_tiles_keeps_near_and_inflight() {
let mut cache = test_cache();
for x in 0..20u32 {
for y in 0..20u32 {
cache
.tiles
.insert(MapTileId { z: 4, x, y }, TileEntry::Ready { svg: AzString::from("<svg/>") });
}
}
cache.tiles.insert(MapTileId { z: 4, x: 8, y: 8 }, TileEntry::Pending);
cache.tiles.insert(MapTileId { z: 4, x: 9, y: 8 }, TileEntry::Ready { svg: AzString::from("<svg/>") });
cache.tiles.insert(MapTileId { z: 4, x: 0, y: 0 }, TileEntry::Ready { svg: AzString::from("<svg/>") });
assert!(cache.tiles.len() > 192, "precondition: over the cap");
cache.prune_distant_tiles();
assert!(cache.tiles.len() <= 192, "cache must be bounded after prune");
assert!(matches!(
cache.tiles.get(&MapTileId { z: 4, x: 8, y: 8 }),
Some(TileEntry::Pending)
));
assert!(cache.tiles.contains_key(&MapTileId { z: 4, x: 9, y: 8 }));
assert!(!cache.tiles.contains_key(&MapTileId { z: 4, x: 0, y: 0 }));
}
#[test]
fn prune_is_noop_under_cap() {
let mut cache = test_cache();
for x in 0..4u32 {
cache
.tiles
.insert(MapTileId { z: 4, x, y: 8 }, TileEntry::Ready { svg: AzString::from("<svg/>") });
}
cache.prune_distant_tiles();
assert_eq!(cache.tiles.len(), 4, "under the cap → nothing evicted");
}
}
#[cfg(test)]
#[allow(
clippy::cast_possible_truncation,
clippy::cast_precision_loss,
clippy::cast_sign_loss,
clippy::cast_possible_wrap,
clippy::float_cmp,
clippy::too_many_lines,
clippy::unreadable_literal
)]
mod autotest_generated {
use std::sync::{Arc, Mutex};
use azul_core::{
callbacks::{HidpiAdjustedBounds, VirtualViewCallbackReason},
dom::{DomId, DomNodeId},
geom::{LogicalPosition, LogicalSize, OptionLogicalPosition},
gl::OptionGlContextPtr,
hit_test::ScrollPosition,
resources::{DpiScaleFactor, ImageCache, RendererResources},
styled_dom::NodeHierarchyItemId,
window::{MonitorVec, RawWindowHandle, WindowTheme},
};
use azul_css::system::SystemStyle;
use rust_fontconfig::FcFontCache;
use super::*;
#[cfg(feature = "icu")]
use crate::icu::IcuLocalizerHandle;
use azul_core::task::ThreadReceiver;
use crate::{
callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
thread::{ThreadCallback, ThreadCallbackType, ThreadSender},
window::LayoutWindow,
window_state::FullWindowState,
};
fn close(a: f64, b: f64, eps: f64) {
assert!((a - b).abs() <= eps, "expected {a} ≈ {b} (within {eps})");
}
fn layer_zoom(min_zoom: u8, max_zoom: u8) -> MapTileLayer {
MapTileLayer {
url_template: AzString::from("https://tiles.invalid/{z}/{x}/{y}.pbf"),
min_zoom,
max_zoom,
attribution: AzString::from("attr"),
style_css: AzString::from(""),
}
}
fn view(lat: f64, lon: f64, zoom: f32) -> MapViewport {
MapViewport {
centre_lat_deg: lat,
centre_lon_deg: lon,
zoom,
bearing_deg: 0.0,
pitch_deg: 0.0,
}
}
fn cache_at(lat: f64, lon: f64, zoom: f32) -> MapTileCache {
MapTileCache::new(layer_zoom(0, 19), view(lat, lon, zoom))
}
#[derive(Default)]
struct HookLog {
viewports: Vec<MapViewport>,
coords: Vec<MapLatLon>,
}
extern "C" fn record_viewport(
mut data: RefAny,
_: CallbackInfo,
viewport: MapViewport,
) -> Update {
if let Some(mut log) = data.downcast_mut::<HookLog>() {
log.viewports.push(viewport);
}
Update::DoNothing
}
extern "C" fn record_pin(mut data: RefAny, _: CallbackInfo, coord: MapLatLon) -> Update {
if let Some(mut log) = data.downcast_mut::<HookLog>() {
log.coords.push(coord);
}
Update::RefreshDom
}
extern "C" fn noop_worker(_: RefAny, _: ThreadSender, _: ThreadReceiver) {}
extern "C" fn other_noop_worker(_: RefAny, _: ThreadSender, _: ThreadReceiver) {}
fn hook_log(data: &mut RefAny) -> (usize, usize) {
let log = data
.downcast_ref::<HookLog>()
.expect("payload must still be a HookLog");
(log.viewports.len(), log.coords.len())
}
fn with_callback_info_at<R>(
cursor: OptionLogicalPosition,
f: impl FnOnce(CallbackInfo) -> R,
) -> (R, Vec<CallbackChange>) {
let layout_window =
LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
let renderer_resources = RendererResources::default();
let previous_window_state: Option<FullWindowState> = None;
let current_window_state = FullWindowState::default();
let gl_context = OptionGlContextPtr::None;
let scroll_states: alloc::collections::BTreeMap<
DomId,
alloc::collections::BTreeMap<NodeHierarchyItemId, ScrollPosition>,
> = alloc::collections::BTreeMap::new();
let window_handle = RawWindowHandle::Unsupported;
let system_callbacks = ExternalSystemCallbacks::rust_internal();
let ref_data = CallbackInfoRefData {
layout_window: &layout_window,
renderer_resources: &renderer_resources,
previous_window_state: &previous_window_state,
current_window_state: ¤t_window_state,
gl_context: &gl_context,
current_scroll_manager: &scroll_states,
current_window_handle: &window_handle,
system_callbacks: &system_callbacks,
system_style: Arc::new(SystemStyle::default()),
monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
#[cfg(feature = "icu")]
icu_localizer: IcuLocalizerHandle::default(),
ctx: OptionRefAny::None,
};
let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
let info = CallbackInfo::new(
&ref_data,
&changes,
DomNodeId {
dom: DomId::ROOT_ID,
node: NodeHierarchyItemId::NONE,
},
cursor,
OptionLogicalPosition::None,
);
let out = f(info);
let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
(out, recorded)
}
fn with_callback_info<R>(f: impl FnOnce(CallbackInfo) -> R) -> (R, Vec<CallbackChange>) {
with_callback_info_at(OptionLogicalPosition::None, f)
}
fn cursor_at(x: f32, y: f32) -> OptionLogicalPosition {
OptionLogicalPosition::Some(LogicalPosition::new(x, y))
}
fn with_virtual_view_info<R>(
w: f32,
h: f32,
f: impl FnOnce(VirtualViewCallbackInfo) -> R,
) -> R {
let fonts = FcFontCache::default();
let images = ImageCache::default();
let size = LogicalSize::new(w, h);
let info = VirtualViewCallbackInfo::new(
VirtualViewCallbackReason::InitialRender,
&fonts,
&images,
WindowTheme::LightMode,
HidpiAdjustedBounds {
logical_size: size,
hidpi_factor: DpiScaleFactor::new(1.0),
},
size,
LogicalPosition::zero(),
size,
LogicalPosition::zero(),
);
f(info)
}
fn rendered_child_count(ret: &VirtualViewReturn) -> Option<usize> {
match &ret.dom {
OptionDom::Some(d) => Some(d.children.as_slice().len()),
OptionDom::None => None,
}
}
#[test]
fn wrap_lon_zero_and_negative_zero_are_zero() {
assert_eq!(wrap_lon(0.0), 0.0);
assert_eq!(wrap_lon(-0.0), 0.0);
}
#[test]
fn wrap_lon_nan_and_infinities_are_nan_not_panic() {
assert!(wrap_lon(f64::NAN).is_nan());
assert!(wrap_lon(f64::INFINITY).is_nan());
assert!(wrap_lon(f64::NEG_INFINITY).is_nan());
}
#[test]
fn wrap_lon_extreme_finite_inputs_stay_bounded_and_finite() {
for raw in [
f64::MAX,
f64::MIN,
f64::MIN_POSITIVE,
-f64::MIN_POSITIVE,
1.0e300,
-1.0e300,
1.0e18,
-1.0e18,
360.0 * 1.0e9,
] {
let w = wrap_lon(raw);
assert!(w.is_finite(), "{raw} → {w} is not finite");
assert!((-180.0..=180.0).contains(&w), "{raw} → {w} out of range");
}
}
#[test]
fn wrap_lon_is_idempotent_on_representative_inputs() {
for (raw, expected) in [
(0.0_f64, 0.0_f64),
(45.0, 45.0),
(-45.0, -45.0),
(181.0, -179.0),
(-181.0, 179.0),
(720.0, 0.0),
(-720.0, 0.0),
(1.0e6, -80.0),
] {
let once = wrap_lon(raw);
close(once, expected, 1e-9);
close(wrap_lon(once), once, 1e-9);
}
}
#[test]
fn lon_to_tile_x_zero_tile_count_collapses_to_zero() {
for lon in [-180.0, -1.0, 0.0, 1.0, 180.0] {
assert_eq!(lon_to_tile_x(lon, 0.0), 0.0, "lon {lon} at tile_count 0");
}
}
#[test]
fn lon_to_tile_x_nan_inf_are_defined_not_panics() {
assert!(lon_to_tile_x(f64::NAN, 4.0).is_nan());
assert!(lon_to_tile_x(0.0, f64::NAN).is_nan());
assert_eq!(lon_to_tile_x(f64::INFINITY, 4.0), f64::INFINITY);
assert_eq!(lon_to_tile_x(f64::NEG_INFINITY, 4.0), f64::NEG_INFINITY);
assert!(lon_to_tile_x(f64::INFINITY, 0.0).is_nan());
}
#[test]
fn lon_to_tile_x_is_monotonic_and_saturates_on_huge_counts() {
let mut prev = f64::NEG_INFINITY;
for lon in [-180.0, -90.0, -0.5, 0.0, 0.5, 90.0, 180.0] {
let x = lon_to_tile_x(lon, 256.0);
assert!(x > prev, "lon_to_tile_x must increase with longitude");
prev = x;
}
assert_eq!(lon_to_tile_x(180.0, f64::MAX), f64::MAX);
assert!(lon_to_tile_x(180.0, f64::INFINITY).is_infinite());
}
#[test]
fn tile_x_to_lon_degenerate_tile_counts_do_not_panic() {
assert!(tile_x_to_lon(0.0, 0.0).is_nan());
assert!(tile_x_to_lon(1.0, 0.0).is_infinite());
assert!(tile_x_to_lon(f64::NAN, 4.0).is_nan());
assert!(tile_x_to_lon(f64::MAX, f64::MIN_POSITIVE).is_infinite());
}
#[test]
fn lon_tile_x_round_trips_across_zooms_and_edges() {
for z in [0u32, 1, 5, 14, 22] {
let tc = f64::from(1u32 << z);
for lon in [-180.0, -179.999, -122.4194, 0.0, 0.1, 151.2093, 180.0] {
let x = lon_to_tile_x(lon, tc);
close(tile_x_to_lon(x, tc), lon, 1e-9);
}
}
}
#[test]
fn lat_to_tile_y_inside_the_mercator_band_is_finite_and_ordered() {
let tc = 256.0;
let mut prev = f64::NEG_INFINITY;
for lat in [85.0, 60.0, 30.0, 0.0, -30.0, -60.0, -85.0] {
let y = lat_to_tile_y(lat, tc);
assert!(y.is_finite(), "lat {lat} → {y}");
assert!((0.0..=tc).contains(&y), "lat {lat} → {y} outside the grid");
assert!(y > prev, "tile-y must increase as latitude decreases");
prev = y;
}
}
#[test]
fn lat_to_tile_y_nan_and_infinite_latitudes_are_nan() {
assert!(lat_to_tile_y(f64::NAN, 4.0).is_nan());
assert!(lat_to_tile_y(f64::INFINITY, 4.0).is_nan());
assert!(lat_to_tile_y(f64::NEG_INFINITY, 4.0).is_nan());
assert!(lat_to_tile_y(0.0, f64::NAN).is_nan());
}
#[test]
fn lat_to_tile_y_past_the_poles_does_not_panic() {
let outs: Vec<f64> = [90.0, -90.0, 89.9999, -89.9999, 180.0, -180.0, 1.0e9]
.iter()
.map(|lat| lat_to_tile_y(*lat, 4.0))
.collect();
assert_eq!(outs.len(), 7);
}
#[test]
fn tile_y_to_lat_saturates_at_the_poles_for_out_of_range_rows() {
for (y, expected) in [(-1.0e9_f64, 90.0_f64), (1.0e9, -90.0)] {
let lat = tile_y_to_lat(y, 4.0);
close(lat, expected, 1e-9);
}
for y in [-1.0e300, -1000.0, -1.0, 0.0, 2.0, 1000.0, 1.0e300] {
let lat = tile_y_to_lat(y, 4.0);
assert!(
(-90.0 - 1e-9..=90.0 + 1e-9).contains(&lat),
"row {y} → {lat} outside ±90"
);
}
}
#[test]
fn tile_y_to_lat_degenerate_tile_counts_do_not_panic() {
assert!(tile_y_to_lat(0.0, 0.0).is_nan()); close(tile_y_to_lat(1.0, 0.0), -90.0, 1e-9); assert!(tile_y_to_lat(f64::NAN, 4.0).is_nan());
}
#[test]
fn lat_tile_y_round_trips_at_the_mercator_edges() {
for z in [0u32, 3, 14, 22] {
let tc = f64::from(1u32 << z);
for lat in [-85.05, -45.0, -0.0001, 0.0, 0.0001, 45.0, 85.05] {
let y = lat_to_tile_y(lat, tc);
close(tile_y_to_lat(y, tc), lat, 1e-6);
}
}
}
#[test]
fn pan_viewport_nan_inputs_propagate_without_panicking() {
let (lon, lat) = pan_viewport(f64::NAN, 0.0, 2.0, 10.0, 10.0);
assert!(lat.is_nan(), "NaN centre latitude must stay NaN, got {lat}");
assert!(lon.is_nan() || (-180.0..=180.0).contains(&lon));
let (lon, _) = pan_viewport(0.0, f64::NAN, 2.0, 10.0, 10.0);
assert!(lon.is_nan());
let (lon, lat) = pan_viewport(0.0, 0.0, 2.0, f64::NAN, f64::NAN);
assert!(lon.is_nan() && lat.is_nan());
}
#[test]
fn pan_viewport_infinite_zoom_is_a_no_op() {
let (lon, lat) = pan_viewport(37.0, -122.0, f64::INFINITY, 1.0e6, -1.0e6);
close(lon, -122.0, 1e-9);
close(lat, 37.0, 1e-9);
}
#[test]
fn pan_viewport_negative_infinite_zoom_saturates_latitude_not_panics() {
let (lon, lat) = pan_viewport(0.0, 0.0, f64::NEG_INFINITY, 100.0, 100.0);
assert!(lon.is_nan(), "expected NaN longitude, got {lon}");
close(lat, 85.0, 1e-9);
let (_, lat_south) = pan_viewport(0.0, 0.0, f64::NEG_INFINITY, 0.0, -100.0);
close(lat_south, -85.0, 1e-9);
}
#[test]
fn pan_viewport_extreme_pixel_deltas_stay_inside_the_world() {
for dx in [-1.0e18_f64, -1.0e9, -1.0, 0.0, 1.0, 1.0e9, 1.0e18] {
for dy in [-1.0e18_f64, 0.0, 1.0e18] {
let (lon, lat) = pan_viewport(37.0, -122.0, 0.0, dx, dy);
assert!(lon.is_finite(), "dx {dx} dy {dy} → lon {lon}");
assert!((-180.0..=180.0).contains(&lon), "lon {lon} out of range");
assert!((-85.0..=85.0).contains(&lat), "lat {lat} out of range");
}
}
}
#[test]
fn pan_viewport_zero_zoom_and_zero_delta_is_the_identity() {
let (lon, lat) = pan_viewport(0.0, 0.0, 0.0, 0.0, 0.0);
assert_eq!((lon, lat), (0.0, 0.0));
}
#[test]
fn pan_viewport_latitude_step_shrinks_towards_the_poles() {
let (_, at_equator) = pan_viewport(0.0, 0.0, 2.0, 0.0, 100.0);
let (_, at_80) = pan_viewport(80.0, 0.0, 2.0, 0.0, 100.0);
assert!(
(at_80 - 80.0).abs() < at_equator.abs(),
"pole-adjacent pan {at_80} must move less than equatorial {at_equator}"
);
}
#[test]
fn latlon_at_px_centre_pixel_is_the_viewport_centre() {
let viewport = view(51.5074, -0.1278, 11.0);
let container = LogicalSize::new(800.0, 600.0);
let coord = MapWidget::latlon_at_px(
viewport,
LogicalPosition::new(400.0, 300.0),
container,
);
close(coord.lat_deg, 51.5074, 1e-9);
close(coord.lon_deg, -0.1278, 1e-9);
}
#[test]
fn latlon_at_px_result_is_always_clamped_or_nan() {
let container = LogicalSize::new(800.0, 600.0);
for zoom in [0.0_f32, 2.0, 11.0, 22.0] {
for px in [
LogicalPosition::new(0.0, 0.0),
LogicalPosition::new(-1.0e9, -1.0e9),
LogicalPosition::new(1.0e9, 1.0e9),
LogicalPosition::new(f32::MAX, f32::MIN),
] {
let c = MapWidget::latlon_at_px(view(0.0, 0.0, zoom), px, container);
assert!(
(-180.0..=180.0).contains(&c.lon_deg),
"zoom {zoom} px {px:?} → lon {}",
c.lon_deg
);
assert!(
(-85.0..=85.0).contains(&c.lat_deg),
"zoom {zoom} px {px:?} → lat {}",
c.lat_deg
);
}
}
}
#[test]
fn latlon_at_px_non_finite_zoom_does_not_panic() {
let container = LogicalSize::new(800.0, 600.0);
let px = LogicalPosition::new(10.0, 10.0);
let c = MapWidget::latlon_at_px(view(0.0, 0.0, f32::INFINITY), px, container);
close(c.lon_deg, 0.0, 1e-9);
close(c.lat_deg, 0.0, 1e-9);
let c = MapWidget::latlon_at_px(view(0.0, 0.0, f32::NEG_INFINITY), px, container);
assert!((-180.0..=180.0).contains(&c.lon_deg));
assert!((-85.0..=85.0).contains(&c.lat_deg));
let c = MapWidget::latlon_at_px(view(0.0, 0.0, f32::NAN), px, container);
assert!(c.lon_deg.is_nan() && c.lat_deg.is_nan());
}
#[test]
fn latlon_at_px_zero_sized_container_is_still_defined() {
let c = MapWidget::latlon_at_px(
view(10.0, 20.0, 4.0),
LogicalPosition::new(0.0, 0.0),
LogicalSize::new(0.0, 0.0),
);
close(c.lat_deg, 10.0, 1e-9);
close(c.lon_deg, 20.0, 1e-9);
}
#[test]
fn px_at_latlon_centre_coord_is_the_container_centre() {
let viewport = view(37.7749, -122.4194, 12.0);
let container = LogicalSize::new(1024.0, 768.0);
let p = MapWidget::px_at_latlon(
viewport,
MapLatLon {
lat_deg: viewport.centre_lat_deg,
lon_deg: viewport.centre_lon_deg,
},
container,
);
close(f64::from(p.x), 512.0, 1e-3);
close(f64::from(p.y), 384.0, 1e-3);
}
#[test]
fn px_at_latlon_saturates_instead_of_overflowing_f32() {
let container = LogicalSize::new(800.0, 600.0);
let p = MapWidget::px_at_latlon(
view(0.0, 0.0, f32::MAX),
MapLatLon {
lat_deg: 10.0,
lon_deg: 10.0,
},
container,
);
assert!(!p.x.is_finite(), "expected a saturated x, got {}", p.x);
assert!(!p.y.is_finite(), "expected a saturated y, got {}", p.y);
}
#[test]
fn px_at_latlon_at_a_pole_centre_does_not_panic() {
let container = LogicalSize::new(800.0, 600.0);
let p = MapWidget::px_at_latlon(
view(90.0, 0.0, 2.0),
MapLatLon {
lat_deg: 0.0,
lon_deg: 0.0,
},
container,
);
assert!(p.x.is_finite(), "x should stay finite, got {}", p.x);
assert!(!p.y.is_nan(), "y must be a number or an infinity, got {}", p.y);
}
#[test]
fn projection_px_round_trips_within_the_clamped_band() {
let container = LogicalSize::new(800.0, 600.0);
for zoom in [2.0_f32, 8.0, 14.0] {
let viewport = view(37.7749, -122.4194, zoom);
for (dlat, dlon) in [(0.0, 0.0), (0.01, 0.02), (-0.03, 0.04)] {
let coord = MapLatLon {
lat_deg: viewport.centre_lat_deg + dlat,
lon_deg: viewport.centre_lon_deg + dlon,
};
let px = MapWidget::px_at_latlon(viewport, coord, container);
let back = MapWidget::latlon_at_px(viewport, px, container);
close(back.lat_deg, coord.lat_deg, 1e-4);
close(back.lon_deg, coord.lon_deg, 1e-4);
}
}
}
#[test]
fn visible_tile_range_zero_zoom_scale_saturates_to_the_i32_extremes() {
let (x0, x1, y0, y1) = visible_tile_range(8.0, 8.0, 800.0, 600.0, 0.0, 16);
assert_eq!((x0, x1), (i32::MIN, i32::MAX));
assert_eq!((y0, y1), (0, 15));
}
#[test]
fn visible_tile_range_infinite_dimensions_saturate_the_same_way() {
let (x0, x1, y0, y1) =
visible_tile_range(8.0, 8.0, f32::INFINITY, f32::INFINITY, 1.0, 16);
assert_eq!((x0, x1), (i32::MIN, i32::MAX));
assert_eq!((y0, y1), (0, 15));
}
#[test]
fn visible_tile_range_nan_inputs_collapse_to_a_single_cell() {
assert_eq!(
visible_tile_range(8.0, 8.0, f32::NAN, f32::NAN, 1.0, 16),
(0, 0, 0, 0)
);
assert_eq!(
visible_tile_range(f32::NAN, f32::NAN, 512.0, 512.0, 1.0, 16),
(0, 0, 0, 0)
);
assert_eq!(
visible_tile_range(8.0, 8.0, 512.0, 512.0, f32::NAN, 16),
(0, 0, 0, 0)
);
}
#[test]
fn visible_tile_range_negative_dimensions_are_taken_absolutely() {
let positive = visible_tile_range(8.0, 8.0, 512.0, 384.0, 1.0, 16);
let negative = visible_tile_range(8.0, 8.0, -512.0, -384.0, 1.0, 16);
assert_eq!(positive, negative);
}
#[test]
fn visible_tile_range_zero_tile_count_yields_an_empty_row_span() {
let (_, _, y0, y1) = visible_tile_range(0.0, 0.0, 512.0, 512.0, 1.0, 0);
assert!(y0 > y1, "expected an empty row span, got {y0}..={y1}");
}
#[test]
fn visible_tile_range_u32_max_tile_count_wraps_to_an_empty_row_span() {
let (_, _, y0, y1) = visible_tile_range(0.0, 0.0, 512.0, 512.0, 1.0, u32::MAX);
assert!(y0 > y1, "expected an empty row span, got {y0}..={y1}");
}
#[test]
fn visible_tile_range_always_keeps_a_one_tile_margin() {
let (x0, x1, y0, y1) = visible_tile_range(8.0, 8.0, 1.0, 1.0, 1.0, 16);
assert!(x0 <= 7 && x1 >= 9, "x span {x0}..={x1} lost its margin");
assert!(y0 <= 7 && y1 >= 9, "y span {y0}..={y1} lost its margin");
}
#[test]
fn visible_tile_range_extreme_zoom_scale_shrinks_to_the_margin() {
let (x0, x1, y0, y1) = visible_tile_range(8.0, 8.0, 800.0, 600.0, f32::MAX, 16);
assert_eq!((x0, x1), (7, 9));
assert_eq!((y0, y1), (7, 9));
}
#[test]
fn wrap_tile_x_zero_tile_count_is_zero_never_a_division_by_zero() {
for x in [i32::MIN, -7, -1, 0, 1, 7, i32::MAX] {
assert_eq!(wrap_tile_x(x, 0), 0, "column {x} at tile_count 0");
}
}
#[test]
fn wrap_tile_x_extremes_stay_inside_the_band() {
for tile_count in [1u32, 2, 4, 256, 65_536, 1 << 30, 1 << 31] {
for x in [i32::MIN, -1_000_000, -1, 0, 1, 1_000_000, i32::MAX] {
let wrapped = wrap_tile_x(x, tile_count);
assert!(
wrapped < tile_count,
"column {x} at tile_count {tile_count} → {wrapped} (out of band)"
);
}
}
}
#[test]
fn wrap_tile_x_matches_rem_euclid_for_realistic_zooms() {
for z in 0u32..=20 {
let tile_count = 1u32 << z;
for x in [i32::MIN, -3, -1, 0, 1, 3, i32::MAX] {
assert_eq!(
wrap_tile_x(x, tile_count),
x.rem_euclid(tile_count as i32) as u32
);
}
}
}
#[test]
fn wrap_tile_x_is_periodic_in_tile_count() {
for tile_count in [1u32, 2, 4, 16, 1024] {
for x in [-9i32, -1, 0, 1, 9] {
let shifted = x
.checked_add(tile_count as i32)
.expect("shift must stay in range");
assert_eq!(wrap_tile_x(x, tile_count), wrap_tile_x(shifted, tile_count));
}
}
}
#[test]
fn map_visible_tiles_zero_bounds_still_covers_the_centre() {
let layer = MapTileLayer::default();
let tiles = map_visible_tiles(&view(0.0, 0.0, 2.0), LogicalSize::new(0.0, 0.0), &layer);
assert!(!tiles.is_empty(), "the one-tile margin must survive 0x0 bounds");
for t in &tiles {
assert_eq!(t.z, 2);
assert!(t.x < 4 && t.y < 4, "tile {t:?} escaped the z2 grid");
}
}
#[test]
fn map_visible_tiles_non_finite_bounds_degenerate_to_one_tile() {
let layer = MapTileLayer::default();
let tiles = map_visible_tiles(
&view(0.0, 0.0, 2.0),
LogicalSize::new(f32::NAN, f32::NAN),
&layer,
);
assert_eq!(tiles.len(), 1, "NaN bounds must not enumerate a grid");
}
#[test]
fn map_visible_tiles_nan_zoom_degenerates_to_one_tile() {
let layer = MapTileLayer::default();
let tiles = map_visible_tiles(
&view(0.0, 0.0, f32::NAN),
LogicalSize::new(800.0, 600.0),
&layer,
);
assert_eq!(tiles.len(), 1);
assert_eq!(tiles[0].z, 0, "a NaN zoom clamps to the layer minimum");
}
#[test]
fn map_visible_tiles_positive_infinite_zoom_stays_bounded() {
let layer = MapTileLayer::default();
let tiles = map_visible_tiles(
&view(0.0, 0.0, f32::INFINITY),
LogicalSize::new(800.0, 600.0),
&layer,
);
assert!(!tiles.is_empty());
assert!(tiles.len() < 64, "+inf zoom produced {} tiles", tiles.len());
for t in &tiles {
assert_eq!(t.z, layer.max_zoom, "+inf zoom must clamp to max_zoom");
}
}
#[test]
fn map_visible_tiles_negative_bounds_match_positive_bounds() {
let layer = MapTileLayer::default();
let viewport = view(48.0, 11.0, 6.0);
let positive = map_visible_tiles(&viewport, LogicalSize::new(640.0, 480.0), &layer);
let negative = map_visible_tiles(&viewport, LogicalSize::new(-640.0, -480.0), &layer);
assert_eq!(positive, negative);
}
#[test]
fn map_visible_tiles_ids_always_live_inside_the_grid() {
for (min_zoom, max_zoom) in [(0u8, 14u8), (3, 5), (0, 0)] {
let layer = layer_zoom(min_zoom, max_zoom);
for zoom in [-1.0_f32, 0.0, 2.5, 7.0, 99.0] {
for (lat, lon) in [(0.0, 0.0), (85.0, 180.0), (-85.0, -180.0), (60.0, 179.99)] {
let viewport = view(lat, lon, zoom);
let tiles =
map_visible_tiles(&viewport, LogicalSize::new(800.0, 600.0), &layer);
let expected_z = (zoom.floor() as i32)
.clamp(i32::from(min_zoom), i32::from(max_zoom))
as u8;
let tile_count = 1u32 << u32::from(expected_z);
for t in &tiles {
assert_eq!(t.z, expected_z, "zoom {zoom} produced z {}", t.z);
assert!(t.x < tile_count, "column {} >= {tile_count}", t.x);
assert!(t.y < tile_count, "row {} >= {tile_count}", t.y);
}
}
}
}
}
#[test]
fn map_visible_tiles_wraps_columns_across_the_antimeridian() {
let layer = layer_zoom(0, 14);
let tiles = map_visible_tiles(
&view(0.0, 180.0, 3.0),
LogicalSize::new(1024.0, 256.0),
&layer,
);
assert!(!tiles.is_empty());
assert!(
tiles.iter().any(|t| t.x == 0),
"panning past +180° must surface the west-edge column"
);
for t in &tiles {
assert!(t.x < 8, "column {} escaped the z3 grid", t.x);
}
}
#[test]
fn create_uses_the_given_layer_and_neutral_defaults() {
let layer = layer_zoom(2, 9);
let widget = MapWidget::create(layer.clone());
assert_eq!(widget.layer, layer);
assert_eq!(widget.viewport, MapViewport::default());
assert!(widget.container_style.as_slice().is_empty());
assert!(matches!(
widget.on_viewport_changed,
OptionMapViewportChanged::None
));
assert!(matches!(widget.on_pin_tap, OptionMapPinTap::None));
}
#[test]
fn create_accepts_degenerate_layers_without_panicking() {
let widget = MapWidget::create(MapTileLayer {
url_template: AzString::from(""),
min_zoom: 30,
max_zoom: 0,
attribution: AzString::from(""),
style_css: AzString::from(""),
});
assert_eq!(widget.layer.min_zoom, 30);
assert_eq!(widget.layer.max_zoom, 0);
}
#[test]
fn with_viewport_stores_extreme_values_verbatim() {
let widget = MapWidget::create(MapTileLayer::default())
.with_viewport(view(1.0e300, -1.0e300, f32::MAX));
assert_eq!(widget.viewport.centre_lat_deg, 1.0e300);
assert_eq!(widget.viewport.centre_lon_deg, -1.0e300);
assert_eq!(widget.viewport.zoom, f32::MAX);
let widget = MapWidget::create(MapTileLayer::default())
.with_viewport(view(f64::NAN, f64::INFINITY, f32::NEG_INFINITY));
assert!(widget.viewport.centre_lat_deg.is_nan());
assert!(widget.viewport.centre_lon_deg.is_infinite());
assert!(widget.viewport.zoom.is_infinite());
}
#[test]
fn with_viewport_is_last_write_wins() {
let widget = MapWidget::create(MapTileLayer::default())
.with_viewport(view(1.0, 2.0, 3.0))
.with_viewport(view(4.0, 5.0, 6.0));
assert_eq!(widget.viewport, view(4.0, 5.0, 6.0));
}
#[test]
fn with_container_style_replaces_the_style_vec() {
let css = CssPropertyWithConditionsVec::parse("width: 100px; height: 50px;");
let parsed_len = css.as_slice().len();
assert!(parsed_len > 0, "positive control: the style must parse");
let widget = MapWidget::create(MapTileLayer::default()).with_container_style(css);
assert_eq!(widget.container_style.as_slice().len(), parsed_len);
let widget = MapWidget::create(MapTileLayer::default())
.with_container_style(CssPropertyWithConditionsVec::parse(""));
assert!(widget.container_style.as_slice().is_empty());
}
#[test]
fn with_container_style_tolerates_garbage_and_unicode() {
for style in [
"\u{1F600}: \u{1F600};",
" ",
";;;;",
"width",
"width: ;",
"}{",
"color: \u{0301}\u{0301};",
] {
let widget = MapWidget::create(MapTileLayer::default())
.with_container_style(CssPropertyWithConditionsVec::parse(style));
let _ = widget.container_style.as_slice().len();
}
}
#[test]
fn with_on_viewport_changed_installs_and_replaces_the_hook() {
let mut widget = MapWidget::create(MapTileLayer::default()).with_on_viewport_changed(
RefAny::new(HookLog::default()),
record_viewport as MapViewportChangedCallbackType,
);
assert!(matches!(
widget.on_viewport_changed,
OptionMapViewportChanged::Some(_)
));
widget.set_on_viewport_changed(
RefAny::new(HookLog::default()),
record_viewport as MapViewportChangedCallbackType,
);
assert!(matches!(
widget.on_viewport_changed,
OptionMapViewportChanged::Some(_)
));
assert!(matches!(widget.on_pin_tap, OptionMapPinTap::None));
}
#[test]
fn with_on_pin_tap_installs_and_replaces_the_hook() {
let mut widget = MapWidget::create(MapTileLayer::default())
.with_on_pin_tap(RefAny::new(HookLog::default()), record_pin as MapPinTapCallbackType);
assert!(matches!(widget.on_pin_tap, OptionMapPinTap::Some(_)));
widget.set_on_pin_tap(
RefAny::new(HookLog::default()),
record_pin as MapPinTapCallbackType,
);
assert!(matches!(widget.on_pin_tap, OptionMapPinTap::Some(_)));
assert!(matches!(
widget.on_viewport_changed,
OptionMapViewportChanged::None
));
}
#[test]
fn builder_chain_order_does_not_matter_for_independent_fields() {
let layer = layer_zoom(1, 12);
let viewport = view(10.0, 20.0, 5.0);
let a = MapWidget::create(layer.clone())
.with_viewport(viewport)
.with_container_style(CssPropertyWithConditionsVec::parse("width: 10px;"));
let b = MapWidget::create(layer)
.with_container_style(CssPropertyWithConditionsVec::parse("width: 10px;"))
.with_viewport(viewport);
assert_eq!(a, b);
}
#[test]
fn dom_builds_a_single_virtual_view_child_with_a_dataset() {
let mut dom = MapWidget::create(MapTileLayer::default())
.with_viewport(view(0.0, 0.0, 3.0))
.dom();
assert_eq!(dom.children.as_slice().len(), 1, "one VirtualView child");
let dataset = dom
.root
.get_dataset_mut()
.expect("the widget div must carry a MapTileCache dataset");
let cache = dataset
.downcast_ref::<MapTileCache>()
.expect("the dataset must be a MapTileCache");
assert_eq!(cache.viewport.zoom, 3.0);
assert!(cache.tiles.is_empty());
assert!(cache.fetch_callback.is_none(), "dom() wires no worker");
}
#[test]
fn dom_survives_degenerate_viewports_and_layers() {
for viewport in [
view(f64::NAN, f64::NAN, f32::NAN),
view(1.0e300, -1.0e300, f32::INFINITY),
view(0.0, 0.0, f32::NEG_INFINITY),
] {
let dom = MapWidget::create(layer_zoom(200, 1))
.with_viewport(viewport)
.dom();
assert_eq!(dom.children.as_slice().len(), 1);
}
}
#[test]
fn dom_with_a_container_style_still_builds_the_grid() {
let dom = MapWidget::create(MapTileLayer::default())
.with_container_style(CssPropertyWithConditionsVec::parse(
"position: relative; width: 320px; height: 240px;",
))
.dom();
assert_eq!(dom.children.as_slice().len(), 1);
}
#[test]
fn dom_with_fetch_records_the_worker_in_the_dataset() {
let mut dom = MapWidget::create(MapTileLayer::default())
.dom_with_fetch(ThreadCallback::new(noop_worker));
let dataset = dom.root.get_dataset_mut().expect("dataset");
let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
let cb = cache
.fetch_callback
.as_ref()
.expect("dom_with_fetch must record the worker");
assert_eq!(cb.cb as usize, noop_worker as ThreadCallbackType as usize);
}
#[test]
fn dom_carries_the_user_hooks_into_the_cache() {
let mut dom = MapWidget::create(MapTileLayer::default())
.with_on_viewport_changed(
RefAny::new(HookLog::default()),
record_viewport as MapViewportChangedCallbackType,
)
.with_on_pin_tap(
RefAny::new(HookLog::default()),
record_pin as MapPinTapCallbackType,
)
.dom();
let dataset = dom.root.get_dataset_mut().expect("dataset");
let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
assert!(matches!(
cache.on_viewport_changed,
OptionMapViewportChanged::Some(_)
));
assert!(matches!(cache.on_pin_tap, OptionMapPinTap::Some(_)));
}
#[test]
fn tile_cache_new_starts_empty_and_idle() {
let cache = MapTileCache::new(layer_zoom(0, 14), view(1.0, 2.0, 3.0));
assert!(cache.tiles.is_empty());
assert!(cache.fetch_callback.is_none());
assert!(cache.drag_anchor.is_none());
assert!(cache.pinch_anchor.is_none());
assert!(cache.press_origin.is_none());
assert_eq!(cache.viewport, view(1.0, 2.0, 3.0));
assert_eq!(cache.layer.max_zoom, 14);
}
#[test]
fn mark_tile_ready_and_failed_overwrite_each_other() {
let mut cache = cache_at(0.0, 0.0, 4.0);
let tile = MapTileId { z: 4, x: 8, y: 8 };
cache.mark_tile_ready(tile, AzString::from("<svg/>"));
assert!(matches!(cache.tiles.get(&tile), Some(TileEntry::Ready { .. })));
cache.mark_tile_failed(tile, AzString::from("boom"));
assert!(matches!(cache.tiles.get(&tile), Some(TileEntry::Failed { .. })));
cache.mark_tile_ready(tile, AzString::from(""));
assert!(matches!(cache.tiles.get(&tile), Some(TileEntry::Ready { .. })));
assert_eq!(cache.tiles.len(), 1, "the same id must not duplicate");
}
#[test]
fn mark_tile_ready_accepts_empty_unicode_and_huge_payloads() {
let mut cache = cache_at(0.0, 0.0, 4.0);
let payloads = [
AzString::from(""),
AzString::from("\u{1F600}\u{4F60}\u{597D}e\u{0301}"),
AzString::from("\0\u{FFFD}\r\n\t"),
AzString::from("x".repeat(1_000_000)),
];
for (i, svg) in payloads.into_iter().enumerate() {
cache.mark_tile_ready(
MapTileId {
z: 4,
x: i as u32,
y: 0,
},
svg,
);
}
assert_eq!(cache.tiles.len(), 4);
}
#[test]
fn mark_tile_ready_accepts_out_of_range_tile_ids() {
let mut cache = cache_at(0.0, 0.0, 4.0);
for tile in [
MapTileId { z: 0, x: 0, y: 0 },
MapTileId {
z: 31,
x: u32::MAX,
y: u32::MAX,
},
MapTileId {
z: 4,
x: u32::MAX,
y: 0,
},
] {
cache.mark_tile_failed(tile, AzString::from("e"));
}
assert_eq!(cache.tiles.len(), 3);
}
fn fill_ready_grid(cache: &mut MapTileCache, z: u8, side: u32) {
for x in 0..side {
for y in 0..side {
cache
.tiles
.insert(MapTileId { z, x, y }, TileEntry::Ready { svg: AzString::from("<svg/>") });
}
}
}
#[test]
fn prune_never_evicts_in_flight_tiles_even_far_over_the_cap() {
let mut cache = cache_at(0.0, 0.0, 4.0);
for x in 0..16u32 {
for y in 0..16u32 {
cache.tiles.insert(
MapTileId { z: 4, x, y },
if (x + y) % 2 == 0 {
TileEntry::Pending
} else {
TileEntry::Fetching
},
);
}
}
assert_eq!(cache.tiles.len(), 256);
cache.prune_distant_tiles();
assert_eq!(
cache.tiles.len(),
256,
"in-flight tiles are unevictable, so the cap can be exceeded"
);
}
#[test]
fn prune_with_a_nan_viewport_centre_still_bounds_the_cache() {
let mut cache = MapTileCache::new(layer_zoom(0, 19), view(f64::NAN, f64::NAN, 4.0));
fill_ready_grid(&mut cache, 4, 16);
assert_eq!(cache.tiles.len(), 256);
cache.prune_distant_tiles();
assert_eq!(cache.tiles.len(), 192);
}
#[test]
fn prune_with_non_finite_zoom_clamps_to_the_layer_band() {
for zoom in [f32::INFINITY, f32::NEG_INFINITY, f32::NAN, 1.0e30, -1.0e30] {
let mut cache = MapTileCache::new(layer_zoom(0, 19), view(0.0, 0.0, zoom));
fill_ready_grid(&mut cache, 4, 16);
cache.prune_distant_tiles();
assert_eq!(cache.tiles.len(), 192, "zoom {zoom} must still bound the cache");
}
}
#[test]
fn prune_is_idempotent_once_under_the_cap() {
let mut cache = cache_at(0.0, 0.0, 4.0);
fill_ready_grid(&mut cache, 4, 16);
cache.prune_distant_tiles();
let first = cache.tiles.len();
let survivors: Vec<MapTileId> = cache.tiles.keys().copied().collect();
cache.prune_distant_tiles();
assert_eq!(cache.tiles.len(), first);
assert_eq!(
cache.tiles.keys().copied().collect::<Vec<_>>(),
survivors,
"a second prune under the cap must change nothing"
);
}
#[test]
fn prune_drops_the_farthest_tiles_first_across_zoom_levels() {
let mut cache = cache_at(0.0, 0.0, 4.0);
fill_ready_grid(&mut cache, 4, 16);
let wrong_zoom = MapTileId { z: 9, x: 256, y: 256 };
cache
.tiles
.insert(wrong_zoom, TileEntry::Ready { svg: AzString::from("<svg/>") });
let near = MapTileId { z: 4, x: 8, y: 8 };
cache.prune_distant_tiles();
assert!(cache.tiles.len() <= 192);
assert!(
!cache.tiles.contains_key(&wrong_zoom),
"a zoom-mismatched tile must be evicted before same-zoom neighbours"
);
assert!(cache.tiles.contains_key(&near), "the centre tile must survive");
}
#[test]
fn merge_with_a_wrong_typed_new_dataset_returns_the_old_one_intact() {
let mut old_cache = cache_at(0.0, 0.0, 5.0);
let tile = MapTileId { z: 5, x: 1, y: 1 };
old_cache.mark_tile_ready(tile, AzString::from("<svg/>"));
let mut merged = merge_map_tile_cache(RefAny::new(0u32), RefAny::new(old_cache));
let cache = merged.downcast_ref::<MapTileCache>().expect("old cache");
assert_eq!(cache.viewport.zoom, 5.0, "no adoption from a bogus new dataset");
assert!(cache.tiles.contains_key(&tile));
}
#[test]
fn merge_with_a_wrong_typed_old_dataset_returns_it_unchanged() {
let new_cache = cache_at(0.0, 0.0, 9.0);
let mut merged = merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(7u64));
assert!(
merged.downcast_ref::<MapTileCache>().is_none(),
"the merge must not fabricate a cache out of a wrong-typed dataset"
);
assert_eq!(*merged.downcast_ref::<u64>().expect("u64 payload"), 7);
}
#[test]
fn merge_of_two_aliases_of_one_dataset_does_not_panic() {
let dataset = RefAny::new(cache_at(0.0, 0.0, 5.0));
let mut merged = merge_map_tile_cache(dataset.clone(), dataset);
let cache = merged.downcast_ref::<MapTileCache>().expect("cache");
assert_eq!(cache.viewport.zoom, 5.0);
}
#[test]
fn merge_adopts_the_worker_only_when_the_old_cache_has_none() {
let old_cache = cache_at(0.0, 0.0, 5.0);
let mut new_cache = cache_at(0.0, 0.0, 6.0);
new_cache.fetch_callback = Some(ThreadCallback::new(noop_worker));
let mut merged = merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(old_cache));
{
let cache = merged.downcast_ref::<MapTileCache>().expect("cache");
let cb = cache.fetch_callback.as_ref().expect("adopted worker");
assert_eq!(cb.cb as usize, noop_worker as ThreadCallbackType as usize);
}
let mut old_cache = cache_at(0.0, 0.0, 5.0);
old_cache.fetch_callback = Some(ThreadCallback::new(noop_worker));
let mut new_cache = cache_at(0.0, 0.0, 6.0);
new_cache.fetch_callback = Some(ThreadCallback::new(other_noop_worker));
let mut merged = merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(old_cache));
let cache = merged.downcast_ref::<MapTileCache>().expect("cache");
let cb = cache.fetch_callback.as_ref().expect("kept worker");
assert_eq!(cb.cb as usize, noop_worker as ThreadCallbackType as usize);
}
#[test]
fn merge_adopts_the_build_layer_and_viewport_even_when_degenerate() {
let old_cache = MapTileCache::new(layer_zoom(0, 19), view(10.0, 20.0, 5.0));
let new_cache =
MapTileCache::new(layer_zoom(3, 7), view(f64::NAN, f64::INFINITY, f32::NAN));
let mut merged = merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(old_cache));
let cache = merged.downcast_ref::<MapTileCache>().expect("cache");
assert!(cache.viewport.centre_lat_deg.is_nan());
assert!(cache.viewport.zoom.is_nan());
assert_eq!(cache.layer.min_zoom, 3);
assert_eq!(cache.layer.max_zoom, 7);
}
#[test]
fn build_tile_url_without_placeholders_is_the_identity() {
let tile = MapTileId { z: 1, x: 2, y: 3 };
assert_eq!(build_tile_url("", tile), "");
assert_eq!(build_tile_url("https://t.example/fixed", tile), "https://t.example/fixed");
assert_eq!(build_tile_url("{q}/{Z}/{ x }", tile), "{q}/{Z}/{ x }");
}
#[test]
fn build_tile_url_substitutes_extreme_tile_ids() {
let tile = MapTileId {
z: u8::MAX,
x: u32::MAX,
y: 0,
};
assert_eq!(build_tile_url("{z}/{x}/{y}", tile), "255/4294967295/0");
}
#[test]
fn build_tile_url_handles_unicode_and_unbalanced_braces() {
let tile = MapTileId { z: 7, x: 8, y: 9 };
assert_eq!(
build_tile_url("\u{1F600}/{z}/\u{4F60}\u{597D}/{y}", tile),
"\u{1F600}/7/\u{4F60}\u{597D}/9"
);
assert_eq!(build_tile_url("{{z}}", tile), "{7}");
assert_eq!(build_tile_url("{z", tile), "{z");
assert_eq!(build_tile_url("z}", tile), "z}");
}
#[test]
fn build_tile_url_substitution_is_not_re_scanned() {
let tile = MapTileId { z: 1, x: 2, y: 3 };
assert_eq!(build_tile_url("{z}{x}{y}{z}", tile), "1231");
}
#[test]
fn build_tile_url_extremely_long_template_does_not_hang() {
let template = "{z}/".repeat(100_000);
let url = build_tile_url(&template, MapTileId { z: 14, x: 0, y: 0 });
assert_eq!(url.len(), 100_000 * 3);
assert!(url.starts_with("14/14/"));
}
#[cfg(all(feature = "xml", feature = "cpurender"))]
const MINIMAL_TILE_SVG: &str =
r#"<svg viewBox="0 0 16 16"><rect x="0" y="0" width="16" height="16" fill="red"/></svg>"#;
#[cfg(all(feature = "xml", feature = "cpurender"))]
#[test]
fn svg_raster_valid_minimal_is_the_positive_control() {
assert!(
svg_string_to_dom(MINIMAL_TILE_SVG).is_some(),
"a well-formed <svg> must rasterise into a Dom"
);
}
#[cfg(all(feature = "xml", feature = "cpurender"))]
#[test]
fn svg_raster_empty_whitespace_and_garbage_return_none() {
for bad in [
"",
" ",
" \t\n\r ",
"garbage",
"<<<>>>",
"<svg",
"</svg>",
"<html><body/></html>",
"\u{0}\u{1}\u{2}",
] {
assert!(
svg_string_to_dom(bad).is_none(),
"{bad:?} must be rejected, not rendered"
);
}
}
#[cfg(all(feature = "xml", feature = "cpurender"))]
#[test]
fn svg_raster_boundary_numeric_attributes_do_not_panic() {
for svg in [
r#"<svg viewBox="0 0 16 16"><rect width="0" height="-0" fill="red"/></svg>"#,
r#"<svg viewBox="0 0 16 16"><rect width="NaN" height="inf" fill="red"/></svg>"#,
r#"<svg viewBox="0 0 16 16"><rect width="1e400" height="1e-400" fill="red"/></svg>"#,
r#"<svg viewBox="0 0 16 16"><rect width="9223372036854775807" height="8"/></svg>"#,
r#"<svg viewBox="0 0 0 0"><rect width="8" height="8" fill="red"/></svg>"#,
r#"<svg viewBox="NaN NaN NaN NaN"><rect width="8" height="8" fill="red"/></svg>"#,
] {
let _ = svg_string_to_dom(svg).is_some();
}
}
#[cfg(all(feature = "xml", feature = "cpurender"))]
#[test]
fn svg_raster_unicode_content_does_not_panic() {
let svg = "<svg viewBox=\"0 0 16 16\"><title>\u{1F600} \u{4F60}\u{597D} \
e\u{0301} \u{202E}</title><rect width=\"16\" height=\"16\" \
fill=\"red\"/></svg>";
assert!(svg_string_to_dom(svg).is_some());
}
#[cfg(all(feature = "xml", feature = "cpurender"))]
#[test]
fn svg_raster_leading_and_trailing_junk_is_deterministic() {
for svg in [
" <svg viewBox=\"0 0 8 8\"><rect width=\"8\" height=\"8\"/></svg> ",
"<svg viewBox=\"0 0 8 8\"><rect width=\"8\" height=\"8\"/></svg>;garbage",
"junk<svg viewBox=\"0 0 8 8\"><rect width=\"8\" height=\"8\"/></svg>",
] {
assert_eq!(
svg_string_to_dom(svg).is_some(),
svg_string_to_dom(svg).is_some(),
"{svg:?} parsed non-deterministically"
);
}
}
#[cfg(all(feature = "xml", feature = "cpurender"))]
#[test]
fn svg_raster_extremely_long_input_does_not_hang() {
let svg = alloc::format!(
"<svg viewBox=\"0 0 8 8\"><desc>{}</desc><rect width=\"8\" height=\"8\" \
fill=\"red\"/></svg>",
"a".repeat(1_000_000)
);
assert!(svg_string_to_dom(&svg).is_some());
}
#[cfg(all(feature = "xml", feature = "cpurender"))]
#[test]
fn svg_raster_deeply_nested_groups_do_not_stack_overflow() {
let ok = std::thread::Builder::new()
.stack_size(128 * 1024 * 1024)
.spawn(|| {
const DEPTH: usize = 2_000;
let svg = alloc::format!(
"<svg viewBox=\"0 0 8 8\">{}<rect width=\"8\" height=\"8\" \
fill=\"red\"/>{}</svg>",
"<g>".repeat(DEPTH),
"</g>".repeat(DEPTH)
);
svg_string_to_dom(&svg).is_some()
})
.expect("spawn")
.join()
.expect("2000-deep nesting must not overflow the stack");
assert!(ok);
}
#[cfg(all(feature = "xml", not(feature = "cpurender")))]
#[test]
fn svg_dom_valid_minimal_is_the_positive_control() {
assert!(svg_string_to_dom("<svg><g/></svg>").is_some());
}
#[cfg(all(feature = "xml", not(feature = "cpurender")))]
#[test]
fn svg_dom_malformed_markup_returns_none() {
for bad in ["<<<>>>", "<svg", "</svg>", "<a></b>", "\u{0}\u{1}"] {
assert!(svg_string_to_dom(bad).is_none(), "{bad:?} must be rejected");
}
}
#[cfg(all(feature = "xml", not(feature = "cpurender")))]
#[test]
fn svg_dom_empty_whitespace_and_unicode_do_not_panic() {
for input in ["", " ", " \t\n\r ", "\u{1F600}", "e\u{0301}"] {
assert_eq!(
svg_string_to_dom(input).is_some(),
svg_string_to_dom(input).is_some()
);
}
}
#[cfg(all(feature = "xml", not(feature = "cpurender")))]
#[test]
fn svg_dom_extremely_long_input_does_not_hang() {
let svg = alloc::format!("<svg><desc>{}</desc></svg>", "a".repeat(1_000_000));
let _ = svg_string_to_dom(&svg).is_some();
}
#[cfg(not(feature = "xml"))]
#[test]
fn svg_stub_returns_none_for_every_input() {
for input in ["", " ", "<svg/>", "<svg><g/></svg>", "\u{1F600}", "<<<>>>"] {
assert!(svg_string_to_dom(input).is_none());
}
let long = "a".repeat(1_000_000);
assert!(svg_string_to_dom(&long).is_none());
}
#[test]
fn invoke_viewport_changed_without_a_hook_is_do_nothing() {
let (update, changes) = with_callback_info(|info| {
invoke_viewport_changed(&OptionMapViewportChanged::None, &info, view(0.0, 0.0, 2.0))
});
assert_eq!(update, Update::DoNothing);
assert!(changes.is_empty());
}
#[test]
fn invoke_viewport_changed_forwards_even_a_nan_viewport() {
let mut log = RefAny::new(HookLog::default());
let hook = OptionMapViewportChanged::Some(MapViewportChanged {
refany: log.clone(),
callback: (record_viewport as MapViewportChangedCallbackType).into(),
});
let viewport = view(f64::NAN, f64::INFINITY, f32::NAN);
let (update, _) = with_callback_info(|info| invoke_viewport_changed(&hook, &info, viewport));
assert_eq!(update, Update::DoNothing);
assert_eq!(hook_log(&mut log), (1, 0));
}
#[test]
fn invoke_pin_tap_without_a_hook_is_do_nothing() {
let (update, _) = with_callback_info(|info| {
invoke_pin_tap(
&OptionMapPinTap::None,
&info,
MapLatLon {
lat_deg: 0.0,
lon_deg: 0.0,
},
)
});
assert_eq!(update, Update::DoNothing);
}
#[test]
fn invoke_pin_tap_returns_the_users_update_verbatim() {
let mut log = RefAny::new(HookLog::default());
let hook = OptionMapPinTap::Some(MapPinTap {
refany: log.clone(),
callback: (record_pin as MapPinTapCallbackType).into(),
});
let (update, _) = with_callback_info(|info| {
invoke_pin_tap(
&hook,
&info,
MapLatLon {
lat_deg: f64::NAN,
lon_deg: -1.0e300,
},
)
});
assert_eq!(update, Update::RefreshDom);
assert_eq!(hook_log(&mut log), (0, 1));
}
#[test]
fn pointer_down_without_a_cursor_is_a_no_op() {
let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
let (update, _) =
with_callback_info(|info| map_on_pointer_down(dataset.clone(), info));
assert_eq!(update, Update::DoNothing);
let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
assert!(cache.drag_anchor.is_none());
assert!(cache.press_origin.is_none());
}
#[test]
fn pointer_down_records_both_anchors() {
let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
let (update, _) = with_callback_info_at(cursor_at(120.0, 80.0), |info| {
map_on_pointer_down(dataset.clone(), info)
});
assert_eq!(update, Update::DoNothing);
let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
let anchor = cache.drag_anchor.expect("drag anchor");
let press = cache.press_origin.expect("press origin");
assert_eq!((anchor.x, anchor.y), (120.0, 80.0));
assert_eq!((press.x, press.y), (120.0, 80.0));
}
#[test]
fn pointer_down_on_a_wrong_typed_dataset_is_a_no_op() {
let dataset = RefAny::new(0u16);
let (update, _) = with_callback_info_at(cursor_at(1.0, 1.0), |info| {
map_on_pointer_down(dataset.clone(), info)
});
assert_eq!(update, Update::DoNothing);
}
#[test]
fn pointer_move_without_an_anchor_does_not_pan() {
let mut dataset = RefAny::new(cache_at(37.0, -122.0, 4.0));
let (update, _) = with_callback_info_at(cursor_at(500.0, 500.0), |info| {
map_on_pointer_move(dataset.clone(), info)
});
assert_eq!(update, Update::DoNothing);
let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
assert_eq!(cache.viewport.centre_lon_deg, -122.0);
assert_eq!(cache.viewport.centre_lat_deg, 37.0);
}
#[test]
fn pointer_move_pans_by_the_exact_mercator_delta_and_re_anchors() {
let mut cache = cache_at(0.0, 0.0, 2.0);
cache.drag_anchor = Some(LogicalPosition::new(100.0, 100.0));
let mut dataset = RefAny::new(cache);
let (_, changes) = with_callback_info_at(cursor_at(150.0, 100.0), |info| {
map_on_pointer_move(dataset.clone(), info)
});
let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
close(cache.viewport.centre_lon_deg, -50.0 * 360.0 / 1024.0, 1e-9);
close(cache.viewport.centre_lat_deg, 0.0, 1e-9);
let anchor = cache.drag_anchor.expect("anchor must follow the cursor");
assert_eq!((anchor.x, anchor.y), (150.0, 100.0));
drop(cache);
assert!(
changes
.iter()
.any(|c| matches!(c, CallbackChange::UpdateAllVirtualViews)),
"a pan must request a virtual-view re-render"
);
}
#[test]
fn pointer_move_ignores_sub_half_pixel_jitter() {
let mut cache = cache_at(10.0, 20.0, 4.0);
cache.drag_anchor = Some(LogicalPosition::new(100.0, 100.0));
let mut dataset = RefAny::new(cache);
let (update, _) = with_callback_info_at(cursor_at(100.4, 99.7), |info| {
map_on_pointer_move(dataset.clone(), info)
});
assert_eq!(update, Update::DoNothing);
let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
assert_eq!(cache.viewport.centre_lon_deg, 20.0, "jitter must not pan");
assert_eq!(cache.viewport.centre_lat_deg, 10.0);
}
#[test]
fn pointer_move_with_a_nan_viewport_does_not_panic() {
let mut cache = MapTileCache::new(layer_zoom(0, 19), view(f64::NAN, f64::NAN, f32::NAN));
cache.drag_anchor = Some(LogicalPosition::new(0.0, 0.0));
let mut dataset = RefAny::new(cache);
let (update, _) = with_callback_info_at(cursor_at(400.0, 400.0), |info| {
map_on_pointer_move(dataset.clone(), info)
});
assert_eq!(update, Update::DoNothing);
let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
assert!(cache.viewport.centre_lon_deg.is_nan());
}
#[test]
fn pointer_move_fires_the_viewport_hook_once_per_pan() {
let mut log = RefAny::new(HookLog::default());
let mut cache = cache_at(0.0, 0.0, 4.0);
cache.drag_anchor = Some(LogicalPosition::new(0.0, 0.0));
cache.on_viewport_changed = OptionMapViewportChanged::Some(MapViewportChanged {
refany: log.clone(),
callback: (record_viewport as MapViewportChangedCallbackType).into(),
});
let dataset = RefAny::new(cache);
let _ = with_callback_info_at(cursor_at(60.0, 60.0), |info| {
map_on_pointer_move(dataset.clone(), info)
});
assert_eq!(hook_log(&mut log), (1, 0));
}
#[test]
fn pointer_up_clears_every_gesture_anchor() {
let mut cache = cache_at(0.0, 0.0, 4.0);
cache.drag_anchor = Some(LogicalPosition::new(5.0, 5.0));
cache.pinch_anchor = Some(120.0);
cache.press_origin = Some(LogicalPosition::new(5.0, 5.0));
let mut dataset = RefAny::new(cache);
let (update, _) = with_callback_info_at(cursor_at(5.0, 5.0), |info| {
map_on_pointer_up(dataset.clone(), info)
});
assert_eq!(update, Update::DoNothing);
let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
assert!(cache.drag_anchor.is_none());
assert!(cache.pinch_anchor.is_none());
assert!(cache.press_origin.is_none());
}
#[test]
fn pointer_up_fires_pin_tap_for_a_tap_but_not_for_a_drag() {
let mut log = RefAny::new(HookLog::default());
let mut cache = cache_at(0.0, 0.0, 4.0);
cache.press_origin = Some(LogicalPosition::new(10.0, 10.0));
cache.on_pin_tap = OptionMapPinTap::Some(MapPinTap {
refany: log.clone(),
callback: (record_pin as MapPinTapCallbackType).into(),
});
let dataset = RefAny::new(cache);
let _ = with_callback_info_at(cursor_at(12.0, 12.0), |info| {
map_on_pointer_up(dataset.clone(), info)
});
assert_eq!(hook_log(&mut log), (0, 1), "a 2px release is a tap");
let mut log = RefAny::new(HookLog::default());
let mut cache = cache_at(0.0, 0.0, 4.0);
cache.press_origin = Some(LogicalPosition::new(10.0, 10.0));
cache.on_pin_tap = OptionMapPinTap::Some(MapPinTap {
refany: log.clone(),
callback: (record_pin as MapPinTapCallbackType).into(),
});
let dataset = RefAny::new(cache);
let _ = with_callback_info_at(cursor_at(100.0, 100.0), |info| {
map_on_pointer_up(dataset.clone(), info)
});
assert_eq!(hook_log(&mut log), (0, 0), "a 90px release is a drag");
}
#[test]
fn pointer_up_without_a_press_origin_never_taps() {
let mut log = RefAny::new(HookLog::default());
let mut cache = cache_at(0.0, 0.0, 4.0);
cache.on_pin_tap = OptionMapPinTap::Some(MapPinTap {
refany: log.clone(),
callback: (record_pin as MapPinTapCallbackType).into(),
});
let dataset = RefAny::new(cache);
let _ = with_callback_info_at(cursor_at(10.0, 10.0), |info| {
map_on_pointer_up(dataset.clone(), info)
});
assert_eq!(hook_log(&mut log), (0, 0));
}
#[test]
fn pointer_up_on_a_wrong_typed_dataset_is_a_no_op() {
let dataset = RefAny::new(MapViewport::default());
let (update, _) = with_callback_info_at(cursor_at(1.0, 1.0), |info| {
map_on_pointer_up(dataset.clone(), info)
});
assert_eq!(update, Update::DoNothing);
}
#[test]
fn scroll_without_a_wheel_delta_is_a_no_op() {
let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
let (update, changes) = with_callback_info(|info| map_on_scroll(dataset.clone(), info));
assert_eq!(update, Update::DoNothing);
assert!(changes.is_empty(), "a zero-delta scroll must queue nothing");
let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
assert_eq!(cache.viewport.zoom, 4.0);
assert!(cache.tiles.is_empty());
}
#[test]
fn scroll_on_a_wrong_typed_dataset_is_a_no_op() {
let dataset = RefAny::new(0u8);
let (update, _) = with_callback_info(|info| map_on_scroll(dataset.clone(), info));
assert_eq!(update, Update::DoNothing);
}
#[test]
fn spawn_pending_tile_fetches_is_a_no_op_without_a_worker() {
let mut cache = cache_at(0.0, 0.0, 4.0);
for x in 0..4u32 {
cache.tiles.insert(MapTileId { z: 4, x, y: 8 }, TileEntry::Pending);
}
let mut dataset = RefAny::new(cache);
let (_, changes) = with_callback_info(|info| {
let mut info = info;
spawn_pending_tile_fetches(&mut dataset.clone(), &mut info);
});
assert!(changes.is_empty(), "no worker → no threads queued");
let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
assert!(
cache.tiles.values().all(|e| matches!(e, TileEntry::Pending)),
"tiles must stay Pending so the placeholder grid renders"
);
}
#[test]
fn spawn_pending_tile_fetches_caps_the_burst_at_sixteen() {
let mut cache = cache_at(0.0, 0.0, 4.0);
cache.fetch_callback = Some(ThreadCallback::new(noop_worker));
for x in 0..20u32 {
cache.tiles.insert(MapTileId { z: 4, x, y: 8 }, TileEntry::Pending);
}
let mut dataset = RefAny::new(cache);
fn count_states(ds: &mut RefAny) -> (usize, usize) {
let cache = ds.downcast_ref::<MapTileCache>().expect("cache");
let fetching = cache
.tiles
.values()
.filter(|e| matches!(e, TileEntry::Fetching))
.count();
let pending = cache
.tiles
.values()
.filter(|e| matches!(e, TileEntry::Pending))
.count();
(fetching, pending)
}
let (_, changes) = with_callback_info(|info| {
let mut info = info;
spawn_pending_tile_fetches(&mut dataset.clone(), &mut info);
});
assert_eq!(count_states(&mut dataset), (16, 4), "one call spawns at most 16");
assert_eq!(
changes
.iter()
.filter(|c| matches!(c, CallbackChange::AddThread { .. }))
.count(),
16
);
let _ = with_callback_info(|info| {
let mut info = info;
spawn_pending_tile_fetches(&mut dataset.clone(), &mut info);
});
assert_eq!(count_states(&mut dataset), (20, 0));
}
#[test]
fn spawn_pending_tile_fetches_on_a_wrong_typed_dataset_is_a_no_op() {
let mut dataset = RefAny::new(1234u32);
let (_, changes) = with_callback_info(|info| {
let mut info = info;
spawn_pending_tile_fetches(&mut dataset, &mut info);
});
assert!(changes.is_empty());
}
#[test]
fn tile_writeback_marks_ready_on_an_empty_error_and_failed_otherwise() {
let tile = MapTileId { z: 4, x: 1, y: 2 };
let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
let ok = RefAny::new(TileReadyMsg {
tile,
svg: AzString::from("<svg/>"),
error: AzString::from(""),
});
let (update, changes) = with_callback_info(|info| {
map_tile_writeback(dataset.clone(), ok.clone(), info)
});
assert_eq!(update, Update::DoNothing);
assert!(changes
.iter()
.any(|c| matches!(c, CallbackChange::UpdateAllVirtualViews)));
{
let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
assert!(matches!(cache.tiles.get(&tile), Some(TileEntry::Ready { .. })));
}
let failed = RefAny::new(TileReadyMsg {
tile,
svg: AzString::from(""),
error: AzString::from("404"),
});
let (update, _) = with_callback_info(|info| {
map_tile_writeback(dataset.clone(), failed.clone(), info)
});
assert_eq!(update, Update::DoNothing);
let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
assert!(matches!(cache.tiles.get(&tile), Some(TileEntry::Failed { .. })));
}
#[test]
fn tile_writeback_accepts_a_huge_payload_and_an_out_of_range_id() {
let tile = MapTileId {
z: 31,
x: u32::MAX,
y: u32::MAX,
};
let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
let msg = RefAny::new(TileReadyMsg {
tile,
svg: AzString::from("<svg/>".repeat(50_000)),
error: AzString::from(""),
});
let (update, _) =
with_callback_info(|info| map_tile_writeback(dataset.clone(), msg.clone(), info));
assert_eq!(update, Update::DoNothing);
let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
assert!(cache.tiles.contains_key(&tile));
}
#[test]
fn tile_writeback_with_a_wrong_typed_message_is_a_no_op() {
let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
let (update, changes) = with_callback_info(|info| {
map_tile_writeback(dataset.clone(), RefAny::new(0u32), info)
});
assert_eq!(update, Update::DoNothing);
assert!(changes.is_empty(), "a bogus message must not force a re-render");
let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
assert!(cache.tiles.is_empty());
}
#[test]
fn tile_writeback_with_a_wrong_typed_cache_is_a_no_op() {
let msg = RefAny::new(TileReadyMsg {
tile: MapTileId { z: 1, x: 0, y: 0 },
svg: AzString::from("<svg/>"),
error: AzString::from(""),
});
let (update, changes) = with_callback_info(|info| {
map_tile_writeback(RefAny::new(9u64), msg.clone(), info)
});
assert_eq!(update, Update::DoNothing);
assert!(changes.is_empty());
}
#[test]
fn after_mount_installs_the_sweep_timer_and_asks_for_a_re_render() {
let dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
let (update, changes) =
with_callback_info(|info| map_on_after_mount(dataset.clone(), info));
assert_eq!(update, Update::DoNothing);
assert_eq!(
changes
.iter()
.filter(|c| matches!(c, CallbackChange::AddTimer { .. }))
.count(),
1,
"exactly one sweep timer per mount"
);
assert!(changes
.iter()
.any(|c| matches!(c, CallbackChange::UpdateAllVirtualViews)));
}
#[test]
fn after_mount_on_a_wrong_typed_dataset_still_installs_the_timer() {
let dataset = RefAny::new(0u8);
let (update, changes) =
with_callback_info(|info| map_on_after_mount(dataset.clone(), info));
assert_eq!(update, Update::DoNothing);
assert!(changes
.iter()
.any(|c| matches!(c, CallbackChange::AddTimer { .. })));
}
#[test]
fn render_with_non_finite_or_empty_bounds_emits_no_dom() {
let dataset = RefAny::new(cache_at(0.0, 0.0, 3.0));
for (w, h) in [
(0.0_f32, 0.0_f32),
(0.0, 600.0),
(800.0, 0.0),
(-800.0, -600.0),
(f32::NAN, 600.0),
(800.0, f32::NAN),
(f32::INFINITY, 600.0),
(800.0, f32::NEG_INFINITY),
] {
let ret =
with_virtual_view_info(w, h, |info| map_widget_render(dataset.clone(), info));
assert!(
rendered_child_count(&ret).is_none(),
"bounds {w}x{h} must render nothing until layout settles"
);
}
}
#[test]
fn render_with_a_wrong_typed_dataset_emits_no_dom() {
let dataset = RefAny::new(0u32);
let ret =
with_virtual_view_info(800.0, 600.0, |info| map_widget_render(dataset.clone(), info));
assert!(rendered_child_count(&ret).is_none());
}
#[test]
fn render_marks_every_visible_tile_pending_and_emits_one_div_each() {
let mut dataset = RefAny::new(cache_at(0.0, 0.0, 2.0));
let expected = map_visible_tiles(
&view(0.0, 0.0, 2.0),
LogicalSize::new(800.0, 600.0),
&layer_zoom(0, 19),
);
let ret =
with_virtual_view_info(800.0, 600.0, |info| map_widget_render(dataset.clone(), info));
assert_eq!(
rendered_child_count(&ret),
Some(expected.len()),
"one div per visible tile"
);
let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
for tile in &expected {
assert!(
matches!(cache.tiles.get(tile), Some(TileEntry::Pending)),
"tile {tile:?} must be queued by the render pass"
);
}
}
#[test]
fn render_reports_the_bounds_back_as_the_scroll_size() {
let dataset = RefAny::new(cache_at(0.0, 0.0, 2.0));
let ret =
with_virtual_view_info(640.0, 480.0, |info| map_widget_render(dataset.clone(), info));
assert_eq!(ret.scroll_size.width, 640.0);
assert_eq!(ret.scroll_size.height, 480.0);
assert_eq!(ret.virtual_scroll_size.width, 640.0);
assert_eq!(ret.virtual_scroll_size.height, 480.0);
assert_eq!((ret.scroll_offset.x, ret.scroll_offset.y), (0.0, 0.0));
assert_eq!(
(ret.virtual_scroll_offset.x, ret.virtual_scroll_offset.y),
(0.0, 0.0)
);
}
#[test]
fn render_falls_back_to_a_glyph_when_a_ready_tile_holds_garbage() {
let mut cache = cache_at(0.0, 0.0, 2.0);
for tile in map_visible_tiles(
&view(0.0, 0.0, 2.0),
LogicalSize::new(512.0, 512.0),
&layer_zoom(0, 19),
) {
cache.mark_tile_ready(tile, AzString::from("not xml at all <<<"));
}
let dataset = RefAny::new(cache);
let ret =
with_virtual_view_info(512.0, 512.0, |info| map_widget_render(dataset.clone(), info));
assert!(
rendered_child_count(&ret).is_some_and(|n| n > 0),
"garbage tiles still render their placeholder"
);
}
#[test]
fn render_handles_mixed_tile_states_including_failures() {
let mut cache = cache_at(0.0, 0.0, 2.0);
let tiles = map_visible_tiles(
&view(0.0, 0.0, 2.0),
LogicalSize::new(512.0, 512.0),
&layer_zoom(0, 19),
);
for (i, tile) in tiles.iter().enumerate() {
match i % 4 {
0 => {
cache.tiles.insert(*tile, TileEntry::Pending);
}
1 => {
cache.tiles.insert(*tile, TileEntry::Fetching);
}
2 => cache.mark_tile_failed(*tile, AzString::from("\u{1F600} failed")),
_ => cache.mark_tile_ready(*tile, AzString::from("")),
}
}
let dataset = RefAny::new(cache);
let ret =
with_virtual_view_info(512.0, 512.0, |info| map_widget_render(dataset.clone(), info));
assert_eq!(rendered_child_count(&ret), Some(tiles.len()));
}
#[test]
fn render_clamps_an_out_of_band_zoom_and_stays_bounded() {
for zoom in [0.0_f32, 1.0, 3.0, 25.0, f32::INFINITY] {
let cache = MapTileCache::new(layer_zoom(3, 3), view(0.0, 0.0, zoom));
let dataset = RefAny::new(cache);
let ret = with_virtual_view_info(800.0, 600.0, |info| {
map_widget_render(dataset.clone(), info)
});
let n = rendered_child_count(&ret).expect("a finite box must render");
assert!(n > 0 && n < 4096, "zoom {zoom} produced {n} tiles");
}
}
#[test]
fn render_is_stable_across_repeated_invocations() {
let dataset = RefAny::new(cache_at(48.1372, 11.5756, 5.0));
let first =
with_virtual_view_info(800.0, 600.0, |info| map_widget_render(dataset.clone(), info));
let second =
with_virtual_view_info(800.0, 600.0, |info| map_widget_render(dataset.clone(), info));
assert_eq!(rendered_child_count(&first), rendered_child_count(&second));
}
}