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)]
#[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://openfreemap.org/example/{z}/{x}/{y}.pbf",
),
min_zoom: 0,
max_zoom: 14,
attribution: AzString::from("© OpenStreetMap contributors, ODbL"),
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 {
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,
}
}
pub fn with_viewport(mut self, viewport: MapViewport) -> Self {
self.viewport = viewport;
self
}
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();
}
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();
}
pub fn with_on_pin_tap<C: Into<MapPinTapCallback>>(
mut self,
data: RefAny,
callback: C,
) -> Self {
self.set_on_pin_tap(data, callback);
self
}
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(viewport.zoom as f64);
let dx = (px.x - container.width * 0.5) as f64;
let dy = (px.y - container.height * 0.5) as f64;
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,
}
}
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(viewport.zoom as f64);
let cos_lat = viewport.centre_lat_deg.to_radians().cos();
let px = container.width as f64 * 0.5
+ (coord.lon_deg - viewport.centre_lon_deg) * world / 360.0;
let py = container.height as f64 * 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)
}
pub fn dom(self) -> Dom {
self.build_dom(None)
}
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();
Dom::create_div()
.with_dataset(OptionRefAny::Some(dataset.clone()))
.with_merge_callback(merge_map_tile_cache as DatasetMergeCallbackType)
.with_callback(
EventFilter::Component(ComponentEventFilter::AfterMount),
dataset.clone(),
crate::callbacks::Callback::from(map_on_after_mount as crate::callbacks::CallbackType),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::MouseDown),
dataset.clone(),
crate::callbacks::Callback::from(map_on_pointer_down as crate::callbacks::CallbackType),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::MouseOver),
dataset.clone(),
crate::callbacks::Callback::from(map_on_pointer_move as crate::callbacks::CallbackType),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::MouseUp),
dataset.clone(),
crate::callbacks::Callback::from(map_on_pointer_up as crate::callbacks::CallbackType),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::MouseLeave),
dataset.clone(),
crate::callbacks::Callback::from(map_on_pointer_up as crate::callbacks::CallbackType),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::TouchStart),
dataset.clone(),
crate::callbacks::Callback::from(map_on_pointer_down as crate::callbacks::CallbackType),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::TouchMove),
dataset.clone(),
crate::callbacks::Callback::from(map_on_pointer_move as crate::callbacks::CallbackType),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::TouchEnd),
dataset.clone(),
crate::callbacks::Callback::from(map_on_pointer_up as crate::callbacks::CallbackType),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::TouchCancel),
dataset.clone(),
crate::callbacks::Callback::from(map_on_pointer_up as crate::callbacks::CallbackType),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::PinchIn),
dataset.clone(),
crate::callbacks::Callback::from(map_on_pointer_move as crate::callbacks::CallbackType),
)
.with_callback(
EventFilter::Hover(HoverEventFilter::PinchOut),
dataset,
crate::callbacks::Callback::from(map_on_pointer_move as crate::callbacks::CallbackType),
)
.with_child(Dom::create_virtual_view(
virtual_view_data,
map_widget_render as azul_core::callbacks::VirtualViewCallbackType,
))
}
}
#[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 {
pub 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 });
}
}
#[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_guard_opt = new_data.downcast_mut::<MapTileCache>();
let old_guard_opt = old_data.downcast_ref::<MapTileCache>();
if let (Some(mut new_g), Some(old_g)) = (new_guard_opt, old_guard_opt) {
for (id, entry) in old_g.tiles.iter() {
new_g.tiles.entry(*id).or_insert_with(|| entry.clone());
}
if new_g.fetch_callback.is_none() {
new_g.fetch_callback = old_g.fetch_callback.clone();
}
if let OptionMapViewportChanged::None = new_g.on_viewport_changed {
new_g.on_viewport_changed = old_g.on_viewport_changed.clone();
}
if let OptionMapPinTap::None = new_g.on_pin_tap {
new_g.on_pin_tap = old_g.on_pin_tap.clone();
}
}
}
new_data
}
use crate::callbacks::CallbackInfo;
use azul_core::callbacks::Update;
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.clone(), 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.clone(), coord),
OptionMapPinTap::None => Update::DoNothing,
}
}
extern "C" fn map_on_pointer_down(mut data: RefAny, info: CallbackInfo) -> Update {
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
}
extern "C" fn map_on_pointer_move(mut data: RefAny, info: CallbackInfo) -> Update {
if let Some(pinch) = info.get_pinch().into_option() {
let mut cache = match data.downcast_mut::<MapTileCache>() {
Some(c) => c,
None => 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 = cache.layer.min_zoom as f32;
let max = cache.layer.max_zoom as f32;
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);
return Update::RefreshDom;
}
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 mut cache_guard = match data.downcast_mut::<MapTileCache>() {
Some(c) => c,
None => return Update::DoNothing,
};
let anchor = match cache_guard.drag_anchor {
Some(a) => a,
None => return Update::DoNothing, };
let dx_px = (pos.x - anchor.x) as f64;
let dy_px = (pos.y - anchor.y) as f64;
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,
cache_guard.viewport.zoom as f64,
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);
Update::RefreshDom
}
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(|r| r.size)
.unwrap_or(azul_core::geom::LogicalSize::new(0.0, 0.0));
let (press, viewport, hook) = match data.downcast_mut::<MapTileCache>() {
Some(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
}
None => (None, MapViewport::default(), OptionMapPinTap::None),
};
if let (Some(origin), Some(up)) = (press, up_pos) {
let dx = (up.x - origin.x) as f64;
let dy = (up.y - origin.y) as f64;
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);
Update::RefreshDom
}
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)]
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()
}
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(feature = "xml")]
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 {
spawn_pending_tile_fetches(&mut data, &mut info);
Update::RefreshDom
}
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 mut cache = match data.downcast_mut::<MapTileCache>() {
Some(c) => c,
None => 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(),
});
}
}
let cb = {
let cache = match data.downcast_ref::<MapTileCache>() {
Some(c) => c,
None => return,
};
match cache.fetch_callback.as_ref() {
Some(cb) => cb.clone(),
None => return,
}
};
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);
}
}
fn build_tile_url(template: &str, tile: MapTileId) -> alloc::string::String {
use alloc::string::ToString;
template
.replace("{z}", &tile.z.to_string())
.replace("{x}", &tile.x.to_string())
.replace("{y}", &tile.y.to_string())
}
pub extern "C" fn map_tile_writeback(
mut cache_dataset: RefAny,
mut incoming: RefAny,
_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 mut cache = match cache_dataset.downcast_mut::<MapTileCache>() {
Some(c) => c,
None => return Update::DoNothing,
};
if msg.2.as_str().is_empty() {
cache.mark_tile_ready(msg.0, msg.1);
} else {
cache.mark_tile_failed(msg.0, msg.2);
}
Update::RefreshDom
}
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).max(0);
let x_max = ((centre_x + half_w).ceil() as i32).min(max_idx);
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)
}
extern "C" fn map_widget_render(
data: RefAny,
info: VirtualViewCallbackInfo,
) -> VirtualViewReturn {
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;
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(layer.min_zoom as i32, layer.max_zoom as i32)
as u8;
let tile_count = 1u32 << z_int as u32;
let frac_zoom = viewport.zoom - z_int as f32;
let zoom_scale = 2.0_f32.powf(frac_zoom);
let centre_x = lon_to_tile_x(viewport.centre_lon_deg, tile_count as f64) as f32;
let centre_y = lat_to_tile_y(viewport.centre_lat_deg, tile_count as f64) 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 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: x as u32,
y: y as u32,
};
cache.tiles.entry(id).or_insert(TileEntry::Pending);
}
}
}
enum TileDisplay {
Glyph(&'static str),
Svg(AzString),
}
let states: BTreeMap<MapTileId, TileDisplay> = match data.downcast_ref::<MapTileCache>() {
Some(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(),
None => BTreeMap::new(),
};
let mut grid = Dom::create_div().with_css(
"position: absolute; left: 0; top: 0; width: 100%; height: 100%; overflow: hidden;",
);
for x in x_min..=x_max {
for y in y_min..=y_max {
let id = MapTileId {
z: z_int,
x: x as u32,
y: y as u32,
};
let screen_x =
((x as f32 - centre_x) * tile_px + width_px * 0.5).round() as i32;
let screen_y =
((y as f32 - centre_y) * tile_px + height_px * 0.5).round() as i32;
let size_px = tile_px.round().max(1.0) as i32;
let style = alloc::format!(
"position: absolute; left: {}px; top: {}px; \
width: {}px; height: {}px; \
background: #e7e9ec; border: 1px solid #d0d4d9;",
screen_x, screen_y, size_px, size_px
);
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!("{} z{}/{}/{}", state_tag, 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]
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!(lat_north <= 85.0 && lat_north >= -85.0);
let (_, lat_south) = pan_viewport(-84.0, 0.0, 0.0, 0.0, -1.0e6);
assert!(lat_south <= 85.0 && lat_south >= -85.0);
}
#[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_preserves_old_tiles_and_keeps_new_viewport() {
let tile = MapTileId { z: 5, x: 1, y: 2 };
let mut old_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(5.0));
old_cache.mark_tile_ready(tile, AzString::from("<svg/>"));
let new_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(9.0));
let mut merged =
merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(old_cache));
let g = merged.downcast_ref::<MapTileCache>().unwrap();
assert!(g.tiles.contains_key(&tile), "old tile must survive relayout");
approx(g.viewport.zoom as f64, 9.0, 1e-6);
}
#[test]
fn merge_keeps_new_tile_over_old() {
let tile = MapTileId { z: 5, x: 1, y: 2 };
let mut old_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(5.0));
old_cache.mark_tile_ready(tile, AzString::from("OLD"));
let mut new_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(5.0));
new_cache.mark_tile_ready(tile, AzString::from("NEW"));
let mut merged =
merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(old_cache));
let g = merged.downcast_ref::<MapTileCache>().unwrap();
match g.tiles.get(&tile) {
Some(TileEntry::Ready { svg }) => {
assert_eq!(svg.as_str(), "NEW", "new frame's tile must not be clobbered");
}
other => panic!("expected Ready, got {other:?}"),
}
}
#[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 tile_range_clamps_to_single_tile_world_at_zoom0() {
let (x0, x1, y0, y1) = visible_tile_range(0.5, 0.5, 256.0, 256.0, 1.0, 1);
assert_eq!((x0, x1, y0, y1), (0, 0, 0, 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_at_grid_edges() {
let (x0, _, y0, _) = visible_tile_range(0.0, 0.0, 512.0, 512.0, 1.0, 16);
assert!(x0 >= 0 && y0 >= 0);
let (_, x1, _, y1) = visible_tile_range(15.0, 15.0, 512.0, 512.0, 1.0, 16);
assert!(x1 <= 15 && y1 <= 15);
}
}