1use alloc::collections::btree_map::BTreeMap;
40
41use azul_core::callbacks::{
42 VirtualViewCallback, VirtualViewCallbackInfo, VirtualViewReturn,
43};
44use azul_core::dom::{DatasetMergeCallbackType, Dom, OptionDom};
45use azul_core::refany::{OptionRefAny, RefAny};
46use azul_css::dynamic_selector::CssPropertyWithConditionsVec;
47use azul_css::impl_option_inner; use azul_css::AzString;
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
55#[repr(C)]
56pub struct MapTileId {
57 pub z: u8,
60 pub x: u32,
62 pub y: u32,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
70#[repr(C)]
71pub struct MapTileLayer {
72 pub url_template: AzString,
75 pub min_zoom: u8,
77 pub max_zoom: u8,
79 pub attribution: AzString,
82 pub style_css: AzString,
89}
90
91impl Default for MapTileLayer {
92 fn default() -> Self {
93 Self {
94 url_template: AzString::from(
104 "https://tiles.openfreemap.org/planet/20260531_080002_pt/{z}/{x}/{y}.pbf",
105 ),
106 min_zoom: 0,
107 max_zoom: 14,
108 attribution: AzString::from(
109 "© OpenFreeMap © OpenMapTiles · Data © OpenStreetMap contributors",
110 ),
111 style_css: AzString::from(""),
112 }
113 }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq)]
120#[repr(C)]
121pub struct MapViewport {
122 pub centre_lat_deg: f64,
123 pub centre_lon_deg: f64,
124 pub zoom: f32,
125 pub bearing_deg: f32,
126 pub pitch_deg: f32,
127}
128
129impl Default for MapViewport {
130 fn default() -> Self {
131 Self {
134 centre_lat_deg: 0.0,
135 centre_lon_deg: 0.0,
136 zoom: 2.0,
137 bearing_deg: 0.0,
138 pitch_deg: 0.0,
139 }
140 }
141}
142
143#[derive(Debug, Clone, Copy, PartialEq)]
146#[repr(C)]
147pub struct MapLatLon {
148 pub lat_deg: f64,
149 pub lon_deg: f64,
150}
151
152#[derive(Debug, Clone, PartialEq)]
160#[repr(C)]
161pub struct MapWidget {
162 pub layer: MapTileLayer,
163 pub viewport: MapViewport,
164 pub container_style: CssPropertyWithConditionsVec,
165 pub on_viewport_changed: OptionMapViewportChanged,
168 pub on_pin_tap: OptionMapPinTap,
171}
172
173impl MapWidget {
174 #[must_use] pub fn create(layer: MapTileLayer) -> Self {
175 Self {
176 layer,
177 viewport: MapViewport::default(),
178 container_style: CssPropertyWithConditionsVec::from_const_slice(&[]),
179 on_viewport_changed: OptionMapViewportChanged::None,
180 on_pin_tap: OptionMapPinTap::None,
181 }
182 }
183
184 #[must_use] pub const fn with_viewport(mut self, viewport: MapViewport) -> Self {
185 self.viewport = viewport;
186 self
187 }
188
189 #[must_use] pub fn with_container_style(mut self, css: CssPropertyWithConditionsVec) -> Self {
190 self.container_style = css;
191 self
192 }
193
194 pub fn set_on_viewport_changed<C: Into<MapViewportChangedCallback>>(
198 &mut self,
199 data: RefAny,
200 callback: C,
201 ) {
202 self.on_viewport_changed = Some(MapViewportChanged {
203 refany: data,
204 callback: callback.into(),
205 })
206 .into();
207 }
208
209 #[must_use]
211 pub fn with_on_viewport_changed<C: Into<MapViewportChangedCallback>>(
212 mut self,
213 data: RefAny,
214 callback: C,
215 ) -> Self {
216 self.set_on_viewport_changed(data, callback);
217 self
218 }
219
220 pub fn set_on_pin_tap<C: Into<MapPinTapCallback>>(&mut self, data: RefAny, callback: C) {
224 self.on_pin_tap = Some(MapPinTap {
225 refany: data,
226 callback: callback.into(),
227 })
228 .into();
229 }
230
231 #[must_use]
233 pub fn with_on_pin_tap<C: Into<MapPinTapCallback>>(
234 mut self,
235 data: RefAny,
236 callback: C,
237 ) -> Self {
238 self.set_on_pin_tap(data, callback);
239 self
240 }
241
242 #[allow(clippy::suboptimal_flops)] #[must_use] pub fn latlon_at_px(
249 viewport: MapViewport,
250 px: azul_core::geom::LogicalPosition,
251 container: azul_core::geom::LogicalSize,
252 ) -> MapLatLon {
253 let world = 256.0_f64 * 2.0_f64.powf(f64::from(viewport.zoom));
254 let dx = f64::from(px.x - container.width * 0.5);
255 let dy = f64::from(px.y - container.height * 0.5);
256 let lon = (viewport.centre_lon_deg + dx * 360.0 / world).clamp(-180.0, 180.0);
257 let cos_lat = viewport.centre_lat_deg.to_radians().cos();
258 let lat = (viewport.centre_lat_deg - dy * 360.0 / world * cos_lat).clamp(-85.0, 85.0);
259 MapLatLon {
260 lat_deg: lat,
261 lon_deg: lon,
262 }
263 }
264
265 #[allow(clippy::suboptimal_flops)] #[allow(clippy::cast_possible_truncation)] #[must_use] pub fn px_at_latlon(
270 viewport: MapViewport,
271 coord: MapLatLon,
272 container: azul_core::geom::LogicalSize,
273 ) -> azul_core::geom::LogicalPosition {
274 let world = 256.0_f64 * 2.0_f64.powf(f64::from(viewport.zoom));
275 let cos_lat = viewport.centre_lat_deg.to_radians().cos();
276 let px = f64::from(container.width) * 0.5
277 + (coord.lon_deg - viewport.centre_lon_deg) * world / 360.0;
278 let py = f64::from(container.height) * 0.5
279 - (coord.lat_deg - viewport.centre_lat_deg) * world / (360.0 * cos_lat);
280 azul_core::geom::LogicalPosition::new(px as f32, py as f32)
281 }
282
283 #[must_use] pub fn dom(self) -> Dom {
299 self.build_dom(None)
300 }
301
302 #[must_use] pub fn dom_with_fetch(self, cb: crate::thread::ThreadCallback) -> Dom {
311 self.build_dom(Some(cb))
312 }
313
314 fn build_dom(self, fetch_cb: Option<crate::thread::ThreadCallback>) -> Dom {
315 use azul_core::dom::{ComponentEventFilter, EventFilter, HoverEventFilter};
316
317 let mut cache = MapTileCache::new(self.layer.clone(), self.viewport);
318 cache.fetch_callback = fetch_cb;
319 cache.on_viewport_changed = self.on_viewport_changed;
320 cache.on_pin_tap = self.on_pin_tap;
321 let dataset = RefAny::new(cache);
322 let virtual_view_data = dataset.clone();
323
324 let root = Dom::create_div()
325 .with_css("position: absolute; top: 0; left: 0; right: 0; bottom: 0; overflow: hidden;")
335 .with_dataset(OptionRefAny::Some(dataset.clone()))
336 .with_merge_callback(azul_core::dom::DatasetMergeCallback::from_ptr(merge_map_tile_cache))
337 .with_callback(
343 EventFilter::Component(ComponentEventFilter::AfterMount),
344 dataset.clone(),
345 crate::callbacks::Callback::from_ptr(map_on_after_mount),
346 )
347 .with_callback(
348 EventFilter::Hover(HoverEventFilter::MouseDown),
349 dataset.clone(),
350 crate::callbacks::Callback::from_ptr(map_on_pointer_down),
351 )
352 .with_callback(
353 EventFilter::Hover(HoverEventFilter::MouseOver),
354 dataset.clone(),
355 crate::callbacks::Callback::from_ptr(map_on_pointer_move),
356 )
357 .with_callback(
358 EventFilter::Hover(HoverEventFilter::MouseUp),
359 dataset.clone(),
360 crate::callbacks::Callback::from_ptr(map_on_pointer_up),
361 )
362 .with_callback(
363 EventFilter::Hover(HoverEventFilter::MouseLeave),
364 dataset.clone(),
365 crate::callbacks::Callback::from_ptr(map_on_pointer_up),
366 )
367 .with_callback(
368 EventFilter::Hover(HoverEventFilter::TouchStart),
369 dataset.clone(),
370 crate::callbacks::Callback::from_ptr(map_on_pointer_down),
371 )
372 .with_callback(
373 EventFilter::Hover(HoverEventFilter::TouchMove),
374 dataset.clone(),
375 crate::callbacks::Callback::from_ptr(map_on_pointer_move),
376 )
377 .with_callback(
378 EventFilter::Hover(HoverEventFilter::TouchEnd),
379 dataset.clone(),
380 crate::callbacks::Callback::from_ptr(map_on_pointer_up),
381 )
382 .with_callback(
383 EventFilter::Hover(HoverEventFilter::TouchCancel),
384 dataset.clone(),
385 crate::callbacks::Callback::from_ptr(map_on_pointer_up),
386 )
387 .with_callback(
392 EventFilter::Hover(HoverEventFilter::PinchIn),
393 dataset.clone(),
394 crate::callbacks::Callback::from_ptr(map_on_pointer_move),
395 )
396 .with_callback(
397 EventFilter::Hover(HoverEventFilter::PinchOut),
398 dataset,
399 crate::callbacks::Callback::from_ptr(map_on_pointer_move),
400 )
401 .with_child(
402 Dom::create_virtual_view(
403 virtual_view_data,
404 azul_core::callbacks::VirtualViewCallback::create(map_widget_render),
405 )
406 .with_css("width: 100%; height: 100%; overflow: hidden;"),
412 );
413
414 if self.container_style.as_slice().is_empty() {
417 root
418 } else {
419 root.with_css_props(self.container_style)
420 }
421 }
422}
423
424#[derive(Debug)]
427pub struct MapTileCache {
428 pub layer: MapTileLayer,
429 pub viewport: MapViewport,
430 pub tiles: BTreeMap<MapTileId, TileEntry>,
435 pub fetch_callback: Option<crate::thread::ThreadCallback>,
444 pub drag_anchor: Option<azul_core::geom::LogicalPosition>,
450 pub pinch_anchor: Option<f32>,
458 pub on_viewport_changed: OptionMapViewportChanged,
461 pub press_origin: Option<azul_core::geom::LogicalPosition>,
464 pub on_pin_tap: OptionMapPinTap,
467}
468
469impl MapTileCache {
470 #[must_use] pub const fn new(layer: MapTileLayer, viewport: MapViewport) -> Self {
471 Self {
472 layer,
473 viewport,
474 tiles: BTreeMap::new(),
475 fetch_callback: None,
476 drag_anchor: None,
477 pinch_anchor: None,
478 press_origin: None,
479 on_viewport_changed: OptionMapViewportChanged::None,
480 on_pin_tap: OptionMapPinTap::None,
481 }
482 }
483
484 pub fn mark_tile_ready(&mut self, tile: MapTileId, svg: AzString) {
487 self.tiles.insert(tile, TileEntry::Ready { svg });
488 }
489
490 pub fn mark_tile_failed(&mut self, tile: MapTileId, error: AzString) {
493 self.tiles.insert(tile, TileEntry::Failed { error });
494 }
495
496 #[allow(clippy::suboptimal_flops)] #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] pub fn prune_distant_tiles(&mut self) {
512 const MAX_CACHED_TILES: usize = 192;
513 if self.tiles.len() <= MAX_CACHED_TILES {
514 return;
515 }
516
517 let z = (self.viewport.zoom.floor() as i32)
518 .clamp(i32::from(self.layer.min_zoom), i32::from(self.layer.max_zoom))
519 as u8;
520 let tile_count = 1u32 << u32::from(z);
521 let cx = lon_to_tile_x(self.viewport.centre_lon_deg, f64::from(tile_count));
522 let cy = lat_to_tile_y(self.viewport.centre_lat_deg, f64::from(tile_count));
523
524 let score = |id: &MapTileId| -> f64 {
526 let zt_count = 1u32 << u32::from(id.z);
527 let scale = f64::from(tile_count) / f64::from(zt_count);
530 let tx = (f64::from(id.x) + 0.5) * scale;
531 let ty = (f64::from(id.y) + 0.5) * scale;
532 let dz = f64::from((i32::from(id.z) - i32::from(z)).abs());
533 let dx = tx - cx;
534 let dy = ty - cy;
535 dz * 10_000.0 + dx * dx + dy * dy
536 };
537
538 let mut evictable: Vec<(f64, MapTileId)> = self
539 .tiles
540 .iter()
541 .filter(|(_, e)| !matches!(e, TileEntry::Pending | TileEntry::Fetching))
542 .map(|(id, _)| (score(id), *id))
543 .collect();
544 evictable.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(core::cmp::Ordering::Equal));
546
547 let mut to_remove = self.tiles.len().saturating_sub(MAX_CACHED_TILES);
548 for (_, id) in evictable {
549 if to_remove == 0 {
550 break;
551 }
552 self.tiles.remove(&id);
553 to_remove -= 1;
554 }
555 }
556}
557
558#[derive(Debug, Clone)]
559pub enum TileEntry {
560 Pending,
562 Fetching,
565 Ready { svg: AzString },
570 Failed { error: AzString },
574}
575
576#[derive(Debug, Clone)]
580pub struct TileFetchInit {
581 pub tile: MapTileId,
582 pub url: AzString,
583 pub style_css: AzString,
585}
586
587#[derive(Debug, Clone)]
591pub struct TileReadyMsg {
592 pub tile: MapTileId,
593 pub svg: AzString,
596 pub error: AzString,
598}
599
600extern "C" fn merge_map_tile_cache(mut new_data: RefAny, mut old_data: RefAny) -> RefAny {
606 {
631 let new_g = new_data.downcast_ref::<MapTileCache>();
632 let old_guard = old_data.downcast_mut::<MapTileCache>();
633 if let (Some(new_g), Some(mut old_g)) = (new_g, old_guard) {
634 if old_g.fetch_callback.is_none() {
635 old_g.fetch_callback.clone_from(&new_g.fetch_callback);
636 }
637 old_g.viewport = new_g.viewport;
638 old_g.layer = new_g.layer.clone();
639 old_g.on_viewport_changed = new_g.on_viewport_changed.clone();
640 }
641 }
642 old_data
643}
644
645use crate::callbacks::CallbackInfo;
648use azul_core::callbacks::Update;
649use azul_core::callbacks::TimerCallbackReturn;
650use azul_core::task::{Duration, SystemTimeDiff, TerminateTimer, TimerId};
651use crate::timer::{Timer, TimerCallback, TimerCallbackInfo};
652
653pub type MapViewportChangedCallbackType =
661 extern "C" fn(RefAny, CallbackInfo, MapViewport) -> Update;
662impl_widget_callback!(
663 MapViewportChanged,
664 OptionMapViewportChanged,
665 MapViewportChangedCallback,
666 MapViewportChangedCallbackType
667);
668azul_core::impl_managed_callback! {
669 wrapper: MapViewportChangedCallback,
670 info_ty: CallbackInfo,
671 return_ty: Update,
672 default_ret: Update::DoNothing,
673 invoker_static: MAP_VIEWPORT_CHANGED_INVOKER,
674 invoker_ty: AzMapViewportChangedCallbackInvoker,
675 thunk_fn: az_map_viewport_changed_callback_thunk,
676 setter_fn: AzApp_setMapViewportChangedCallbackInvoker,
677 from_handle_fn: AzMapViewportChangedCallback_createFromHostHandle,
678 extra_args: [ viewport: MapViewport ],
679}
680
681fn invoke_viewport_changed(
684 hook: &OptionMapViewportChanged,
685 info: &CallbackInfo,
686 viewport: MapViewport,
687) -> Update {
688 match hook {
689 OptionMapViewportChanged::Some(h) => {
690 (h.callback.cb)(h.refany.clone(), *info, viewport)
691 }
692 OptionMapViewportChanged::None => Update::DoNothing,
693 }
694}
695
696pub type MapPinTapCallbackType = extern "C" fn(RefAny, CallbackInfo, MapLatLon) -> Update;
705impl_widget_callback!(
706 MapPinTap,
707 OptionMapPinTap,
708 MapPinTapCallback,
709 MapPinTapCallbackType
710);
711azul_core::impl_managed_callback! {
712 wrapper: MapPinTapCallback,
713 info_ty: CallbackInfo,
714 return_ty: Update,
715 default_ret: Update::DoNothing,
716 invoker_static: MAP_PIN_TAP_INVOKER,
717 invoker_ty: AzMapPinTapCallbackInvoker,
718 thunk_fn: az_map_pin_tap_callback_thunk,
719 setter_fn: AzApp_setMapPinTapCallbackInvoker,
720 from_handle_fn: AzMapPinTapCallback_createFromHostHandle,
721 extra_args: [ coord: MapLatLon ],
722}
723
724fn invoke_pin_tap(hook: &OptionMapPinTap, info: &CallbackInfo, coord: MapLatLon) -> Update {
726 match hook {
727 OptionMapPinTap::Some(h) => (h.callback.cb)(h.refany.clone(), *info, coord),
728 OptionMapPinTap::None => Update::DoNothing,
729 }
730}
731
732extern "C" fn map_on_pointer_down(mut data: RefAny, info: CallbackInfo) -> Update {
736 #[cfg(feature = "std")]
737 if std::env::var("AZ_MAP_DEBUG").is_ok() {
738 eprintln!("[map] pointer_down fired");
739 }
740 let pos = match info.get_cursor_relative_to_node().into_option() {
741 Some(p) => azul_core::geom::LogicalPosition::new(p.x, p.y),
742 None => return Update::DoNothing,
743 };
744 if let Some(mut cache) = data.downcast_mut::<MapTileCache>() {
745 cache.drag_anchor = Some(pos);
746 cache.press_origin = Some(pos);
747 }
748 Update::DoNothing
749}
750
751#[allow(clippy::similar_names)] extern "C" fn map_on_pointer_move(mut data: RefAny, mut info: CallbackInfo) -> Update {
763 #[cfg(feature = "std")]
764 if std::env::var("AZ_MAP_DEBUG").is_ok() {
765 let dragging = data
766 .downcast_ref::<MapTileCache>()
767 .is_some_and(|c| c.drag_anchor.is_some());
768 eprintln!("[map] pointer_move fired (dragging={dragging})");
769 }
770 if let Some(pinch) = info.get_pinch().into_option() {
772 let Some(mut cache) = data.downcast_mut::<MapTileCache>() else {
773 return Update::DoNothing;
774 };
775 let anchor = *cache.pinch_anchor.get_or_insert(pinch.current_distance);
776 if anchor > 1.0 && pinch.current_distance > 1.0 {
777 let dz = (pinch.current_distance / anchor).log2();
778 let min = f32::from(cache.layer.min_zoom);
779 let max = f32::from(cache.layer.max_zoom);
780 cache.viewport.zoom = (cache.viewport.zoom + dz).clamp(min, max);
781 }
782 cache.pinch_anchor = Some(pinch.current_distance);
783 cache.drag_anchor = None;
786 let hook = cache.on_viewport_changed.clone();
787 let vp = cache.viewport;
788 drop(cache);
789 invoke_viewport_changed(&hook, &info, vp);
790 info.trigger_all_virtual_view_rerender();
794 return Update::DoNothing;
795 }
796
797 let pos = match info.get_cursor_relative_to_node().into_option() {
798 Some(p) => azul_core::geom::LogicalPosition::new(p.x, p.y),
799 None => return Update::DoNothing,
800 };
801 let Some(mut cache_guard) = data.downcast_mut::<MapTileCache>() else {
802 return Update::DoNothing;
803 };
804 let Some(anchor) = cache_guard.drag_anchor else {
805 return Update::DoNothing; };
807
808 let dx_px = f64::from(pos.x - anchor.x);
809 let dy_px = f64::from(pos.y - anchor.y);
810 if dx_px.abs() < 0.5 && dy_px.abs() < 0.5 {
811 return Update::DoNothing;
812 }
813
814 let (new_lon, new_lat) = pan_viewport(
815 cache_guard.viewport.centre_lat_deg,
816 cache_guard.viewport.centre_lon_deg,
817 f64::from(cache_guard.viewport.zoom),
818 dx_px,
819 dy_px,
820 );
821 cache_guard.viewport.centre_lon_deg = new_lon;
822 cache_guard.viewport.centre_lat_deg = new_lat;
823 cache_guard.drag_anchor = Some(pos);
824
825 let hook = cache_guard.on_viewport_changed.clone();
826 let vp = cache_guard.viewport;
827 drop(cache_guard);
828 invoke_viewport_changed(&hook, &info, vp);
829 info.trigger_all_virtual_view_rerender();
832 Update::DoNothing
833}
834
835#[allow(clippy::suboptimal_flops)] extern "C" fn map_on_pointer_up(mut data: RefAny, mut info: CallbackInfo) -> Update {
840 let up_pos = info
842 .get_cursor_relative_to_node()
843 .into_option()
844 .map(|p| azul_core::geom::LogicalPosition::new(p.x, p.y));
845 let container = info
846 .get_hit_node_rect()
847 .map_or(azul_core::geom::LogicalSize::new(0.0, 0.0), |r| r.size);
848 let (press, viewport, hook) = data.downcast_mut::<MapTileCache>().map_or_else(|| (None, MapViewport::default(), OptionMapPinTap::None), |mut cache| {
849 let out = (cache.press_origin, cache.viewport, cache.on_pin_tap.clone());
850 cache.drag_anchor = None;
851 cache.pinch_anchor = None;
852 cache.press_origin = None;
853 out
854 });
855 if let (Some(origin), Some(up)) = (press, up_pos) {
858 let dx = f64::from(up.x - origin.x);
859 let dy = f64::from(up.y - origin.y);
860 if dx * dx + dy * dy < 36.0 {
861 let coord = MapWidget::latlon_at_px(viewport, up, container);
862 invoke_pin_tap(&hook, &info, coord);
863 }
864 }
865 spawn_pending_tile_fetches(&mut data, &mut info);
868 info.trigger_all_virtual_view_rerender();
871 Update::DoNothing
872}
873
874extern "C" fn map_on_scroll(mut data: RefAny, mut info: CallbackInfo) -> Update {
880 let dy: f32 = {
884 let hn = info.get_hit_node();
885 hn.node.into_crate_internal().map_or(0.0, |nid| info.get_scroll_delta(hn.dom, nid).map_or(0.0, |d| d.y))
886 };
887 #[cfg(feature = "std")]
888 if std::env::var("AZ_MAP_DEBUG").is_ok() {
889 eprintln!("[map] scroll fired dy={dy}");
890 }
891 if dy == 0.0 {
892 return Update::DoNothing;
893 }
894 let bounds = info
897 .get_hit_node_rect()
898 .map_or(azul_core::geom::LogicalSize::new(0.0, 0.0), |r| r.size);
899 let (vp, hook) = {
900 let Some(mut cache) = data.downcast_mut::<MapTileCache>() else {
901 return Update::DoNothing;
902 };
903 let min = f32::from(cache.layer.min_zoom);
904 let max = f32::from(cache.layer.max_zoom);
905 let dz = dy.signum() * 0.5;
908 cache.viewport.zoom = (cache.viewport.zoom + dz).clamp(min, max);
909 let vp = cache.viewport;
910 let layer = cache.layer.clone();
911 for t in map_visible_tiles(&vp, bounds, &layer) {
912 cache.tiles.entry(t).or_insert(TileEntry::Pending);
913 }
914 (vp, cache.on_viewport_changed.clone())
915 };
916 invoke_viewport_changed(&hook, &info, vp);
917 spawn_pending_tile_fetches(&mut data, &mut info);
918 info.trigger_all_virtual_view_rerender();
919 Update::DoNothing
920}
921
922fn wrap_lon(lon: f64) -> f64 {
923 (lon + 180.0).rem_euclid(360.0) - 180.0
926}
927
928fn lon_to_tile_x(lon_deg: f64, tile_count: f64) -> f64 {
939 (lon_deg + 180.0) / 360.0 * tile_count
940}
941
942fn lat_to_tile_y(lat_deg: f64, tile_count: f64) -> f64 {
944 let lat_rad = lat_deg.to_radians();
945 let mercator =
946 (1.0 - (lat_rad.tan() + 1.0 / lat_rad.cos()).ln() / core::f64::consts::PI) / 2.0;
947 mercator * tile_count
948}
949
950#[allow(dead_code)]
954#[allow(clippy::suboptimal_flops)] fn tile_x_to_lon(x: f64, tile_count: f64) -> f64 {
956 x / tile_count * 360.0 - 180.0
957}
958
959#[allow(dead_code)]
961fn tile_y_to_lat(y: f64, tile_count: f64) -> f64 {
962 let n = core::f64::consts::PI * (1.0 - 2.0 * y / tile_count);
963 n.sinh().atan().to_degrees()
964}
965
966#[allow(clippy::suboptimal_flops)] #[allow(clippy::similar_names)] fn pan_viewport(
978 centre_lat_deg: f64,
979 centre_lon_deg: f64,
980 zoom: f64,
981 dx_px: f64,
982 dy_px: f64,
983) -> (f64, f64) {
984 let world_px = 256.0 * (2.0_f64).powf(zoom);
986 let d_lon = -dx_px * 360.0 / world_px;
987 let d_lat = dy_px * 360.0 / world_px * centre_lat_deg.to_radians().cos();
988 let new_lon = wrap_lon(centre_lon_deg + d_lon);
989 let new_lat = (centre_lat_deg + d_lat).clamp(-85.0, 85.0);
990 (new_lon, new_lat)
991}
992
993#[cfg(all(feature = "xml", feature = "cpurender"))]
1007#[must_use] pub fn svg_string_to_dom(svg: &str) -> Option<Dom> {
1008 let img = crate::cpurender::render_svg_to_imageref(svg.as_bytes(), 256, 256).ok()?;
1009 Some(
1010 Dom::create_image(img)
1011 .with_css("position: absolute; left: 0; top: 0; width: 100%; height: 100%;"),
1012 )
1013}
1014
1015#[cfg(all(feature = "xml", not(feature = "cpurender")))]
1016pub fn svg_string_to_dom(svg: &str) -> Option<Dom> {
1017 use azul_core::xml::{str_to_dom_unstyled, ComponentMap};
1018
1019 let wrapped = alloc::format!("<html><body>{}</body></html>", svg);
1020 let nodes = crate::xml::parse_xml_string(&wrapped).ok()?;
1021 let component_map = ComponentMap::default();
1022 str_to_dom_unstyled(nodes.as_ref(), &component_map).ok()
1023}
1024
1025#[cfg(not(feature = "xml"))]
1026fn svg_string_to_dom(_svg: &str) -> Option<Dom> {
1027 None
1028}
1029
1030extern "C" fn map_on_after_mount(mut data: RefAny, mut info: CallbackInfo) -> Update {
1037 #[cfg(feature = "std")]
1038 if std::env::var("AZ_MAP_DEBUG").is_ok() {
1039 eprintln!("[map] after_mount fired");
1040 }
1041 spawn_pending_tile_fetches(&mut data, &mut info);
1042 let sweep = Timer::create(
1050 data.clone(),
1051 TimerCallback::create(map_fetch_sweep_tick),
1052 info.get_system_time_fn(),
1053 )
1054 .with_interval(Duration::System(SystemTimeDiff::from_millis(250)));
1055 info.add_timer(TimerId::unique(), sweep);
1056 info.trigger_all_virtual_view_rerender();
1063 Update::DoNothing
1064}
1065
1066fn spawn_pending_tile_fetches(data: &mut RefAny, info: &mut CallbackInfo) {
1076 use crate::thread::Thread;
1077 use azul_core::task::ThreadId;
1078
1079 const MAX_SPAWN_PER_CALL: usize = 16;
1081
1082 let mut to_spawn: Vec<TileFetchInit> = Vec::new();
1086 {
1087 let Some(mut cache) = data.downcast_mut::<MapTileCache>() else {
1088 return;
1089 };
1090 if cache.fetch_callback.is_none() {
1091 return; }
1093 let template = cache.layer.url_template.as_str().to_string();
1094 let style_css = cache.layer.style_css.clone();
1095 let pending: Vec<MapTileId> = cache
1096 .tiles
1097 .iter()
1098 .filter(|(_, e)| matches!(e, TileEntry::Pending))
1099 .map(|(id, _)| *id)
1100 .take(MAX_SPAWN_PER_CALL)
1101 .collect();
1102 for tile in pending {
1103 let url = build_tile_url(&template, tile);
1104 cache.tiles.insert(tile, TileEntry::Fetching);
1105 to_spawn.push(TileFetchInit {
1106 tile,
1107 url: AzString::from(url),
1108 style_css: style_css.clone(),
1109 });
1110 }
1111 cache.prune_distant_tiles();
1115 }
1116
1117 let cb = {
1118 let Some(cache) = data.downcast_ref::<MapTileCache>() else {
1119 return;
1120 };
1121 match cache.fetch_callback.as_ref() {
1122 Some(cb) => cb.clone(),
1123 None => return,
1124 }
1125 };
1126
1127 #[cfg(feature = "std")]
1128 let spawn_count = to_spawn.len();
1129 for init in to_spawn {
1130 let init_data = RefAny::new(init);
1131 let writeback_data = data.clone(); let thread = Thread::create(init_data, writeback_data, cb.clone());
1133 info.add_thread(ThreadId::unique(), thread);
1134 }
1135 #[cfg(feature = "std")]
1136 if std::env::var("AZ_MAP_DEBUG").is_ok() {
1137 eprintln!("[map] spawn_pending: {spawn_count} thread(s) spawned");
1138 }
1139}
1140
1141extern "C" fn map_fetch_sweep_tick(
1150 mut data: RefAny,
1151 mut info: TimerCallbackInfo,
1152) -> TimerCallbackReturn {
1153 spawn_pending_tile_fetches(&mut data, &mut info.callback_info);
1154 TimerCallbackReturn {
1155 should_update: Update::DoNothing,
1156 should_terminate: TerminateTimer::Continue,
1157 }
1158}
1159
1160fn build_tile_url(template: &str, tile: MapTileId) -> String {
1163 use alloc::string::ToString;
1164 template
1165 .replace("{z}", &tile.z.to_string())
1166 .replace("{x}", &tile.x.to_string())
1167 .replace("{y}", &tile.y.to_string())
1168}
1169
1170#[must_use] pub extern "C" fn map_tile_writeback(
1178 mut cache_dataset: RefAny,
1179 mut incoming: RefAny,
1180 mut info: CallbackInfo,
1181) -> Update {
1182 let msg = match incoming.downcast_ref::<TileReadyMsg>() {
1183 Some(m) => (m.tile, m.svg.clone(), m.error.clone()),
1184 None => return Update::DoNothing,
1185 };
1186 {
1187 let Some(mut cache) = cache_dataset.downcast_mut::<MapTileCache>() else {
1188 return Update::DoNothing;
1189 };
1190 #[cfg(feature = "std")]
1191 if std::env::var("AZ_MAP_DEBUG").is_ok() {
1192 eprintln!(
1193 "[map] writeback tile=({},{},{}) ok={} svg_len={} err={:?}",
1194 msg.0.z, msg.0.x, msg.0.y,
1195 msg.2.as_str().is_empty(), msg.1.as_str().len(), msg.2.as_str()
1196 );
1197 }
1198 if msg.2.as_str().is_empty() {
1199 cache.mark_tile_ready(msg.0, msg.1);
1200 } else {
1201 cache.mark_tile_failed(msg.0, msg.2);
1202 }
1203 } info.trigger_all_virtual_view_rerender();
1213 Update::DoNothing
1214}
1215
1216#[allow(clippy::suboptimal_flops)] #[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] fn visible_tile_range(
1226 centre_x: f32,
1227 centre_y: f32,
1228 width_px: f32,
1229 height_px: f32,
1230 zoom_scale: f32,
1231 tile_count: u32,
1232) -> (i32, i32, i32, i32) {
1233 let tile_px = 256.0 * zoom_scale;
1234 let half_w = (width_px / tile_px).abs() * 0.5 + 1.0;
1235 let half_h = (height_px / tile_px).abs() * 0.5 + 1.0;
1236 let max_idx = tile_count as i32 - 1;
1237 let x_min = (centre_x - half_w).floor() as i32;
1243 let x_max = (centre_x + half_w).ceil() as i32;
1244 let y_min = ((centre_y - half_h).floor() as i32).max(0);
1245 let y_max = ((centre_y + half_h).ceil() as i32).min(max_idx);
1246 (x_min, x_max, y_min, y_max)
1247}
1248
1249#[allow(clippy::cast_possible_wrap)] fn wrap_tile_x(x: i32, tile_count: u32) -> u32 {
1255 x.rem_euclid(tile_count.max(1) as i32) as u32
1256}
1257
1258#[allow(clippy::suboptimal_flops)] #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] fn map_visible_tiles(
1265 viewport: &MapViewport,
1266 bounds: azul_core::geom::LogicalSize,
1267 layer: &MapTileLayer,
1268) -> Vec<MapTileId> {
1269 let z_int =
1270 (viewport.zoom.floor() as i32).clamp(i32::from(layer.min_zoom), i32::from(layer.max_zoom)) as u8;
1271 let tile_count = 1u32 << u32::from(z_int);
1272 let frac_zoom = viewport.zoom - f32::from(z_int);
1273 let zoom_scale = 2.0_f32.powf(frac_zoom);
1274 let centre_x = lon_to_tile_x(viewport.centre_lon_deg, f64::from(tile_count)) as f32;
1275 let centre_y = lat_to_tile_y(viewport.centre_lat_deg, f64::from(tile_count)) as f32;
1276 let (x_min, x_max, y_min, y_max) =
1277 visible_tile_range(centre_x, centre_y, bounds.width, bounds.height, zoom_scale, tile_count);
1278 let mut tiles = Vec::new();
1279 for x in x_min..=x_max {
1280 for y in y_min..=y_max {
1281 tiles.push(MapTileId { z: z_int, x: wrap_tile_x(x, tile_count), y: y as u32 });
1282 }
1283 }
1284 tiles
1285}
1286
1287#[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(
1293 data: RefAny,
1294 info: VirtualViewCallbackInfo,
1295) -> VirtualViewReturn {
1296 enum TileDisplay {
1297 Glyph(&'static str),
1298 Svg(AzString),
1299 }
1300 let mut data = data;
1301 let bounds = info.get_bounds();
1302 let bounds_logical = bounds.get_logical_size();
1303 let width_px = bounds_logical.width;
1304 let height_px = bounds_logical.height;
1305
1306 if !width_px.is_finite() || !height_px.is_finite() || width_px <= 0.0 || height_px <= 0.0 {
1311 if std::env::var("AZ_MAP_DEBUG").is_ok() {
1312 eprintln!("[map] non-finite bounds {width_px}x{height_px} — skipping render");
1313 }
1314 return VirtualViewReturn {
1315 dom: OptionDom::None,
1316 scroll_size: bounds_logical,
1317 scroll_offset: azul_core::geom::LogicalPosition::zero(),
1318 virtual_scroll_size: bounds_logical,
1319 virtual_scroll_offset: azul_core::geom::LogicalPosition::zero(),
1320 };
1321 }
1322
1323 let (layer, viewport) = match data.downcast_ref::<MapTileCache>() {
1324 Some(c) => (c.layer.clone(), c.viewport),
1325 None => {
1326 return VirtualViewReturn {
1327 dom: OptionDom::None,
1328 scroll_size: bounds_logical,
1329 scroll_offset: azul_core::geom::LogicalPosition::zero(),
1330 virtual_scroll_size: bounds_logical,
1331 virtual_scroll_offset: azul_core::geom::LogicalPosition::zero(),
1332 };
1333 }
1334 };
1335
1336 let z_int = (viewport.zoom.floor() as i32)
1339 .clamp(i32::from(layer.min_zoom), i32::from(layer.max_zoom))
1340 as u8;
1341 let tile_count = 1u32 << u32::from(z_int);
1342 let frac_zoom = viewport.zoom - f32::from(z_int);
1343 let zoom_scale = 2.0_f32.powf(frac_zoom);
1344
1345 let centre_x = lon_to_tile_x(viewport.centre_lon_deg, f64::from(tile_count)) as f32;
1348 let centre_y = lat_to_tile_y(viewport.centre_lat_deg, f64::from(tile_count)) as f32;
1349
1350 let tile_px = 256.0 * zoom_scale;
1353 let (x_min, x_max, y_min, y_max) =
1354 visible_tile_range(centre_x, centre_y, width_px, height_px, zoom_scale, tile_count);
1355
1356 if std::env::var("AZ_MAP_DEBUG").is_ok() {
1361 eprintln!(
1362 "[map] render bounds={:.0}x{:.0} z={} centre_tile=({:.2},{:.2}) tiles x{}..{} y{}..{} = {}",
1363 width_px, height_px, z_int, centre_x, centre_y, x_min, x_max, y_min, y_max,
1364 (x_max - x_min + 1).max(0) * (y_max - y_min + 1).max(0)
1365 );
1366 }
1367
1368 if let Some(mut cache) = data.downcast_mut::<MapTileCache>() {
1372 for x in x_min..=x_max {
1373 for y in y_min..=y_max {
1374 let id = MapTileId {
1375 z: z_int,
1376 x: wrap_tile_x(x, tile_count),
1377 y: y as u32,
1378 };
1379 cache.tiles.entry(id).or_insert(TileEntry::Pending);
1380 }
1381 }
1382 }
1383
1384 let states: BTreeMap<MapTileId, TileDisplay> = data
1390 .downcast_ref::<MapTileCache>()
1391 .map_or_else(BTreeMap::new, |c| {
1392 c.tiles
1393 .iter()
1394 .map(|(id, e)| {
1395 let disp = match e {
1396 TileEntry::Pending => TileDisplay::Glyph("…"),
1397 TileEntry::Fetching => TileDisplay::Glyph("⟳"),
1398 TileEntry::Ready { svg } => TileDisplay::Svg(svg.clone()),
1399 TileEntry::Failed { .. } => TileDisplay::Glyph("✗"),
1400 };
1401 (*id, disp)
1402 })
1403 .collect()
1404 });
1405
1406 let mut grid = Dom::create_div().with_css(
1410 "position: absolute; left: 0; top: 0; width: 100%; height: 100%; overflow: hidden;",
1411 );
1412
1413 {
1420 use crate::callbacks::{Callback, CallbackType};
1421 use azul_core::dom::{EventFilter, HoverEventFilter};
1422 grid = grid
1423 .with_callback(
1424 EventFilter::Hover(HoverEventFilter::MouseDown),
1425 data.clone(),
1426 Callback::from_ptr(map_on_pointer_down),
1427 )
1428 .with_callback(
1429 EventFilter::Hover(HoverEventFilter::MouseOver),
1430 data.clone(),
1431 Callback::from_ptr(map_on_pointer_move),
1432 )
1433 .with_callback(
1434 EventFilter::Hover(HoverEventFilter::MouseUp),
1435 data.clone(),
1436 Callback::from_ptr(map_on_pointer_up),
1437 )
1438 .with_callback(
1439 EventFilter::Hover(HoverEventFilter::MouseLeave),
1440 data.clone(),
1441 Callback::from_ptr(map_on_pointer_up),
1442 )
1443 .with_callback(
1444 EventFilter::Hover(HoverEventFilter::Scroll),
1445 data.clone(),
1446 Callback::from_ptr(map_on_scroll),
1447 );
1448 }
1449
1450 for x in x_min..=x_max {
1451 for y in y_min..=y_max {
1452 let id = MapTileId {
1456 z: z_int,
1457 x: wrap_tile_x(x, tile_count),
1458 y: y as u32,
1459 };
1460 let proj = |coord: f32, centre: f32, span_px: f32| {
1469 ((coord - centre) * tile_px + span_px * 0.5).round() as i32
1470 };
1471 let screen_x = proj(x as f32, centre_x, width_px);
1472 let screen_y = proj(y as f32, centre_y, height_px);
1473 let size_w = proj(x as f32 + 1.0, centre_x, width_px)
1481 .saturating_sub(screen_x)
1482 .max(1);
1483 let size_h = proj(y as f32 + 1.0, centre_y, height_px)
1484 .saturating_sub(screen_y)
1485 .max(1);
1486
1487 let is_ready = matches!(states.get(&id), Some(TileDisplay::Svg(_)));
1493 let chrome = if is_ready {
1494 ""
1495 } else {
1496 "background: #e7e9ec; border: 1px solid #d0d4d9;"
1497 };
1498 let style = alloc::format!(
1499 "position: absolute; left: {screen_x}px; top: {screen_y}px; \
1500 width: {size_w}px; height: {size_h}px; {chrome}"
1501 );
1502
1503 let mut tile_div = Dom::create_div().with_css(style.as_str());
1504
1505 match states.get(&id) {
1510 Some(TileDisplay::Svg(svg)) => match svg_string_to_dom(svg.as_str()) {
1511 Some(svg_dom) => {
1512 tile_div = tile_div.with_child(svg_dom);
1513 }
1514 None => {
1515 tile_div = tile_div.with_child(
1516 Dom::create_text(alloc::format!("✓? z{z_int}/{x}/{y}"))
1517 .with_css("position: absolute; left: 4px; top: 4px; font-size: 11px; color: #888;"),
1518 );
1519 }
1520 },
1521 other => {
1522 let state_tag = match other {
1523 Some(TileDisplay::Glyph(g)) => *g,
1524 _ => "",
1525 };
1526 tile_div = tile_div.with_child(
1527 Dom::create_text(alloc::format!("{state_tag} z{z_int}/{x}/{y}"))
1528 .with_css("position: absolute; left: 4px; top: 4px; font-size: 11px; color: #888;"),
1529 );
1530 }
1531 }
1532
1533 grid = grid.with_child(tile_div);
1534 }
1535 }
1536
1537 VirtualViewReturn {
1538 dom: OptionDom::Some(grid),
1539 scroll_size: bounds_logical,
1540 scroll_offset: azul_core::geom::LogicalPosition::zero(),
1541 virtual_scroll_size: bounds_logical,
1542 virtual_scroll_offset: azul_core::geom::LogicalPosition::zero(),
1543 }
1544}
1545
1546#[cfg(test)]
1547mod tests {
1548 use super::*;
1549
1550 fn approx(a: f64, b: f64, eps: f64) {
1551 assert!((a - b).abs() < eps, "expected {a} ≈ {b} (within {eps})");
1552 }
1553
1554 #[test]
1555 fn wrap_lon_keeps_in_range() {
1556 approx(wrap_lon(0.0), 0.0, 1e-9);
1557 approx(wrap_lon(179.0), 179.0, 1e-9);
1558 approx(wrap_lon(-179.0), -179.0, 1e-9);
1559 approx(wrap_lon(181.0), -179.0, 1e-9);
1561 approx(wrap_lon(-181.0), 179.0, 1e-9);
1562 approx(wrap_lon(540.0), -180.0, 1e-9);
1564 for raw in [-1234.5, -360.0, 360.0, 999.9] {
1566 let w = wrap_lon(raw);
1567 assert!((-180.0..=180.0).contains(&w), "{raw} → {w} out of range");
1568 }
1569 }
1570
1571 #[test]
1572 fn build_tile_url_substitutes_zxy() {
1573 let tile = MapTileId { z: 11, x: 327, y: 791 };
1574 assert_eq!(
1575 build_tile_url("https://t.example/{z}/{x}/{y}.pbf", tile),
1576 "https://t.example/11/327/791.pbf"
1577 );
1578 assert_eq!(
1580 build_tile_url("{y}-{x}-{z}-{z}", MapTileId { z: 3, x: 4, y: 5 }),
1581 "5-4-3-3"
1582 );
1583 }
1584
1585 #[test]
1586 fn lon_tile_endpoints() {
1587 approx(lon_to_tile_x(-180.0, 1.0), 0.0, 1e-9);
1589 approx(lon_to_tile_x(180.0, 1.0), 1.0, 1e-9);
1590 approx(lon_to_tile_x(0.0, 1.0), 0.5, 1e-9);
1591 approx(lon_to_tile_x(0.0, 2.0), 1.0, 1e-9);
1593 }
1594
1595 #[test]
1596 fn lat_tile_equator_and_symmetry() {
1597 approx(lat_to_tile_y(0.0, 1.0), 0.5, 1e-9);
1599 let north = lat_to_tile_y(45.0, 1.0);
1601 let south = lat_to_tile_y(-45.0, 1.0);
1602 assert!(north < 0.5 && south > 0.5);
1603 approx(north + south, 1.0, 1e-9);
1604 }
1605
1606 #[test]
1607 #[allow(clippy::cast_precision_loss)] fn projection_round_trips() {
1609 let points = [
1612 (37.7749, -122.4194), (51.5074, -0.1278), (-33.8688, 151.2093), (0.0, 0.0), ];
1617 for z in [0u32, 5, 11, 18] {
1618 let tc = (1u64 << z) as f64;
1619 for (lat, lon) in points {
1620 let x = lon_to_tile_x(lon, tc);
1621 let y = lat_to_tile_y(lat, tc);
1622 approx(tile_x_to_lon(x, tc), lon, 1e-6);
1623 approx(tile_y_to_lat(y, tc), lat, 1e-6);
1624 }
1625 }
1626 }
1627
1628 #[test]
1629 fn pan_zero_drag_is_identity() {
1630 let (lon, lat) = pan_viewport(37.0, -122.0, 11.0, 0.0, 0.0);
1632 approx(lon, -122.0, 1e-9);
1633 approx(lat, 37.0, 1e-9);
1634 }
1635
1636 #[test]
1637 fn pan_right_decreases_longitude() {
1638 let (lon, _) = pan_viewport(0.0, 0.0, 0.0, 100.0, 0.0);
1640 assert!(lon < 0.0, "drag right should lower longitude, got {lon}");
1641 let (lon_left, _) = pan_viewport(0.0, 0.0, 0.0, -100.0, 0.0);
1643 approx(lon_left, -lon, 1e-9);
1644 }
1645
1646 #[test]
1647 fn pan_step_scales_inversely_with_zoom() {
1648 let (lon_z0, _) = pan_viewport(0.0, 0.0, 0.0, 50.0, 0.0);
1651 let (lon_z1, _) = pan_viewport(0.0, 0.0, 1.0, 50.0, 0.0);
1652 approx(lon_z1, lon_z0 / 2.0, 1e-9);
1653 }
1654
1655 #[test]
1656 fn pan_clamps_latitude_to_mercator_limit() {
1657 let (_, lat_north) = pan_viewport(84.0, 0.0, 0.0, 0.0, 1.0e6);
1659 assert!((-85.0..=85.0).contains(&lat_north));
1660 let (_, lat_south) = pan_viewport(-84.0, 0.0, 0.0, 0.0, -1.0e6);
1661 assert!((-85.0..=85.0).contains(&lat_south));
1662 }
1663
1664 #[test]
1665 fn pan_wraps_longitude_across_antimeridian() {
1666 let (lon, _) = pan_viewport(0.0, 179.0, 0.0, -100.0, 0.0);
1669 assert!((-180.0..180.0).contains(&lon), "lon {lon} out of range");
1670 }
1671
1672 fn viewport_at(zoom: f32) -> MapViewport {
1673 MapViewport {
1674 centre_lat_deg: 0.0,
1675 centre_lon_deg: 0.0,
1676 zoom,
1677 bearing_deg: 0.0,
1678 pitch_deg: 0.0,
1679 }
1680 }
1681
1682 #[test]
1683 fn merge_shares_old_cache_so_worker_writebacks_survive_relayout() {
1684 let tile = MapTileId { z: 5, x: 1, y: 2 };
1691 let old_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(5.0));
1692 let old_ref = RefAny::new(old_cache);
1693 let mut worker_handle = old_ref.clone();
1695 let new_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(9.0));
1697
1698 let mut merged = merge_map_tile_cache(RefAny::new(new_cache), old_ref);
1699
1700 worker_handle
1703 .downcast_mut::<MapTileCache>()
1704 .unwrap()
1705 .mark_tile_ready(tile, AzString::from("<svg/>"));
1706
1707 let g = merged.downcast_ref::<MapTileCache>().unwrap();
1710 assert!(
1711 g.tiles.contains_key(&tile),
1712 "a worker writeback after relayout must reach the rendered cache"
1713 );
1714 }
1715
1716 #[test]
1717 fn merge_adopts_build_viewport_but_keeps_tiles() {
1718 let mut old_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(5.0));
1728 old_cache.viewport.zoom = 7.0; let tile = MapTileId { z: 2, x: 1, y: 1 };
1730 old_cache.tiles.insert(tile, TileEntry::Ready { svg: "<svg/>".into() });
1731
1732 let new_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(2.0));
1733
1734 let mut merged =
1735 merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(old_cache));
1736 let g = merged.downcast_ref::<MapTileCache>().unwrap();
1737 approx(f64::from(g.viewport.zoom), f64::from(viewport_at(2.0).zoom), 1e-6);
1739 assert!(
1741 g.tiles.contains_key(&tile),
1742 "fetched tiles must survive the merge (workers write into this cache)"
1743 );
1744 }
1745
1746 #[test]
1747 fn tile_range_covers_centre_with_margin() {
1748 let (x0, x1, y0, y1) = visible_tile_range(8.0, 8.0, 512.0, 512.0, 1.0, 16);
1751 assert_eq!((x0, x1), (6, 10));
1752 assert_eq!((y0, y1), (6, 10));
1753 }
1754
1755 #[test]
1756 fn wrap_tile_x_wraps_both_directions() {
1757 assert_eq!(wrap_tile_x(-1, 4), 3);
1759 assert_eq!(wrap_tile_x(0, 4), 0);
1760 assert_eq!(wrap_tile_x(3, 4), 3);
1761 assert_eq!(wrap_tile_x(4, 4), 0);
1762 assert_eq!(wrap_tile_x(-5, 4), 3);
1763 assert_eq!(wrap_tile_x(7, 1), 0);
1765 assert_eq!(wrap_tile_x(-3, 1), 0);
1766 }
1767
1768 #[test]
1769 fn tile_range_y_clamps_but_x_wraps_at_zoom0() {
1770 let (x0, x1, y0, y1) = visible_tile_range(0.5, 0.5, 256.0, 256.0, 1.0, 1);
1774 assert_eq!((y0, y1), (0, 0));
1775 for x in x0..=x1 {
1776 assert_eq!(wrap_tile_x(x, 1), 0);
1777 }
1778 }
1779
1780 #[test]
1781 fn tile_range_widens_with_viewport() {
1782 let (nx0, nx1, ..) = visible_tile_range(8.0, 8.0, 512.0, 512.0, 1.0, 16);
1783 let (wx0, wx1, ..) = visible_tile_range(8.0, 8.0, 1024.0, 512.0, 1.0, 16);
1784 assert!(
1785 (wx1 - wx0) > (nx1 - nx0),
1786 "a wider viewport must request more columns"
1787 );
1788 }
1789
1790 #[test]
1791 fn tile_range_clamps_y_but_wraps_x_at_edges() {
1792 let (x0, _, y0, _) = visible_tile_range(0.0, 0.0, 512.0, 512.0, 1.0, 16);
1795 assert!(y0 >= 0);
1796 let (_, x1, _, y1) = visible_tile_range(15.0, 15.0, 512.0, 512.0, 1.0, 16);
1797 assert!(y1 <= 15);
1798 assert!(x0 < 0, "west-edge viewport should over-scan into wrapped columns");
1802 assert!(x1 > 15, "east-edge viewport should over-scan into wrapped columns");
1803 assert_eq!(wrap_tile_x(x0, 16), x0.rem_euclid(16) as u32);
1804 assert_eq!(wrap_tile_x(x1, 16), x1.rem_euclid(16) as u32);
1805 }
1806
1807 fn test_cache() -> MapTileCache {
1808 let layer = MapTileLayer {
1809 url_template: AzString::from("{z}/{x}/{y}"),
1810 min_zoom: 0,
1811 max_zoom: 19,
1812 attribution: AzString::from(""),
1813 style_css: AzString::from(""),
1814 };
1815 let viewport = MapViewport {
1816 centre_lat_deg: 0.0,
1817 centre_lon_deg: 0.0,
1818 zoom: 4.0,
1819 bearing_deg: 0.0,
1820 pitch_deg: 0.0,
1821 };
1822 MapTileCache::new(layer, viewport)
1823 }
1824
1825 #[test]
1826 fn prune_evicts_distant_tiles_keeps_near_and_inflight() {
1827 let mut cache = test_cache();
1828 for x in 0..20u32 {
1831 for y in 0..20u32 {
1832 cache
1833 .tiles
1834 .insert(MapTileId { z: 4, x, y }, TileEntry::Ready { svg: AzString::from("<svg/>") });
1835 }
1836 }
1837 cache.tiles.insert(MapTileId { z: 4, x: 8, y: 8 }, TileEntry::Pending);
1839 cache.tiles.insert(MapTileId { z: 4, x: 9, y: 8 }, TileEntry::Ready { svg: AzString::from("<svg/>") });
1841 cache.tiles.insert(MapTileId { z: 4, x: 0, y: 0 }, TileEntry::Ready { svg: AzString::from("<svg/>") });
1843
1844 assert!(cache.tiles.len() > 192, "precondition: over the cap");
1845 cache.prune_distant_tiles();
1846
1847 assert!(cache.tiles.len() <= 192, "cache must be bounded after prune");
1848 assert!(matches!(
1850 cache.tiles.get(&MapTileId { z: 4, x: 8, y: 8 }),
1851 Some(TileEntry::Pending)
1852 ));
1853 assert!(cache.tiles.contains_key(&MapTileId { z: 4, x: 9, y: 8 }));
1855 assert!(!cache.tiles.contains_key(&MapTileId { z: 4, x: 0, y: 0 }));
1856 }
1857
1858 #[test]
1859 fn prune_is_noop_under_cap() {
1860 let mut cache = test_cache();
1861 for x in 0..4u32 {
1862 cache
1863 .tiles
1864 .insert(MapTileId { z: 4, x, y: 8 }, TileEntry::Ready { svg: AzString::from("<svg/>") });
1865 }
1866 cache.prune_distant_tiles();
1867 assert_eq!(cache.tiles.len(), 4, "under the cap → nothing evicted");
1868 }
1869}
1870
1871#[cfg(test)]
1879#[allow(
1880 clippy::cast_possible_truncation,
1881 clippy::cast_precision_loss,
1882 clippy::cast_sign_loss,
1883 clippy::cast_possible_wrap,
1884 clippy::float_cmp,
1885 clippy::too_many_lines,
1886 clippy::unreadable_literal
1887)]
1888mod autotest_generated {
1889 use std::sync::{Arc, Mutex};
1890
1891 use azul_core::{
1892 callbacks::{HidpiAdjustedBounds, VirtualViewCallbackReason},
1893 dom::{DomId, DomNodeId},
1894 geom::{LogicalPosition, LogicalSize, OptionLogicalPosition},
1895 gl::OptionGlContextPtr,
1896 hit_test::ScrollPosition,
1897 resources::{DpiScaleFactor, ImageCache, RendererResources},
1898 styled_dom::NodeHierarchyItemId,
1899 window::{MonitorVec, RawWindowHandle, WindowTheme},
1900 };
1901 use azul_css::system::SystemStyle;
1902 use rust_fontconfig::FcFontCache;
1903
1904 use super::*;
1905 #[cfg(feature = "icu")]
1906 use crate::icu::IcuLocalizerHandle;
1907 use azul_core::task::ThreadReceiver;
1908
1909 use crate::{
1910 callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
1911 thread::{ThreadCallback, ThreadCallbackType, ThreadSender},
1912 window::LayoutWindow,
1913 window_state::FullWindowState,
1914 };
1915
1916 fn close(a: f64, b: f64, eps: f64) {
1921 assert!((a - b).abs() <= eps, "expected {a} ≈ {b} (within {eps})");
1922 }
1923
1924 fn layer_zoom(min_zoom: u8, max_zoom: u8) -> MapTileLayer {
1925 MapTileLayer {
1926 url_template: AzString::from("https://tiles.invalid/{z}/{x}/{y}.pbf"),
1927 min_zoom,
1928 max_zoom,
1929 attribution: AzString::from("attr"),
1930 style_css: AzString::from(""),
1931 }
1932 }
1933
1934 fn view(lat: f64, lon: f64, zoom: f32) -> MapViewport {
1935 MapViewport {
1936 centre_lat_deg: lat,
1937 centre_lon_deg: lon,
1938 zoom,
1939 bearing_deg: 0.0,
1940 pitch_deg: 0.0,
1941 }
1942 }
1943
1944 fn cache_at(lat: f64, lon: f64, zoom: f32) -> MapTileCache {
1945 MapTileCache::new(layer_zoom(0, 19), view(lat, lon, zoom))
1946 }
1947
1948 #[derive(Default)]
1950 struct HookLog {
1951 viewports: Vec<MapViewport>,
1952 coords: Vec<MapLatLon>,
1953 }
1954
1955 extern "C" fn record_viewport(
1956 mut data: RefAny,
1957 _: CallbackInfo,
1958 viewport: MapViewport,
1959 ) -> Update {
1960 if let Some(mut log) = data.downcast_mut::<HookLog>() {
1961 log.viewports.push(viewport);
1962 }
1963 Update::DoNothing
1964 }
1965
1966 extern "C" fn record_pin(mut data: RefAny, _: CallbackInfo, coord: MapLatLon) -> Update {
1967 if let Some(mut log) = data.downcast_mut::<HookLog>() {
1968 log.coords.push(coord);
1969 }
1970 Update::RefreshDom
1971 }
1972
1973 extern "C" fn noop_worker(_: RefAny, _: ThreadSender, _: ThreadReceiver) {}
1976 extern "C" fn other_noop_worker(_: RefAny, _: ThreadSender, _: ThreadReceiver) {}
1977
1978 fn hook_log(data: &mut RefAny) -> (usize, usize) {
1979 let log = data
1980 .downcast_ref::<HookLog>()
1981 .expect("payload must still be a HookLog");
1982 (log.viewports.len(), log.coords.len())
1983 }
1984
1985 fn with_callback_info_at<R>(
1989 cursor: OptionLogicalPosition,
1990 f: impl FnOnce(CallbackInfo) -> R,
1991 ) -> (R, Vec<CallbackChange>) {
1992 let layout_window =
1993 LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
1994 let renderer_resources = RendererResources::default();
1995 let previous_window_state: Option<FullWindowState> = None;
1996 let current_window_state = FullWindowState::default();
1997 let gl_context = OptionGlContextPtr::None;
1998 let scroll_states: alloc::collections::BTreeMap<
1999 DomId,
2000 alloc::collections::BTreeMap<NodeHierarchyItemId, ScrollPosition>,
2001 > = alloc::collections::BTreeMap::new();
2002 let window_handle = RawWindowHandle::Unsupported;
2003 let system_callbacks = ExternalSystemCallbacks::rust_internal();
2004
2005 let ref_data = CallbackInfoRefData {
2006 layout_window: &layout_window,
2007 renderer_resources: &renderer_resources,
2008 previous_window_state: &previous_window_state,
2009 current_window_state: ¤t_window_state,
2010 gl_context: &gl_context,
2011 current_scroll_manager: &scroll_states,
2012 current_window_handle: &window_handle,
2013 system_callbacks: &system_callbacks,
2014 system_style: Arc::new(SystemStyle::default()),
2015 monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
2016 #[cfg(feature = "icu")]
2017 icu_localizer: IcuLocalizerHandle::default(),
2018 ctx: OptionRefAny::None,
2019 };
2020
2021 let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
2022 let info = CallbackInfo::new(
2023 &ref_data,
2024 &changes,
2025 DomNodeId {
2026 dom: DomId::ROOT_ID,
2027 node: NodeHierarchyItemId::NONE,
2028 },
2029 cursor,
2030 OptionLogicalPosition::None,
2031 );
2032
2033 let out = f(info);
2034 let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
2035 (out, recorded)
2036 }
2037
2038 fn with_callback_info<R>(f: impl FnOnce(CallbackInfo) -> R) -> (R, Vec<CallbackChange>) {
2039 with_callback_info_at(OptionLogicalPosition::None, f)
2040 }
2041
2042 fn cursor_at(x: f32, y: f32) -> OptionLogicalPosition {
2043 OptionLogicalPosition::Some(LogicalPosition::new(x, y))
2044 }
2045
2046 fn with_virtual_view_info<R>(
2048 w: f32,
2049 h: f32,
2050 f: impl FnOnce(VirtualViewCallbackInfo) -> R,
2051 ) -> R {
2052 let fonts = FcFontCache::default();
2053 let images = ImageCache::default();
2054 let size = LogicalSize::new(w, h);
2055 let info = VirtualViewCallbackInfo::new(
2056 VirtualViewCallbackReason::InitialRender,
2057 &fonts,
2058 &images,
2059 WindowTheme::LightMode,
2060 HidpiAdjustedBounds {
2061 logical_size: size,
2062 hidpi_factor: DpiScaleFactor::new(1.0),
2063 },
2064 size,
2065 LogicalPosition::zero(),
2066 size,
2067 LogicalPosition::zero(),
2068 );
2069 f(info)
2070 }
2071
2072 fn rendered_child_count(ret: &VirtualViewReturn) -> Option<usize> {
2073 match &ret.dom {
2074 OptionDom::Some(d) => Some(d.children.as_slice().len()),
2075 OptionDom::None => None,
2076 }
2077 }
2078
2079 #[test]
2084 fn wrap_lon_zero_and_negative_zero_are_zero() {
2085 assert_eq!(wrap_lon(0.0), 0.0);
2086 assert_eq!(wrap_lon(-0.0), 0.0);
2087 }
2088
2089 #[test]
2090 fn wrap_lon_nan_and_infinities_are_nan_not_panic() {
2091 assert!(wrap_lon(f64::NAN).is_nan());
2094 assert!(wrap_lon(f64::INFINITY).is_nan());
2095 assert!(wrap_lon(f64::NEG_INFINITY).is_nan());
2096 }
2097
2098 #[test]
2099 fn wrap_lon_extreme_finite_inputs_stay_bounded_and_finite() {
2100 for raw in [
2101 f64::MAX,
2102 f64::MIN,
2103 f64::MIN_POSITIVE,
2104 -f64::MIN_POSITIVE,
2105 1.0e300,
2106 -1.0e300,
2107 1.0e18,
2108 -1.0e18,
2109 360.0 * 1.0e9,
2110 ] {
2111 let w = wrap_lon(raw);
2112 assert!(w.is_finite(), "{raw} → {w} is not finite");
2113 assert!((-180.0..=180.0).contains(&w), "{raw} → {w} out of range");
2114 }
2115 }
2116
2117 #[test]
2118 fn wrap_lon_is_idempotent_on_representative_inputs() {
2119 for (raw, expected) in [
2120 (0.0_f64, 0.0_f64),
2121 (45.0, 45.0),
2122 (-45.0, -45.0),
2123 (181.0, -179.0),
2124 (-181.0, 179.0),
2125 (720.0, 0.0),
2126 (-720.0, 0.0),
2127 (1.0e6, -80.0),
2128 ] {
2129 let once = wrap_lon(raw);
2130 close(once, expected, 1e-9);
2131 close(wrap_lon(once), once, 1e-9);
2132 }
2133 }
2134
2135 #[test]
2140 fn lon_to_tile_x_zero_tile_count_collapses_to_zero() {
2141 for lon in [-180.0, -1.0, 0.0, 1.0, 180.0] {
2142 assert_eq!(lon_to_tile_x(lon, 0.0), 0.0, "lon {lon} at tile_count 0");
2143 }
2144 }
2145
2146 #[test]
2147 fn lon_to_tile_x_nan_inf_are_defined_not_panics() {
2148 assert!(lon_to_tile_x(f64::NAN, 4.0).is_nan());
2149 assert!(lon_to_tile_x(0.0, f64::NAN).is_nan());
2150 assert_eq!(lon_to_tile_x(f64::INFINITY, 4.0), f64::INFINITY);
2151 assert_eq!(lon_to_tile_x(f64::NEG_INFINITY, 4.0), f64::NEG_INFINITY);
2152 assert!(lon_to_tile_x(f64::INFINITY, 0.0).is_nan());
2154 }
2155
2156 #[test]
2157 fn lon_to_tile_x_is_monotonic_and_saturates_on_huge_counts() {
2158 let mut prev = f64::NEG_INFINITY;
2159 for lon in [-180.0, -90.0, -0.5, 0.0, 0.5, 90.0, 180.0] {
2160 let x = lon_to_tile_x(lon, 256.0);
2161 assert!(x > prev, "lon_to_tile_x must increase with longitude");
2162 prev = x;
2163 }
2164 assert_eq!(lon_to_tile_x(180.0, f64::MAX), f64::MAX);
2165 assert!(lon_to_tile_x(180.0, f64::INFINITY).is_infinite());
2166 }
2167
2168 #[test]
2169 fn tile_x_to_lon_degenerate_tile_counts_do_not_panic() {
2170 assert!(tile_x_to_lon(0.0, 0.0).is_nan());
2172 assert!(tile_x_to_lon(1.0, 0.0).is_infinite());
2173 assert!(tile_x_to_lon(f64::NAN, 4.0).is_nan());
2174 assert!(tile_x_to_lon(f64::MAX, f64::MIN_POSITIVE).is_infinite());
2175 }
2176
2177 #[test]
2178 fn lon_tile_x_round_trips_across_zooms_and_edges() {
2179 for z in [0u32, 1, 5, 14, 22] {
2180 let tc = f64::from(1u32 << z);
2181 for lon in [-180.0, -179.999, -122.4194, 0.0, 0.1, 151.2093, 180.0] {
2182 let x = lon_to_tile_x(lon, tc);
2183 close(tile_x_to_lon(x, tc), lon, 1e-9);
2184 }
2185 }
2186 }
2187
2188 #[test]
2193 fn lat_to_tile_y_inside_the_mercator_band_is_finite_and_ordered() {
2194 let tc = 256.0;
2195 let mut prev = f64::NEG_INFINITY;
2196 for lat in [85.0, 60.0, 30.0, 0.0, -30.0, -60.0, -85.0] {
2198 let y = lat_to_tile_y(lat, tc);
2199 assert!(y.is_finite(), "lat {lat} → {y}");
2200 assert!((0.0..=tc).contains(&y), "lat {lat} → {y} outside the grid");
2201 assert!(y > prev, "tile-y must increase as latitude decreases");
2202 prev = y;
2203 }
2204 }
2205
2206 #[test]
2207 fn lat_to_tile_y_nan_and_infinite_latitudes_are_nan() {
2208 assert!(lat_to_tile_y(f64::NAN, 4.0).is_nan());
2209 assert!(lat_to_tile_y(f64::INFINITY, 4.0).is_nan());
2211 assert!(lat_to_tile_y(f64::NEG_INFINITY, 4.0).is_nan());
2212 assert!(lat_to_tile_y(0.0, f64::NAN).is_nan());
2213 }
2214
2215 #[test]
2216 fn lat_to_tile_y_past_the_poles_does_not_panic() {
2217 let outs: Vec<f64> = [90.0, -90.0, 89.9999, -89.9999, 180.0, -180.0, 1.0e9]
2220 .iter()
2221 .map(|lat| lat_to_tile_y(*lat, 4.0))
2222 .collect();
2223 assert_eq!(outs.len(), 7);
2224 }
2225
2226 #[test]
2227 fn tile_y_to_lat_saturates_at_the_poles_for_out_of_range_rows() {
2228 for (y, expected) in [(-1.0e9_f64, 90.0_f64), (1.0e9, -90.0)] {
2229 let lat = tile_y_to_lat(y, 4.0);
2230 close(lat, expected, 1e-9);
2231 }
2232 for y in [-1.0e300, -1000.0, -1.0, 0.0, 2.0, 1000.0, 1.0e300] {
2234 let lat = tile_y_to_lat(y, 4.0);
2235 assert!(
2236 (-90.0 - 1e-9..=90.0 + 1e-9).contains(&lat),
2237 "row {y} → {lat} outside ±90"
2238 );
2239 }
2240 }
2241
2242 #[test]
2243 fn tile_y_to_lat_degenerate_tile_counts_do_not_panic() {
2244 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());
2247 }
2248
2249 #[test]
2250 fn lat_tile_y_round_trips_at_the_mercator_edges() {
2251 for z in [0u32, 3, 14, 22] {
2252 let tc = f64::from(1u32 << z);
2253 for lat in [-85.05, -45.0, -0.0001, 0.0, 0.0001, 45.0, 85.05] {
2254 let y = lat_to_tile_y(lat, tc);
2255 close(tile_y_to_lat(y, tc), lat, 1e-6);
2256 }
2257 }
2258 }
2259
2260 #[test]
2265 fn pan_viewport_nan_inputs_propagate_without_panicking() {
2266 let (lon, lat) = pan_viewport(f64::NAN, 0.0, 2.0, 10.0, 10.0);
2267 assert!(lat.is_nan(), "NaN centre latitude must stay NaN, got {lat}");
2268 assert!(lon.is_nan() || (-180.0..=180.0).contains(&lon));
2269
2270 let (lon, _) = pan_viewport(0.0, f64::NAN, 2.0, 10.0, 10.0);
2271 assert!(lon.is_nan());
2272
2273 let (lon, lat) = pan_viewport(0.0, 0.0, 2.0, f64::NAN, f64::NAN);
2274 assert!(lon.is_nan() && lat.is_nan());
2275 }
2276
2277 #[test]
2278 fn pan_viewport_infinite_zoom_is_a_no_op() {
2279 let (lon, lat) = pan_viewport(37.0, -122.0, f64::INFINITY, 1.0e6, -1.0e6);
2281 close(lon, -122.0, 1e-9);
2282 close(lat, 37.0, 1e-9);
2283 }
2284
2285 #[test]
2286 fn pan_viewport_negative_infinite_zoom_saturates_latitude_not_panics() {
2287 let (lon, lat) = pan_viewport(0.0, 0.0, f64::NEG_INFINITY, 100.0, 100.0);
2290 assert!(lon.is_nan(), "expected NaN longitude, got {lon}");
2291 close(lat, 85.0, 1e-9);
2292 let (_, lat_south) = pan_viewport(0.0, 0.0, f64::NEG_INFINITY, 0.0, -100.0);
2293 close(lat_south, -85.0, 1e-9);
2294 }
2295
2296 #[test]
2297 fn pan_viewport_extreme_pixel_deltas_stay_inside_the_world() {
2298 for dx in [-1.0e18_f64, -1.0e9, -1.0, 0.0, 1.0, 1.0e9, 1.0e18] {
2299 for dy in [-1.0e18_f64, 0.0, 1.0e18] {
2300 let (lon, lat) = pan_viewport(37.0, -122.0, 0.0, dx, dy);
2301 assert!(lon.is_finite(), "dx {dx} dy {dy} → lon {lon}");
2302 assert!((-180.0..=180.0).contains(&lon), "lon {lon} out of range");
2303 assert!((-85.0..=85.0).contains(&lat), "lat {lat} out of range");
2304 }
2305 }
2306 }
2307
2308 #[test]
2309 fn pan_viewport_zero_zoom_and_zero_delta_is_the_identity() {
2310 let (lon, lat) = pan_viewport(0.0, 0.0, 0.0, 0.0, 0.0);
2311 assert_eq!((lon, lat), (0.0, 0.0));
2312 }
2313
2314 #[test]
2315 fn pan_viewport_latitude_step_shrinks_towards_the_poles() {
2316 let (_, at_equator) = pan_viewport(0.0, 0.0, 2.0, 0.0, 100.0);
2318 let (_, at_80) = pan_viewport(80.0, 0.0, 2.0, 0.0, 100.0);
2319 assert!(
2320 (at_80 - 80.0).abs() < at_equator.abs(),
2321 "pole-adjacent pan {at_80} must move less than equatorial {at_equator}"
2322 );
2323 }
2324
2325 #[test]
2330 fn latlon_at_px_centre_pixel_is_the_viewport_centre() {
2331 let viewport = view(51.5074, -0.1278, 11.0);
2332 let container = LogicalSize::new(800.0, 600.0);
2333 let coord = MapWidget::latlon_at_px(
2334 viewport,
2335 LogicalPosition::new(400.0, 300.0),
2336 container,
2337 );
2338 close(coord.lat_deg, 51.5074, 1e-9);
2339 close(coord.lon_deg, -0.1278, 1e-9);
2340 }
2341
2342 #[test]
2343 fn latlon_at_px_result_is_always_clamped_or_nan() {
2344 let container = LogicalSize::new(800.0, 600.0);
2345 for zoom in [0.0_f32, 2.0, 11.0, 22.0] {
2346 for px in [
2347 LogicalPosition::new(0.0, 0.0),
2348 LogicalPosition::new(-1.0e9, -1.0e9),
2349 LogicalPosition::new(1.0e9, 1.0e9),
2350 LogicalPosition::new(f32::MAX, f32::MIN),
2351 ] {
2352 let c = MapWidget::latlon_at_px(view(0.0, 0.0, zoom), px, container);
2353 assert!(
2354 (-180.0..=180.0).contains(&c.lon_deg),
2355 "zoom {zoom} px {px:?} → lon {}",
2356 c.lon_deg
2357 );
2358 assert!(
2359 (-85.0..=85.0).contains(&c.lat_deg),
2360 "zoom {zoom} px {px:?} → lat {}",
2361 c.lat_deg
2362 );
2363 }
2364 }
2365 }
2366
2367 #[test]
2368 fn latlon_at_px_non_finite_zoom_does_not_panic() {
2369 let container = LogicalSize::new(800.0, 600.0);
2370 let px = LogicalPosition::new(10.0, 10.0);
2371 let c = MapWidget::latlon_at_px(view(0.0, 0.0, f32::INFINITY), px, container);
2373 close(c.lon_deg, 0.0, 1e-9);
2374 close(c.lat_deg, 0.0, 1e-9);
2375 let c = MapWidget::latlon_at_px(view(0.0, 0.0, f32::NEG_INFINITY), px, container);
2377 assert!((-180.0..=180.0).contains(&c.lon_deg));
2378 assert!((-85.0..=85.0).contains(&c.lat_deg));
2379 let c = MapWidget::latlon_at_px(view(0.0, 0.0, f32::NAN), px, container);
2381 assert!(c.lon_deg.is_nan() && c.lat_deg.is_nan());
2382 }
2383
2384 #[test]
2385 fn latlon_at_px_zero_sized_container_is_still_defined() {
2386 let c = MapWidget::latlon_at_px(
2387 view(10.0, 20.0, 4.0),
2388 LogicalPosition::new(0.0, 0.0),
2389 LogicalSize::new(0.0, 0.0),
2390 );
2391 close(c.lat_deg, 10.0, 1e-9);
2392 close(c.lon_deg, 20.0, 1e-9);
2393 }
2394
2395 #[test]
2396 fn px_at_latlon_centre_coord_is_the_container_centre() {
2397 let viewport = view(37.7749, -122.4194, 12.0);
2398 let container = LogicalSize::new(1024.0, 768.0);
2399 let p = MapWidget::px_at_latlon(
2400 viewport,
2401 MapLatLon {
2402 lat_deg: viewport.centre_lat_deg,
2403 lon_deg: viewport.centre_lon_deg,
2404 },
2405 container,
2406 );
2407 close(f64::from(p.x), 512.0, 1e-3);
2408 close(f64::from(p.y), 384.0, 1e-3);
2409 }
2410
2411 #[test]
2412 fn px_at_latlon_saturates_instead_of_overflowing_f32() {
2413 let container = LogicalSize::new(800.0, 600.0);
2416 let p = MapWidget::px_at_latlon(
2417 view(0.0, 0.0, f32::MAX),
2418 MapLatLon {
2419 lat_deg: 10.0,
2420 lon_deg: 10.0,
2421 },
2422 container,
2423 );
2424 assert!(!p.x.is_finite(), "expected a saturated x, got {}", p.x);
2425 assert!(!p.y.is_finite(), "expected a saturated y, got {}", p.y);
2426 }
2427
2428 #[test]
2429 fn px_at_latlon_at_a_pole_centre_does_not_panic() {
2430 let container = LogicalSize::new(800.0, 600.0);
2432 let p = MapWidget::px_at_latlon(
2433 view(90.0, 0.0, 2.0),
2434 MapLatLon {
2435 lat_deg: 0.0,
2436 lon_deg: 0.0,
2437 },
2438 container,
2439 );
2440 assert!(p.x.is_finite(), "x should stay finite, got {}", p.x);
2441 assert!(!p.y.is_nan(), "y must be a number or an infinity, got {}", p.y);
2442 }
2443
2444 #[test]
2445 fn projection_px_round_trips_within_the_clamped_band() {
2446 let container = LogicalSize::new(800.0, 600.0);
2447 for zoom in [2.0_f32, 8.0, 14.0] {
2448 let viewport = view(37.7749, -122.4194, zoom);
2449 for (dlat, dlon) in [(0.0, 0.0), (0.01, 0.02), (-0.03, 0.04)] {
2450 let coord = MapLatLon {
2451 lat_deg: viewport.centre_lat_deg + dlat,
2452 lon_deg: viewport.centre_lon_deg + dlon,
2453 };
2454 let px = MapWidget::px_at_latlon(viewport, coord, container);
2455 let back = MapWidget::latlon_at_px(viewport, px, container);
2456 close(back.lat_deg, coord.lat_deg, 1e-4);
2457 close(back.lon_deg, coord.lon_deg, 1e-4);
2458 }
2459 }
2460 }
2461
2462 #[test]
2467 fn visible_tile_range_zero_zoom_scale_saturates_to_the_i32_extremes() {
2468 let (x0, x1, y0, y1) = visible_tile_range(8.0, 8.0, 800.0, 600.0, 0.0, 16);
2471 assert_eq!((x0, x1), (i32::MIN, i32::MAX));
2472 assert_eq!((y0, y1), (0, 15));
2473 }
2474
2475 #[test]
2476 fn visible_tile_range_infinite_dimensions_saturate_the_same_way() {
2477 let (x0, x1, y0, y1) =
2478 visible_tile_range(8.0, 8.0, f32::INFINITY, f32::INFINITY, 1.0, 16);
2479 assert_eq!((x0, x1), (i32::MIN, i32::MAX));
2480 assert_eq!((y0, y1), (0, 15));
2481 }
2482
2483 #[test]
2484 fn visible_tile_range_nan_inputs_collapse_to_a_single_cell() {
2485 assert_eq!(
2488 visible_tile_range(8.0, 8.0, f32::NAN, f32::NAN, 1.0, 16),
2489 (0, 0, 0, 0)
2490 );
2491 assert_eq!(
2492 visible_tile_range(f32::NAN, f32::NAN, 512.0, 512.0, 1.0, 16),
2493 (0, 0, 0, 0)
2494 );
2495 assert_eq!(
2496 visible_tile_range(8.0, 8.0, 512.0, 512.0, f32::NAN, 16),
2497 (0, 0, 0, 0)
2498 );
2499 }
2500
2501 #[test]
2502 fn visible_tile_range_negative_dimensions_are_taken_absolutely() {
2503 let positive = visible_tile_range(8.0, 8.0, 512.0, 384.0, 1.0, 16);
2504 let negative = visible_tile_range(8.0, 8.0, -512.0, -384.0, 1.0, 16);
2505 assert_eq!(positive, negative);
2506 }
2507
2508 #[test]
2509 fn visible_tile_range_zero_tile_count_yields_an_empty_row_span() {
2510 let (_, _, y0, y1) = visible_tile_range(0.0, 0.0, 512.0, 512.0, 1.0, 0);
2513 assert!(y0 > y1, "expected an empty row span, got {y0}..={y1}");
2514 }
2515
2516 #[test]
2517 fn visible_tile_range_u32_max_tile_count_wraps_to_an_empty_row_span() {
2518 let (_, _, y0, y1) = visible_tile_range(0.0, 0.0, 512.0, 512.0, 1.0, u32::MAX);
2520 assert!(y0 > y1, "expected an empty row span, got {y0}..={y1}");
2521 }
2522
2523 #[test]
2524 fn visible_tile_range_always_keeps_a_one_tile_margin() {
2525 let (x0, x1, y0, y1) = visible_tile_range(8.0, 8.0, 1.0, 1.0, 1.0, 16);
2527 assert!(x0 <= 7 && x1 >= 9, "x span {x0}..={x1} lost its margin");
2528 assert!(y0 <= 7 && y1 >= 9, "y span {y0}..={y1} lost its margin");
2529 }
2530
2531 #[test]
2532 fn visible_tile_range_extreme_zoom_scale_shrinks_to_the_margin() {
2533 let (x0, x1, y0, y1) = visible_tile_range(8.0, 8.0, 800.0, 600.0, f32::MAX, 16);
2535 assert_eq!((x0, x1), (7, 9));
2536 assert_eq!((y0, y1), (7, 9));
2537 }
2538
2539 #[test]
2544 fn wrap_tile_x_zero_tile_count_is_zero_never_a_division_by_zero() {
2545 for x in [i32::MIN, -7, -1, 0, 1, 7, i32::MAX] {
2546 assert_eq!(wrap_tile_x(x, 0), 0, "column {x} at tile_count 0");
2547 }
2548 }
2549
2550 #[test]
2551 fn wrap_tile_x_extremes_stay_inside_the_band() {
2552 for tile_count in [1u32, 2, 4, 256, 65_536, 1 << 30, 1 << 31] {
2553 for x in [i32::MIN, -1_000_000, -1, 0, 1, 1_000_000, i32::MAX] {
2554 let wrapped = wrap_tile_x(x, tile_count);
2555 assert!(
2556 wrapped < tile_count,
2557 "column {x} at tile_count {tile_count} → {wrapped} (out of band)"
2558 );
2559 }
2560 }
2561 }
2562
2563 #[test]
2564 fn wrap_tile_x_matches_rem_euclid_for_realistic_zooms() {
2565 for z in 0u32..=20 {
2566 let tile_count = 1u32 << z;
2567 for x in [i32::MIN, -3, -1, 0, 1, 3, i32::MAX] {
2568 assert_eq!(
2569 wrap_tile_x(x, tile_count),
2570 x.rem_euclid(tile_count as i32) as u32
2571 );
2572 }
2573 }
2574 }
2575
2576 #[test]
2577 fn wrap_tile_x_is_periodic_in_tile_count() {
2578 for tile_count in [1u32, 2, 4, 16, 1024] {
2579 for x in [-9i32, -1, 0, 1, 9] {
2580 let shifted = x
2581 .checked_add(tile_count as i32)
2582 .expect("shift must stay in range");
2583 assert_eq!(wrap_tile_x(x, tile_count), wrap_tile_x(shifted, tile_count));
2584 }
2585 }
2586 }
2587
2588 #[test]
2593 fn map_visible_tiles_zero_bounds_still_covers_the_centre() {
2594 let layer = MapTileLayer::default();
2595 let tiles = map_visible_tiles(&view(0.0, 0.0, 2.0), LogicalSize::new(0.0, 0.0), &layer);
2596 assert!(!tiles.is_empty(), "the one-tile margin must survive 0x0 bounds");
2597 for t in &tiles {
2598 assert_eq!(t.z, 2);
2599 assert!(t.x < 4 && t.y < 4, "tile {t:?} escaped the z2 grid");
2600 }
2601 }
2602
2603 #[test]
2604 fn map_visible_tiles_non_finite_bounds_degenerate_to_one_tile() {
2605 let layer = MapTileLayer::default();
2606 let tiles = map_visible_tiles(
2607 &view(0.0, 0.0, 2.0),
2608 LogicalSize::new(f32::NAN, f32::NAN),
2609 &layer,
2610 );
2611 assert_eq!(tiles.len(), 1, "NaN bounds must not enumerate a grid");
2612 }
2613
2614 #[test]
2615 fn map_visible_tiles_nan_zoom_degenerates_to_one_tile() {
2616 let layer = MapTileLayer::default();
2617 let tiles = map_visible_tiles(
2618 &view(0.0, 0.0, f32::NAN),
2619 LogicalSize::new(800.0, 600.0),
2620 &layer,
2621 );
2622 assert_eq!(tiles.len(), 1);
2623 assert_eq!(tiles[0].z, 0, "a NaN zoom clamps to the layer minimum");
2624 }
2625
2626 #[test]
2627 fn map_visible_tiles_positive_infinite_zoom_stays_bounded() {
2628 let layer = MapTileLayer::default();
2629 let tiles = map_visible_tiles(
2630 &view(0.0, 0.0, f32::INFINITY),
2631 LogicalSize::new(800.0, 600.0),
2632 &layer,
2633 );
2634 assert!(!tiles.is_empty());
2635 assert!(tiles.len() < 64, "+inf zoom produced {} tiles", tiles.len());
2636 for t in &tiles {
2637 assert_eq!(t.z, layer.max_zoom, "+inf zoom must clamp to max_zoom");
2638 }
2639 }
2640
2641 #[test]
2642 fn map_visible_tiles_negative_bounds_match_positive_bounds() {
2643 let layer = MapTileLayer::default();
2644 let viewport = view(48.0, 11.0, 6.0);
2645 let positive = map_visible_tiles(&viewport, LogicalSize::new(640.0, 480.0), &layer);
2646 let negative = map_visible_tiles(&viewport, LogicalSize::new(-640.0, -480.0), &layer);
2647 assert_eq!(positive, negative);
2648 }
2649
2650 #[test]
2651 fn map_visible_tiles_ids_always_live_inside_the_grid() {
2652 for (min_zoom, max_zoom) in [(0u8, 14u8), (3, 5), (0, 0)] {
2653 let layer = layer_zoom(min_zoom, max_zoom);
2654 for zoom in [-1.0_f32, 0.0, 2.5, 7.0, 99.0] {
2655 for (lat, lon) in [(0.0, 0.0), (85.0, 180.0), (-85.0, -180.0), (60.0, 179.99)] {
2656 let viewport = view(lat, lon, zoom);
2657 let tiles =
2658 map_visible_tiles(&viewport, LogicalSize::new(800.0, 600.0), &layer);
2659 let expected_z = (zoom.floor() as i32)
2660 .clamp(i32::from(min_zoom), i32::from(max_zoom))
2661 as u8;
2662 let tile_count = 1u32 << u32::from(expected_z);
2663 for t in &tiles {
2664 assert_eq!(t.z, expected_z, "zoom {zoom} produced z {}", t.z);
2665 assert!(t.x < tile_count, "column {} >= {tile_count}", t.x);
2666 assert!(t.y < tile_count, "row {} >= {tile_count}", t.y);
2667 }
2668 }
2669 }
2670 }
2671 }
2672
2673 #[test]
2674 fn map_visible_tiles_wraps_columns_across_the_antimeridian() {
2675 let layer = layer_zoom(0, 14);
2678 let tiles = map_visible_tiles(
2679 &view(0.0, 180.0, 3.0),
2680 LogicalSize::new(1024.0, 256.0),
2681 &layer,
2682 );
2683 assert!(!tiles.is_empty());
2684 assert!(
2685 tiles.iter().any(|t| t.x == 0),
2686 "panning past +180° must surface the west-edge column"
2687 );
2688 for t in &tiles {
2689 assert!(t.x < 8, "column {} escaped the z3 grid", t.x);
2690 }
2691 }
2692
2693 #[test]
2698 fn create_uses_the_given_layer_and_neutral_defaults() {
2699 let layer = layer_zoom(2, 9);
2700 let widget = MapWidget::create(layer.clone());
2701 assert_eq!(widget.layer, layer);
2702 assert_eq!(widget.viewport, MapViewport::default());
2703 assert!(widget.container_style.as_slice().is_empty());
2704 assert!(matches!(
2705 widget.on_viewport_changed,
2706 OptionMapViewportChanged::None
2707 ));
2708 assert!(matches!(widget.on_pin_tap, OptionMapPinTap::None));
2709 }
2710
2711 #[test]
2712 fn create_accepts_degenerate_layers_without_panicking() {
2713 let widget = MapWidget::create(MapTileLayer {
2716 url_template: AzString::from(""),
2717 min_zoom: 30,
2718 max_zoom: 0,
2719 attribution: AzString::from(""),
2720 style_css: AzString::from(""),
2721 });
2722 assert_eq!(widget.layer.min_zoom, 30);
2723 assert_eq!(widget.layer.max_zoom, 0);
2724 }
2725
2726 #[test]
2727 fn with_viewport_stores_extreme_values_verbatim() {
2728 let widget = MapWidget::create(MapTileLayer::default())
2729 .with_viewport(view(1.0e300, -1.0e300, f32::MAX));
2730 assert_eq!(widget.viewport.centre_lat_deg, 1.0e300);
2731 assert_eq!(widget.viewport.centre_lon_deg, -1.0e300);
2732 assert_eq!(widget.viewport.zoom, f32::MAX);
2733
2734 let widget = MapWidget::create(MapTileLayer::default())
2735 .with_viewport(view(f64::NAN, f64::INFINITY, f32::NEG_INFINITY));
2736 assert!(widget.viewport.centre_lat_deg.is_nan());
2737 assert!(widget.viewport.centre_lon_deg.is_infinite());
2738 assert!(widget.viewport.zoom.is_infinite());
2739 }
2740
2741 #[test]
2742 fn with_viewport_is_last_write_wins() {
2743 let widget = MapWidget::create(MapTileLayer::default())
2744 .with_viewport(view(1.0, 2.0, 3.0))
2745 .with_viewport(view(4.0, 5.0, 6.0));
2746 assert_eq!(widget.viewport, view(4.0, 5.0, 6.0));
2747 }
2748
2749 #[test]
2750 fn with_container_style_replaces_the_style_vec() {
2751 let css = CssPropertyWithConditionsVec::parse("width: 100px; height: 50px;");
2752 let parsed_len = css.as_slice().len();
2753 assert!(parsed_len > 0, "positive control: the style must parse");
2754 let widget = MapWidget::create(MapTileLayer::default()).with_container_style(css);
2755 assert_eq!(widget.container_style.as_slice().len(), parsed_len);
2756
2757 let widget = MapWidget::create(MapTileLayer::default())
2759 .with_container_style(CssPropertyWithConditionsVec::parse(""));
2760 assert!(widget.container_style.as_slice().is_empty());
2761 }
2762
2763 #[test]
2764 fn with_container_style_tolerates_garbage_and_unicode() {
2765 for style in [
2766 "\u{1F600}: \u{1F600};",
2767 " ",
2768 ";;;;",
2769 "width",
2770 "width: ;",
2771 "}{",
2772 "color: \u{0301}\u{0301};",
2773 ] {
2774 let widget = MapWidget::create(MapTileLayer::default())
2775 .with_container_style(CssPropertyWithConditionsVec::parse(style));
2776 let _ = widget.container_style.as_slice().len();
2778 }
2779 }
2780
2781 #[test]
2782 fn with_on_viewport_changed_installs_and_replaces_the_hook() {
2783 let mut widget = MapWidget::create(MapTileLayer::default()).with_on_viewport_changed(
2784 RefAny::new(HookLog::default()),
2785 record_viewport as MapViewportChangedCallbackType,
2786 );
2787 assert!(matches!(
2788 widget.on_viewport_changed,
2789 OptionMapViewportChanged::Some(_)
2790 ));
2791 widget.set_on_viewport_changed(
2793 RefAny::new(HookLog::default()),
2794 record_viewport as MapViewportChangedCallbackType,
2795 );
2796 assert!(matches!(
2797 widget.on_viewport_changed,
2798 OptionMapViewportChanged::Some(_)
2799 ));
2800 assert!(matches!(widget.on_pin_tap, OptionMapPinTap::None));
2801 }
2802
2803 #[test]
2804 fn with_on_pin_tap_installs_and_replaces_the_hook() {
2805 let mut widget = MapWidget::create(MapTileLayer::default())
2806 .with_on_pin_tap(RefAny::new(HookLog::default()), record_pin as MapPinTapCallbackType);
2807 assert!(matches!(widget.on_pin_tap, OptionMapPinTap::Some(_)));
2808 widget.set_on_pin_tap(
2809 RefAny::new(HookLog::default()),
2810 record_pin as MapPinTapCallbackType,
2811 );
2812 assert!(matches!(widget.on_pin_tap, OptionMapPinTap::Some(_)));
2813 assert!(matches!(
2814 widget.on_viewport_changed,
2815 OptionMapViewportChanged::None
2816 ));
2817 }
2818
2819 #[test]
2820 fn builder_chain_order_does_not_matter_for_independent_fields() {
2821 let layer = layer_zoom(1, 12);
2822 let viewport = view(10.0, 20.0, 5.0);
2823 let a = MapWidget::create(layer.clone())
2824 .with_viewport(viewport)
2825 .with_container_style(CssPropertyWithConditionsVec::parse("width: 10px;"));
2826 let b = MapWidget::create(layer)
2827 .with_container_style(CssPropertyWithConditionsVec::parse("width: 10px;"))
2828 .with_viewport(viewport);
2829 assert_eq!(a, b);
2830 }
2831
2832 #[test]
2837 fn dom_builds_a_single_virtual_view_child_with_a_dataset() {
2838 let mut dom = MapWidget::create(MapTileLayer::default())
2839 .with_viewport(view(0.0, 0.0, 3.0))
2840 .dom();
2841 assert_eq!(dom.children.as_slice().len(), 1, "one VirtualView child");
2842 let dataset = dom
2843 .root
2844 .get_dataset_mut()
2845 .expect("the widget div must carry a MapTileCache dataset");
2846 let cache = dataset
2847 .downcast_ref::<MapTileCache>()
2848 .expect("the dataset must be a MapTileCache");
2849 assert_eq!(cache.viewport.zoom, 3.0);
2850 assert!(cache.tiles.is_empty());
2851 assert!(cache.fetch_callback.is_none(), "dom() wires no worker");
2852 }
2853
2854 #[test]
2855 fn dom_survives_degenerate_viewports_and_layers() {
2856 for viewport in [
2857 view(f64::NAN, f64::NAN, f32::NAN),
2858 view(1.0e300, -1.0e300, f32::INFINITY),
2859 view(0.0, 0.0, f32::NEG_INFINITY),
2860 ] {
2861 let dom = MapWidget::create(layer_zoom(200, 1))
2862 .with_viewport(viewport)
2863 .dom();
2864 assert_eq!(dom.children.as_slice().len(), 1);
2865 }
2866 }
2867
2868 #[test]
2869 fn dom_with_a_container_style_still_builds_the_grid() {
2870 let dom = MapWidget::create(MapTileLayer::default())
2871 .with_container_style(CssPropertyWithConditionsVec::parse(
2872 "position: relative; width: 320px; height: 240px;",
2873 ))
2874 .dom();
2875 assert_eq!(dom.children.as_slice().len(), 1);
2876 }
2877
2878 #[test]
2879 fn dom_with_fetch_records_the_worker_in_the_dataset() {
2880 let mut dom = MapWidget::create(MapTileLayer::default())
2881 .dom_with_fetch(ThreadCallback::new(noop_worker));
2882 let dataset = dom.root.get_dataset_mut().expect("dataset");
2883 let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
2884 let cb = cache
2885 .fetch_callback
2886 .as_ref()
2887 .expect("dom_with_fetch must record the worker");
2888 assert_eq!(cb.cb as usize, noop_worker as ThreadCallbackType as usize);
2889 }
2890
2891 #[test]
2892 fn dom_carries_the_user_hooks_into_the_cache() {
2893 let mut dom = MapWidget::create(MapTileLayer::default())
2894 .with_on_viewport_changed(
2895 RefAny::new(HookLog::default()),
2896 record_viewport as MapViewportChangedCallbackType,
2897 )
2898 .with_on_pin_tap(
2899 RefAny::new(HookLog::default()),
2900 record_pin as MapPinTapCallbackType,
2901 )
2902 .dom();
2903 let dataset = dom.root.get_dataset_mut().expect("dataset");
2904 let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
2905 assert!(matches!(
2906 cache.on_viewport_changed,
2907 OptionMapViewportChanged::Some(_)
2908 ));
2909 assert!(matches!(cache.on_pin_tap, OptionMapPinTap::Some(_)));
2910 }
2911
2912 #[test]
2917 fn tile_cache_new_starts_empty_and_idle() {
2918 let cache = MapTileCache::new(layer_zoom(0, 14), view(1.0, 2.0, 3.0));
2919 assert!(cache.tiles.is_empty());
2920 assert!(cache.fetch_callback.is_none());
2921 assert!(cache.drag_anchor.is_none());
2922 assert!(cache.pinch_anchor.is_none());
2923 assert!(cache.press_origin.is_none());
2924 assert_eq!(cache.viewport, view(1.0, 2.0, 3.0));
2925 assert_eq!(cache.layer.max_zoom, 14);
2926 }
2927
2928 #[test]
2929 fn mark_tile_ready_and_failed_overwrite_each_other() {
2930 let mut cache = cache_at(0.0, 0.0, 4.0);
2931 let tile = MapTileId { z: 4, x: 8, y: 8 };
2932 cache.mark_tile_ready(tile, AzString::from("<svg/>"));
2933 assert!(matches!(cache.tiles.get(&tile), Some(TileEntry::Ready { .. })));
2934 cache.mark_tile_failed(tile, AzString::from("boom"));
2935 assert!(matches!(cache.tiles.get(&tile), Some(TileEntry::Failed { .. })));
2936 cache.mark_tile_ready(tile, AzString::from(""));
2937 assert!(matches!(cache.tiles.get(&tile), Some(TileEntry::Ready { .. })));
2938 assert_eq!(cache.tiles.len(), 1, "the same id must not duplicate");
2939 }
2940
2941 #[test]
2942 fn mark_tile_ready_accepts_empty_unicode_and_huge_payloads() {
2943 let mut cache = cache_at(0.0, 0.0, 4.0);
2944 let payloads = [
2945 AzString::from(""),
2946 AzString::from("\u{1F600}\u{4F60}\u{597D}e\u{0301}"),
2947 AzString::from("\0\u{FFFD}\r\n\t"),
2948 AzString::from("x".repeat(1_000_000)),
2949 ];
2950 for (i, svg) in payloads.into_iter().enumerate() {
2951 cache.mark_tile_ready(
2952 MapTileId {
2953 z: 4,
2954 x: i as u32,
2955 y: 0,
2956 },
2957 svg,
2958 );
2959 }
2960 assert_eq!(cache.tiles.len(), 4);
2961 }
2962
2963 #[test]
2964 fn mark_tile_ready_accepts_out_of_range_tile_ids() {
2965 let mut cache = cache_at(0.0, 0.0, 4.0);
2968 for tile in [
2969 MapTileId { z: 0, x: 0, y: 0 },
2970 MapTileId {
2971 z: 31,
2972 x: u32::MAX,
2973 y: u32::MAX,
2974 },
2975 MapTileId {
2976 z: 4,
2977 x: u32::MAX,
2978 y: 0,
2979 },
2980 ] {
2981 cache.mark_tile_failed(tile, AzString::from("e"));
2982 }
2983 assert_eq!(cache.tiles.len(), 3);
2984 }
2985
2986 fn fill_ready_grid(cache: &mut MapTileCache, z: u8, side: u32) {
2991 for x in 0..side {
2992 for y in 0..side {
2993 cache
2994 .tiles
2995 .insert(MapTileId { z, x, y }, TileEntry::Ready { svg: AzString::from("<svg/>") });
2996 }
2997 }
2998 }
2999
3000 #[test]
3001 fn prune_never_evicts_in_flight_tiles_even_far_over_the_cap() {
3002 let mut cache = cache_at(0.0, 0.0, 4.0);
3003 for x in 0..16u32 {
3004 for y in 0..16u32 {
3005 cache.tiles.insert(
3006 MapTileId { z: 4, x, y },
3007 if (x + y) % 2 == 0 {
3008 TileEntry::Pending
3009 } else {
3010 TileEntry::Fetching
3011 },
3012 );
3013 }
3014 }
3015 assert_eq!(cache.tiles.len(), 256);
3016 cache.prune_distant_tiles();
3017 assert_eq!(
3018 cache.tiles.len(),
3019 256,
3020 "in-flight tiles are unevictable, so the cap can be exceeded"
3021 );
3022 }
3023
3024 #[test]
3025 fn prune_with_a_nan_viewport_centre_still_bounds_the_cache() {
3026 let mut cache = MapTileCache::new(layer_zoom(0, 19), view(f64::NAN, f64::NAN, 4.0));
3027 fill_ready_grid(&mut cache, 4, 16);
3028 assert_eq!(cache.tiles.len(), 256);
3029 cache.prune_distant_tiles();
3032 assert_eq!(cache.tiles.len(), 192);
3033 }
3034
3035 #[test]
3036 fn prune_with_non_finite_zoom_clamps_to_the_layer_band() {
3037 for zoom in [f32::INFINITY, f32::NEG_INFINITY, f32::NAN, 1.0e30, -1.0e30] {
3038 let mut cache = MapTileCache::new(layer_zoom(0, 19), view(0.0, 0.0, zoom));
3039 fill_ready_grid(&mut cache, 4, 16);
3040 cache.prune_distant_tiles();
3041 assert_eq!(cache.tiles.len(), 192, "zoom {zoom} must still bound the cache");
3042 }
3043 }
3044
3045 #[test]
3046 fn prune_is_idempotent_once_under_the_cap() {
3047 let mut cache = cache_at(0.0, 0.0, 4.0);
3048 fill_ready_grid(&mut cache, 4, 16);
3049 cache.prune_distant_tiles();
3050 let first = cache.tiles.len();
3051 let survivors: Vec<MapTileId> = cache.tiles.keys().copied().collect();
3052 cache.prune_distant_tiles();
3053 assert_eq!(cache.tiles.len(), first);
3054 assert_eq!(
3055 cache.tiles.keys().copied().collect::<Vec<_>>(),
3056 survivors,
3057 "a second prune under the cap must change nothing"
3058 );
3059 }
3060
3061 #[test]
3062 fn prune_drops_the_farthest_tiles_first_across_zoom_levels() {
3063 let mut cache = cache_at(0.0, 0.0, 4.0);
3066 fill_ready_grid(&mut cache, 4, 16);
3067 let wrong_zoom = MapTileId { z: 9, x: 256, y: 256 };
3068 cache
3069 .tiles
3070 .insert(wrong_zoom, TileEntry::Ready { svg: AzString::from("<svg/>") });
3071 let near = MapTileId { z: 4, x: 8, y: 8 };
3072 cache.prune_distant_tiles();
3073 assert!(cache.tiles.len() <= 192);
3074 assert!(
3075 !cache.tiles.contains_key(&wrong_zoom),
3076 "a zoom-mismatched tile must be evicted before same-zoom neighbours"
3077 );
3078 assert!(cache.tiles.contains_key(&near), "the centre tile must survive");
3079 }
3080
3081 #[test]
3086 fn merge_with_a_wrong_typed_new_dataset_returns_the_old_one_intact() {
3087 let mut old_cache = cache_at(0.0, 0.0, 5.0);
3088 let tile = MapTileId { z: 5, x: 1, y: 1 };
3089 old_cache.mark_tile_ready(tile, AzString::from("<svg/>"));
3090 let mut merged = merge_map_tile_cache(RefAny::new(0u32), RefAny::new(old_cache));
3091 let cache = merged.downcast_ref::<MapTileCache>().expect("old cache");
3092 assert_eq!(cache.viewport.zoom, 5.0, "no adoption from a bogus new dataset");
3093 assert!(cache.tiles.contains_key(&tile));
3094 }
3095
3096 #[test]
3097 fn merge_with_a_wrong_typed_old_dataset_returns_it_unchanged() {
3098 let new_cache = cache_at(0.0, 0.0, 9.0);
3099 let mut merged = merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(7u64));
3100 assert!(
3101 merged.downcast_ref::<MapTileCache>().is_none(),
3102 "the merge must not fabricate a cache out of a wrong-typed dataset"
3103 );
3104 assert_eq!(*merged.downcast_ref::<u64>().expect("u64 payload"), 7);
3105 }
3106
3107 #[test]
3108 fn merge_of_two_aliases_of_one_dataset_does_not_panic() {
3109 let dataset = RefAny::new(cache_at(0.0, 0.0, 5.0));
3113 let mut merged = merge_map_tile_cache(dataset.clone(), dataset);
3114 let cache = merged.downcast_ref::<MapTileCache>().expect("cache");
3115 assert_eq!(cache.viewport.zoom, 5.0);
3116 }
3117
3118 #[test]
3119 fn merge_adopts_the_worker_only_when_the_old_cache_has_none() {
3120 let old_cache = cache_at(0.0, 0.0, 5.0);
3122 let mut new_cache = cache_at(0.0, 0.0, 6.0);
3123 new_cache.fetch_callback = Some(ThreadCallback::new(noop_worker));
3124 let mut merged = merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(old_cache));
3125 {
3126 let cache = merged.downcast_ref::<MapTileCache>().expect("cache");
3127 let cb = cache.fetch_callback.as_ref().expect("adopted worker");
3128 assert_eq!(cb.cb as usize, noop_worker as ThreadCallbackType as usize);
3129 }
3130
3131 let mut old_cache = cache_at(0.0, 0.0, 5.0);
3133 old_cache.fetch_callback = Some(ThreadCallback::new(noop_worker));
3134 let mut new_cache = cache_at(0.0, 0.0, 6.0);
3135 new_cache.fetch_callback = Some(ThreadCallback::new(other_noop_worker));
3136 let mut merged = merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(old_cache));
3137 let cache = merged.downcast_ref::<MapTileCache>().expect("cache");
3138 let cb = cache.fetch_callback.as_ref().expect("kept worker");
3139 assert_eq!(cb.cb as usize, noop_worker as ThreadCallbackType as usize);
3140 }
3141
3142 #[test]
3143 fn merge_adopts_the_build_layer_and_viewport_even_when_degenerate() {
3144 let old_cache = MapTileCache::new(layer_zoom(0, 19), view(10.0, 20.0, 5.0));
3145 let new_cache =
3146 MapTileCache::new(layer_zoom(3, 7), view(f64::NAN, f64::INFINITY, f32::NAN));
3147 let mut merged = merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(old_cache));
3148 let cache = merged.downcast_ref::<MapTileCache>().expect("cache");
3149 assert!(cache.viewport.centre_lat_deg.is_nan());
3150 assert!(cache.viewport.zoom.is_nan());
3151 assert_eq!(cache.layer.min_zoom, 3);
3152 assert_eq!(cache.layer.max_zoom, 7);
3153 }
3154
3155 #[test]
3160 fn build_tile_url_without_placeholders_is_the_identity() {
3161 let tile = MapTileId { z: 1, x: 2, y: 3 };
3162 assert_eq!(build_tile_url("", tile), "");
3163 assert_eq!(build_tile_url("https://t.example/fixed", tile), "https://t.example/fixed");
3164 assert_eq!(build_tile_url("{q}/{Z}/{ x }", tile), "{q}/{Z}/{ x }");
3166 }
3167
3168 #[test]
3169 fn build_tile_url_substitutes_extreme_tile_ids() {
3170 let tile = MapTileId {
3171 z: u8::MAX,
3172 x: u32::MAX,
3173 y: 0,
3174 };
3175 assert_eq!(build_tile_url("{z}/{x}/{y}", tile), "255/4294967295/0");
3176 }
3177
3178 #[test]
3179 fn build_tile_url_handles_unicode_and_unbalanced_braces() {
3180 let tile = MapTileId { z: 7, x: 8, y: 9 };
3181 assert_eq!(
3182 build_tile_url("\u{1F600}/{z}/\u{4F60}\u{597D}/{y}", tile),
3183 "\u{1F600}/7/\u{4F60}\u{597D}/9"
3184 );
3185 assert_eq!(build_tile_url("{{z}}", tile), "{7}");
3186 assert_eq!(build_tile_url("{z", tile), "{z");
3187 assert_eq!(build_tile_url("z}", tile), "z}");
3188 }
3189
3190 #[test]
3191 fn build_tile_url_substitution_is_not_re_scanned() {
3192 let tile = MapTileId { z: 1, x: 2, y: 3 };
3196 assert_eq!(build_tile_url("{z}{x}{y}{z}", tile), "1231");
3197 }
3198
3199 #[test]
3200 fn build_tile_url_extremely_long_template_does_not_hang() {
3201 let template = "{z}/".repeat(100_000);
3202 let url = build_tile_url(&template, MapTileId { z: 14, x: 0, y: 0 });
3203 assert_eq!(url.len(), 100_000 * 3);
3204 assert!(url.starts_with("14/14/"));
3205 }
3206
3207 #[cfg(all(feature = "xml", feature = "cpurender"))]
3214 const MINIMAL_TILE_SVG: &str =
3215 r#"<svg viewBox="0 0 16 16"><rect x="0" y="0" width="16" height="16" fill="red"/></svg>"#;
3216
3217 #[cfg(all(feature = "xml", feature = "cpurender"))]
3218 #[test]
3219 fn svg_raster_valid_minimal_is_the_positive_control() {
3220 assert!(
3221 svg_string_to_dom(MINIMAL_TILE_SVG).is_some(),
3222 "a well-formed <svg> must rasterise into a Dom"
3223 );
3224 }
3225
3226 #[cfg(all(feature = "xml", feature = "cpurender"))]
3227 #[test]
3228 fn svg_raster_empty_whitespace_and_garbage_return_none() {
3229 for bad in [
3230 "",
3231 " ",
3232 " \t\n\r ",
3233 "garbage",
3234 "<<<>>>",
3235 "<svg",
3236 "</svg>",
3237 "<html><body/></html>",
3238 "\u{0}\u{1}\u{2}",
3239 ] {
3240 assert!(
3241 svg_string_to_dom(bad).is_none(),
3242 "{bad:?} must be rejected, not rendered"
3243 );
3244 }
3245 }
3246
3247 #[cfg(all(feature = "xml", feature = "cpurender"))]
3248 #[test]
3249 fn svg_raster_boundary_numeric_attributes_do_not_panic() {
3250 for svg in [
3251 r#"<svg viewBox="0 0 16 16"><rect width="0" height="-0" fill="red"/></svg>"#,
3252 r#"<svg viewBox="0 0 16 16"><rect width="NaN" height="inf" fill="red"/></svg>"#,
3253 r#"<svg viewBox="0 0 16 16"><rect width="1e400" height="1e-400" fill="red"/></svg>"#,
3254 r#"<svg viewBox="0 0 16 16"><rect width="9223372036854775807" height="8"/></svg>"#,
3255 r#"<svg viewBox="0 0 0 0"><rect width="8" height="8" fill="red"/></svg>"#,
3256 r#"<svg viewBox="NaN NaN NaN NaN"><rect width="8" height="8" fill="red"/></svg>"#,
3257 ] {
3258 let _ = svg_string_to_dom(svg).is_some();
3260 }
3261 }
3262
3263 #[cfg(all(feature = "xml", feature = "cpurender"))]
3264 #[test]
3265 fn svg_raster_unicode_content_does_not_panic() {
3266 let svg = "<svg viewBox=\"0 0 16 16\"><title>\u{1F600} \u{4F60}\u{597D} \
3267 e\u{0301} \u{202E}</title><rect width=\"16\" height=\"16\" \
3268 fill=\"red\"/></svg>";
3269 assert!(svg_string_to_dom(svg).is_some());
3270 }
3271
3272 #[cfg(all(feature = "xml", feature = "cpurender"))]
3273 #[test]
3274 fn svg_raster_leading_and_trailing_junk_is_deterministic() {
3275 for svg in [
3276 " <svg viewBox=\"0 0 8 8\"><rect width=\"8\" height=\"8\"/></svg> ",
3277 "<svg viewBox=\"0 0 8 8\"><rect width=\"8\" height=\"8\"/></svg>;garbage",
3278 "junk<svg viewBox=\"0 0 8 8\"><rect width=\"8\" height=\"8\"/></svg>",
3279 ] {
3280 assert_eq!(
3282 svg_string_to_dom(svg).is_some(),
3283 svg_string_to_dom(svg).is_some(),
3284 "{svg:?} parsed non-deterministically"
3285 );
3286 }
3287 }
3288
3289 #[cfg(all(feature = "xml", feature = "cpurender"))]
3290 #[test]
3291 fn svg_raster_extremely_long_input_does_not_hang() {
3292 let svg = alloc::format!(
3293 "<svg viewBox=\"0 0 8 8\"><desc>{}</desc><rect width=\"8\" height=\"8\" \
3294 fill=\"red\"/></svg>",
3295 "a".repeat(1_000_000)
3296 );
3297 assert!(svg_string_to_dom(&svg).is_some());
3298 }
3299
3300 #[cfg(all(feature = "xml", feature = "cpurender"))]
3301 #[test]
3302 fn svg_raster_deeply_nested_groups_do_not_stack_overflow() {
3303 let ok = std::thread::Builder::new()
3306 .stack_size(128 * 1024 * 1024)
3307 .spawn(|| {
3308 const DEPTH: usize = 2_000;
3309 let svg = alloc::format!(
3310 "<svg viewBox=\"0 0 8 8\">{}<rect width=\"8\" height=\"8\" \
3311 fill=\"red\"/>{}</svg>",
3312 "<g>".repeat(DEPTH),
3313 "</g>".repeat(DEPTH)
3314 );
3315 svg_string_to_dom(&svg).is_some()
3316 })
3317 .expect("spawn")
3318 .join()
3319 .expect("2000-deep nesting must not overflow the stack");
3320 assert!(ok);
3321 }
3322
3323 #[cfg(all(feature = "xml", not(feature = "cpurender")))]
3326 #[test]
3327 fn svg_dom_valid_minimal_is_the_positive_control() {
3328 assert!(svg_string_to_dom("<svg><g/></svg>").is_some());
3329 }
3330
3331 #[cfg(all(feature = "xml", not(feature = "cpurender")))]
3332 #[test]
3333 fn svg_dom_malformed_markup_returns_none() {
3334 for bad in ["<<<>>>", "<svg", "</svg>", "<a></b>", "\u{0}\u{1}"] {
3335 assert!(svg_string_to_dom(bad).is_none(), "{bad:?} must be rejected");
3336 }
3337 }
3338
3339 #[cfg(all(feature = "xml", not(feature = "cpurender")))]
3340 #[test]
3341 fn svg_dom_empty_whitespace_and_unicode_do_not_panic() {
3342 for input in ["", " ", " \t\n\r ", "\u{1F600}", "e\u{0301}"] {
3343 assert_eq!(
3344 svg_string_to_dom(input).is_some(),
3345 svg_string_to_dom(input).is_some()
3346 );
3347 }
3348 }
3349
3350 #[cfg(all(feature = "xml", not(feature = "cpurender")))]
3351 #[test]
3352 fn svg_dom_extremely_long_input_does_not_hang() {
3353 let svg = alloc::format!("<svg><desc>{}</desc></svg>", "a".repeat(1_000_000));
3354 let _ = svg_string_to_dom(&svg).is_some();
3355 }
3356
3357 #[cfg(not(feature = "xml"))]
3360 #[test]
3361 fn svg_stub_returns_none_for_every_input() {
3362 for input in ["", " ", "<svg/>", "<svg><g/></svg>", "\u{1F600}", "<<<>>>"] {
3363 assert!(svg_string_to_dom(input).is_none());
3364 }
3365 let long = "a".repeat(1_000_000);
3366 assert!(svg_string_to_dom(&long).is_none());
3367 }
3368
3369 #[test]
3374 fn invoke_viewport_changed_without_a_hook_is_do_nothing() {
3375 let (update, changes) = with_callback_info(|info| {
3376 invoke_viewport_changed(&OptionMapViewportChanged::None, &info, view(0.0, 0.0, 2.0))
3377 });
3378 assert_eq!(update, Update::DoNothing);
3379 assert!(changes.is_empty());
3380 }
3381
3382 #[test]
3383 fn invoke_viewport_changed_forwards_even_a_nan_viewport() {
3384 let mut log = RefAny::new(HookLog::default());
3385 let hook = OptionMapViewportChanged::Some(MapViewportChanged {
3386 refany: log.clone(),
3387 callback: (record_viewport as MapViewportChangedCallbackType).into(),
3388 });
3389 let viewport = view(f64::NAN, f64::INFINITY, f32::NAN);
3390 let (update, _) = with_callback_info(|info| invoke_viewport_changed(&hook, &info, viewport));
3391 assert_eq!(update, Update::DoNothing);
3392 assert_eq!(hook_log(&mut log), (1, 0));
3393 }
3394
3395 #[test]
3396 fn invoke_pin_tap_without_a_hook_is_do_nothing() {
3397 let (update, _) = with_callback_info(|info| {
3398 invoke_pin_tap(
3399 &OptionMapPinTap::None,
3400 &info,
3401 MapLatLon {
3402 lat_deg: 0.0,
3403 lon_deg: 0.0,
3404 },
3405 )
3406 });
3407 assert_eq!(update, Update::DoNothing);
3408 }
3409
3410 #[test]
3411 fn invoke_pin_tap_returns_the_users_update_verbatim() {
3412 let mut log = RefAny::new(HookLog::default());
3413 let hook = OptionMapPinTap::Some(MapPinTap {
3414 refany: log.clone(),
3415 callback: (record_pin as MapPinTapCallbackType).into(),
3416 });
3417 let (update, _) = with_callback_info(|info| {
3418 invoke_pin_tap(
3419 &hook,
3420 &info,
3421 MapLatLon {
3422 lat_deg: f64::NAN,
3423 lon_deg: -1.0e300,
3424 },
3425 )
3426 });
3427 assert_eq!(update, Update::RefreshDom);
3428 assert_eq!(hook_log(&mut log), (0, 1));
3429 }
3430
3431 #[test]
3436 fn pointer_down_without_a_cursor_is_a_no_op() {
3437 let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
3438 let (update, _) =
3439 with_callback_info(|info| map_on_pointer_down(dataset.clone(), info));
3440 assert_eq!(update, Update::DoNothing);
3441 let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3442 assert!(cache.drag_anchor.is_none());
3443 assert!(cache.press_origin.is_none());
3444 }
3445
3446 #[test]
3447 fn pointer_down_records_both_anchors() {
3448 let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
3449 let (update, _) = with_callback_info_at(cursor_at(120.0, 80.0), |info| {
3450 map_on_pointer_down(dataset.clone(), info)
3451 });
3452 assert_eq!(update, Update::DoNothing);
3453 let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3454 let anchor = cache.drag_anchor.expect("drag anchor");
3455 let press = cache.press_origin.expect("press origin");
3456 assert_eq!((anchor.x, anchor.y), (120.0, 80.0));
3457 assert_eq!((press.x, press.y), (120.0, 80.0));
3458 }
3459
3460 #[test]
3461 fn pointer_down_on_a_wrong_typed_dataset_is_a_no_op() {
3462 let dataset = RefAny::new(0u16);
3463 let (update, _) = with_callback_info_at(cursor_at(1.0, 1.0), |info| {
3464 map_on_pointer_down(dataset.clone(), info)
3465 });
3466 assert_eq!(update, Update::DoNothing);
3467 }
3468
3469 #[test]
3470 fn pointer_move_without_an_anchor_does_not_pan() {
3471 let mut dataset = RefAny::new(cache_at(37.0, -122.0, 4.0));
3472 let (update, _) = with_callback_info_at(cursor_at(500.0, 500.0), |info| {
3473 map_on_pointer_move(dataset.clone(), info)
3474 });
3475 assert_eq!(update, Update::DoNothing);
3476 let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3477 assert_eq!(cache.viewport.centre_lon_deg, -122.0);
3478 assert_eq!(cache.viewport.centre_lat_deg, 37.0);
3479 }
3480
3481 #[test]
3482 fn pointer_move_pans_by_the_exact_mercator_delta_and_re_anchors() {
3483 let mut cache = cache_at(0.0, 0.0, 2.0);
3484 cache.drag_anchor = Some(LogicalPosition::new(100.0, 100.0));
3485 let mut dataset = RefAny::new(cache);
3486 let (_, changes) = with_callback_info_at(cursor_at(150.0, 100.0), |info| {
3487 map_on_pointer_move(dataset.clone(), info)
3488 });
3489 let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3490 close(cache.viewport.centre_lon_deg, -50.0 * 360.0 / 1024.0, 1e-9);
3492 close(cache.viewport.centre_lat_deg, 0.0, 1e-9);
3493 let anchor = cache.drag_anchor.expect("anchor must follow the cursor");
3494 assert_eq!((anchor.x, anchor.y), (150.0, 100.0));
3495 drop(cache);
3496 assert!(
3497 changes
3498 .iter()
3499 .any(|c| matches!(c, CallbackChange::UpdateAllVirtualViews)),
3500 "a pan must request a virtual-view re-render"
3501 );
3502 }
3503
3504 #[test]
3505 fn pointer_move_ignores_sub_half_pixel_jitter() {
3506 let mut cache = cache_at(10.0, 20.0, 4.0);
3507 cache.drag_anchor = Some(LogicalPosition::new(100.0, 100.0));
3508 let mut dataset = RefAny::new(cache);
3509 let (update, _) = with_callback_info_at(cursor_at(100.4, 99.7), |info| {
3510 map_on_pointer_move(dataset.clone(), info)
3511 });
3512 assert_eq!(update, Update::DoNothing);
3513 let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3514 assert_eq!(cache.viewport.centre_lon_deg, 20.0, "jitter must not pan");
3515 assert_eq!(cache.viewport.centre_lat_deg, 10.0);
3516 }
3517
3518 #[test]
3519 fn pointer_move_with_a_nan_viewport_does_not_panic() {
3520 let mut cache = MapTileCache::new(layer_zoom(0, 19), view(f64::NAN, f64::NAN, f32::NAN));
3521 cache.drag_anchor = Some(LogicalPosition::new(0.0, 0.0));
3522 let mut dataset = RefAny::new(cache);
3523 let (update, _) = with_callback_info_at(cursor_at(400.0, 400.0), |info| {
3524 map_on_pointer_move(dataset.clone(), info)
3525 });
3526 assert_eq!(update, Update::DoNothing);
3527 let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3528 assert!(cache.viewport.centre_lon_deg.is_nan());
3529 }
3530
3531 #[test]
3532 fn pointer_move_fires_the_viewport_hook_once_per_pan() {
3533 let mut log = RefAny::new(HookLog::default());
3534 let mut cache = cache_at(0.0, 0.0, 4.0);
3535 cache.drag_anchor = Some(LogicalPosition::new(0.0, 0.0));
3536 cache.on_viewport_changed = OptionMapViewportChanged::Some(MapViewportChanged {
3537 refany: log.clone(),
3538 callback: (record_viewport as MapViewportChangedCallbackType).into(),
3539 });
3540 let dataset = RefAny::new(cache);
3541 let _ = with_callback_info_at(cursor_at(60.0, 60.0), |info| {
3542 map_on_pointer_move(dataset.clone(), info)
3543 });
3544 assert_eq!(hook_log(&mut log), (1, 0));
3545 }
3546
3547 #[test]
3548 fn pointer_up_clears_every_gesture_anchor() {
3549 let mut cache = cache_at(0.0, 0.0, 4.0);
3550 cache.drag_anchor = Some(LogicalPosition::new(5.0, 5.0));
3551 cache.pinch_anchor = Some(120.0);
3552 cache.press_origin = Some(LogicalPosition::new(5.0, 5.0));
3553 let mut dataset = RefAny::new(cache);
3554 let (update, _) = with_callback_info_at(cursor_at(5.0, 5.0), |info| {
3555 map_on_pointer_up(dataset.clone(), info)
3556 });
3557 assert_eq!(update, Update::DoNothing);
3558 let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3559 assert!(cache.drag_anchor.is_none());
3560 assert!(cache.pinch_anchor.is_none());
3561 assert!(cache.press_origin.is_none());
3562 }
3563
3564 #[test]
3565 fn pointer_up_fires_pin_tap_for_a_tap_but_not_for_a_drag() {
3566 let mut log = RefAny::new(HookLog::default());
3568 let mut cache = cache_at(0.0, 0.0, 4.0);
3569 cache.press_origin = Some(LogicalPosition::new(10.0, 10.0));
3570 cache.on_pin_tap = OptionMapPinTap::Some(MapPinTap {
3571 refany: log.clone(),
3572 callback: (record_pin as MapPinTapCallbackType).into(),
3573 });
3574 let dataset = RefAny::new(cache);
3575 let _ = with_callback_info_at(cursor_at(12.0, 12.0), |info| {
3576 map_on_pointer_up(dataset.clone(), info)
3577 });
3578 assert_eq!(hook_log(&mut log), (0, 1), "a 2px release is a tap");
3579
3580 let mut log = RefAny::new(HookLog::default());
3582 let mut cache = cache_at(0.0, 0.0, 4.0);
3583 cache.press_origin = Some(LogicalPosition::new(10.0, 10.0));
3584 cache.on_pin_tap = OptionMapPinTap::Some(MapPinTap {
3585 refany: log.clone(),
3586 callback: (record_pin as MapPinTapCallbackType).into(),
3587 });
3588 let dataset = RefAny::new(cache);
3589 let _ = with_callback_info_at(cursor_at(100.0, 100.0), |info| {
3590 map_on_pointer_up(dataset.clone(), info)
3591 });
3592 assert_eq!(hook_log(&mut log), (0, 0), "a 90px release is a drag");
3593 }
3594
3595 #[test]
3596 fn pointer_up_without_a_press_origin_never_taps() {
3597 let mut log = RefAny::new(HookLog::default());
3598 let mut cache = cache_at(0.0, 0.0, 4.0);
3599 cache.on_pin_tap = OptionMapPinTap::Some(MapPinTap {
3600 refany: log.clone(),
3601 callback: (record_pin as MapPinTapCallbackType).into(),
3602 });
3603 let dataset = RefAny::new(cache);
3604 let _ = with_callback_info_at(cursor_at(10.0, 10.0), |info| {
3605 map_on_pointer_up(dataset.clone(), info)
3606 });
3607 assert_eq!(hook_log(&mut log), (0, 0));
3608 }
3609
3610 #[test]
3611 fn pointer_up_on_a_wrong_typed_dataset_is_a_no_op() {
3612 let dataset = RefAny::new(MapViewport::default());
3614 let (update, _) = with_callback_info_at(cursor_at(1.0, 1.0), |info| {
3615 map_on_pointer_up(dataset.clone(), info)
3616 });
3617 assert_eq!(update, Update::DoNothing);
3618 }
3619
3620 #[test]
3621 fn scroll_without_a_wheel_delta_is_a_no_op() {
3622 let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
3625 let (update, changes) = with_callback_info(|info| map_on_scroll(dataset.clone(), info));
3626 assert_eq!(update, Update::DoNothing);
3627 assert!(changes.is_empty(), "a zero-delta scroll must queue nothing");
3628 let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3629 assert_eq!(cache.viewport.zoom, 4.0);
3630 assert!(cache.tiles.is_empty());
3631 }
3632
3633 #[test]
3634 fn scroll_on_a_wrong_typed_dataset_is_a_no_op() {
3635 let dataset = RefAny::new(0u8);
3636 let (update, _) = with_callback_info(|info| map_on_scroll(dataset.clone(), info));
3637 assert_eq!(update, Update::DoNothing);
3638 }
3639
3640 #[test]
3645 fn spawn_pending_tile_fetches_is_a_no_op_without_a_worker() {
3646 let mut cache = cache_at(0.0, 0.0, 4.0);
3647 for x in 0..4u32 {
3648 cache.tiles.insert(MapTileId { z: 4, x, y: 8 }, TileEntry::Pending);
3649 }
3650 let mut dataset = RefAny::new(cache);
3651 let (_, changes) = with_callback_info(|info| {
3652 let mut info = info;
3653 spawn_pending_tile_fetches(&mut dataset.clone(), &mut info);
3654 });
3655 assert!(changes.is_empty(), "no worker → no threads queued");
3656 let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3657 assert!(
3658 cache.tiles.values().all(|e| matches!(e, TileEntry::Pending)),
3659 "tiles must stay Pending so the placeholder grid renders"
3660 );
3661 }
3662
3663 #[test]
3664 fn spawn_pending_tile_fetches_caps_the_burst_at_sixteen() {
3665 let mut cache = cache_at(0.0, 0.0, 4.0);
3666 cache.fetch_callback = Some(ThreadCallback::new(noop_worker));
3667 for x in 0..20u32 {
3668 cache.tiles.insert(MapTileId { z: 4, x, y: 8 }, TileEntry::Pending);
3669 }
3670 let mut dataset = RefAny::new(cache);
3671
3672 fn count_states(ds: &mut RefAny) -> (usize, usize) {
3675 let cache = ds.downcast_ref::<MapTileCache>().expect("cache");
3676 let fetching = cache
3677 .tiles
3678 .values()
3679 .filter(|e| matches!(e, TileEntry::Fetching))
3680 .count();
3681 let pending = cache
3682 .tiles
3683 .values()
3684 .filter(|e| matches!(e, TileEntry::Pending))
3685 .count();
3686 (fetching, pending)
3687 }
3688
3689 let (_, changes) = with_callback_info(|info| {
3690 let mut info = info;
3691 spawn_pending_tile_fetches(&mut dataset.clone(), &mut info);
3692 });
3693 assert_eq!(count_states(&mut dataset), (16, 4), "one call spawns at most 16");
3694 assert_eq!(
3695 changes
3696 .iter()
3697 .filter(|c| matches!(c, CallbackChange::AddThread { .. }))
3698 .count(),
3699 16
3700 );
3701
3702 let _ = with_callback_info(|info| {
3705 let mut info = info;
3706 spawn_pending_tile_fetches(&mut dataset.clone(), &mut info);
3707 });
3708 assert_eq!(count_states(&mut dataset), (20, 0));
3709 }
3710
3711 #[test]
3712 fn spawn_pending_tile_fetches_on_a_wrong_typed_dataset_is_a_no_op() {
3713 let mut dataset = RefAny::new(1234u32);
3714 let (_, changes) = with_callback_info(|info| {
3715 let mut info = info;
3716 spawn_pending_tile_fetches(&mut dataset, &mut info);
3717 });
3718 assert!(changes.is_empty());
3719 }
3720
3721 #[test]
3722 fn tile_writeback_marks_ready_on_an_empty_error_and_failed_otherwise() {
3723 let tile = MapTileId { z: 4, x: 1, y: 2 };
3724 let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
3725
3726 let ok = RefAny::new(TileReadyMsg {
3727 tile,
3728 svg: AzString::from("<svg/>"),
3729 error: AzString::from(""),
3730 });
3731 let (update, changes) = with_callback_info(|info| {
3732 map_tile_writeback(dataset.clone(), ok.clone(), info)
3733 });
3734 assert_eq!(update, Update::DoNothing);
3735 assert!(changes
3736 .iter()
3737 .any(|c| matches!(c, CallbackChange::UpdateAllVirtualViews)));
3738 {
3739 let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3740 assert!(matches!(cache.tiles.get(&tile), Some(TileEntry::Ready { .. })));
3741 }
3742
3743 let failed = RefAny::new(TileReadyMsg {
3744 tile,
3745 svg: AzString::from(""),
3746 error: AzString::from("404"),
3747 });
3748 let (update, _) = with_callback_info(|info| {
3749 map_tile_writeback(dataset.clone(), failed.clone(), info)
3750 });
3751 assert_eq!(update, Update::DoNothing);
3752 let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3753 assert!(matches!(cache.tiles.get(&tile), Some(TileEntry::Failed { .. })));
3754 }
3755
3756 #[test]
3757 fn tile_writeback_accepts_a_huge_payload_and_an_out_of_range_id() {
3758 let tile = MapTileId {
3759 z: 31,
3760 x: u32::MAX,
3761 y: u32::MAX,
3762 };
3763 let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
3764 let msg = RefAny::new(TileReadyMsg {
3765 tile,
3766 svg: AzString::from("<svg/>".repeat(50_000)),
3767 error: AzString::from(""),
3768 });
3769 let (update, _) =
3770 with_callback_info(|info| map_tile_writeback(dataset.clone(), msg.clone(), info));
3771 assert_eq!(update, Update::DoNothing);
3772 let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3773 assert!(cache.tiles.contains_key(&tile));
3774 }
3775
3776 #[test]
3777 fn tile_writeback_with_a_wrong_typed_message_is_a_no_op() {
3778 let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
3779 let (update, changes) = with_callback_info(|info| {
3780 map_tile_writeback(dataset.clone(), RefAny::new(0u32), info)
3781 });
3782 assert_eq!(update, Update::DoNothing);
3783 assert!(changes.is_empty(), "a bogus message must not force a re-render");
3784 let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3785 assert!(cache.tiles.is_empty());
3786 }
3787
3788 #[test]
3789 fn tile_writeback_with_a_wrong_typed_cache_is_a_no_op() {
3790 let msg = RefAny::new(TileReadyMsg {
3791 tile: MapTileId { z: 1, x: 0, y: 0 },
3792 svg: AzString::from("<svg/>"),
3793 error: AzString::from(""),
3794 });
3795 let (update, changes) = with_callback_info(|info| {
3796 map_tile_writeback(RefAny::new(9u64), msg.clone(), info)
3797 });
3798 assert_eq!(update, Update::DoNothing);
3799 assert!(changes.is_empty());
3800 }
3801
3802 #[test]
3803 fn after_mount_installs_the_sweep_timer_and_asks_for_a_re_render() {
3804 let dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
3805 let (update, changes) =
3806 with_callback_info(|info| map_on_after_mount(dataset.clone(), info));
3807 assert_eq!(update, Update::DoNothing);
3808 assert_eq!(
3809 changes
3810 .iter()
3811 .filter(|c| matches!(c, CallbackChange::AddTimer { .. }))
3812 .count(),
3813 1,
3814 "exactly one sweep timer per mount"
3815 );
3816 assert!(changes
3817 .iter()
3818 .any(|c| matches!(c, CallbackChange::UpdateAllVirtualViews)));
3819 }
3820
3821 #[test]
3822 fn after_mount_on_a_wrong_typed_dataset_still_installs_the_timer() {
3823 let dataset = RefAny::new(0u8);
3825 let (update, changes) =
3826 with_callback_info(|info| map_on_after_mount(dataset.clone(), info));
3827 assert_eq!(update, Update::DoNothing);
3828 assert!(changes
3829 .iter()
3830 .any(|c| matches!(c, CallbackChange::AddTimer { .. })));
3831 }
3832
3833 #[test]
3838 fn render_with_non_finite_or_empty_bounds_emits_no_dom() {
3839 let dataset = RefAny::new(cache_at(0.0, 0.0, 3.0));
3840 for (w, h) in [
3841 (0.0_f32, 0.0_f32),
3842 (0.0, 600.0),
3843 (800.0, 0.0),
3844 (-800.0, -600.0),
3845 (f32::NAN, 600.0),
3846 (800.0, f32::NAN),
3847 (f32::INFINITY, 600.0),
3848 (800.0, f32::NEG_INFINITY),
3849 ] {
3850 let ret =
3851 with_virtual_view_info(w, h, |info| map_widget_render(dataset.clone(), info));
3852 assert!(
3853 rendered_child_count(&ret).is_none(),
3854 "bounds {w}x{h} must render nothing until layout settles"
3855 );
3856 }
3857 }
3858
3859 #[test]
3860 fn render_with_a_wrong_typed_dataset_emits_no_dom() {
3861 let dataset = RefAny::new(0u32);
3862 let ret =
3863 with_virtual_view_info(800.0, 600.0, |info| map_widget_render(dataset.clone(), info));
3864 assert!(rendered_child_count(&ret).is_none());
3865 }
3866
3867 #[test]
3868 fn render_marks_every_visible_tile_pending_and_emits_one_div_each() {
3869 let mut dataset = RefAny::new(cache_at(0.0, 0.0, 2.0));
3870 let expected = map_visible_tiles(
3871 &view(0.0, 0.0, 2.0),
3872 LogicalSize::new(800.0, 600.0),
3873 &layer_zoom(0, 19),
3874 );
3875 let ret =
3876 with_virtual_view_info(800.0, 600.0, |info| map_widget_render(dataset.clone(), info));
3877 assert_eq!(
3878 rendered_child_count(&ret),
3879 Some(expected.len()),
3880 "one div per visible tile"
3881 );
3882 let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3883 for tile in &expected {
3884 assert!(
3885 matches!(cache.tiles.get(tile), Some(TileEntry::Pending)),
3886 "tile {tile:?} must be queued by the render pass"
3887 );
3888 }
3889 }
3890
3891 #[test]
3892 fn render_reports_the_bounds_back_as_the_scroll_size() {
3893 let dataset = RefAny::new(cache_at(0.0, 0.0, 2.0));
3894 let ret =
3895 with_virtual_view_info(640.0, 480.0, |info| map_widget_render(dataset.clone(), info));
3896 assert_eq!(ret.scroll_size.width, 640.0);
3897 assert_eq!(ret.scroll_size.height, 480.0);
3898 assert_eq!(ret.virtual_scroll_size.width, 640.0);
3899 assert_eq!(ret.virtual_scroll_size.height, 480.0);
3900 assert_eq!((ret.scroll_offset.x, ret.scroll_offset.y), (0.0, 0.0));
3901 assert_eq!(
3902 (ret.virtual_scroll_offset.x, ret.virtual_scroll_offset.y),
3903 (0.0, 0.0)
3904 );
3905 }
3906
3907 #[test]
3908 fn render_falls_back_to_a_glyph_when_a_ready_tile_holds_garbage() {
3909 let mut cache = cache_at(0.0, 0.0, 2.0);
3912 for tile in map_visible_tiles(
3913 &view(0.0, 0.0, 2.0),
3914 LogicalSize::new(512.0, 512.0),
3915 &layer_zoom(0, 19),
3916 ) {
3917 cache.mark_tile_ready(tile, AzString::from("not xml at all <<<"));
3918 }
3919 let dataset = RefAny::new(cache);
3920 let ret =
3921 with_virtual_view_info(512.0, 512.0, |info| map_widget_render(dataset.clone(), info));
3922 assert!(
3923 rendered_child_count(&ret).is_some_and(|n| n > 0),
3924 "garbage tiles still render their placeholder"
3925 );
3926 }
3927
3928 #[test]
3929 fn render_handles_mixed_tile_states_including_failures() {
3930 let mut cache = cache_at(0.0, 0.0, 2.0);
3931 let tiles = map_visible_tiles(
3932 &view(0.0, 0.0, 2.0),
3933 LogicalSize::new(512.0, 512.0),
3934 &layer_zoom(0, 19),
3935 );
3936 for (i, tile) in tiles.iter().enumerate() {
3937 match i % 4 {
3938 0 => {
3939 cache.tiles.insert(*tile, TileEntry::Pending);
3940 }
3941 1 => {
3942 cache.tiles.insert(*tile, TileEntry::Fetching);
3943 }
3944 2 => cache.mark_tile_failed(*tile, AzString::from("\u{1F600} failed")),
3945 _ => cache.mark_tile_ready(*tile, AzString::from("")),
3946 }
3947 }
3948 let dataset = RefAny::new(cache);
3949 let ret =
3950 with_virtual_view_info(512.0, 512.0, |info| map_widget_render(dataset.clone(), info));
3951 assert_eq!(rendered_child_count(&ret), Some(tiles.len()));
3952 }
3953
3954 #[test]
3955 fn render_clamps_an_out_of_band_zoom_and_stays_bounded() {
3956 for zoom in [0.0_f32, 1.0, 3.0, 25.0, f32::INFINITY] {
3963 let cache = MapTileCache::new(layer_zoom(3, 3), view(0.0, 0.0, zoom));
3964 let dataset = RefAny::new(cache);
3965 let ret = with_virtual_view_info(800.0, 600.0, |info| {
3966 map_widget_render(dataset.clone(), info)
3967 });
3968 let n = rendered_child_count(&ret).expect("a finite box must render");
3969 assert!(n > 0 && n < 4096, "zoom {zoom} produced {n} tiles");
3970 }
3971 }
3972
3973 #[test]
3974 fn render_is_stable_across_repeated_invocations() {
3975 let dataset = RefAny::new(cache_at(48.1372, 11.5756, 5.0));
3976 let first =
3977 with_virtual_view_info(800.0, 600.0, |info| map_widget_render(dataset.clone(), info));
3978 let second =
3979 with_virtual_view_info(800.0, 600.0, |info| map_widget_render(dataset.clone(), info));
3980 assert_eq!(rendered_child_count(&first), rendered_child_count(&second));
3981 }
3982}