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) - screen_x).max(1);
let size_h = (proj(y as f32 + 1.0, centre_y, height_px) - 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");
}
}