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)]
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(
95 "https://openfreemap.org/example/{z}/{x}/{y}.pbf",
96 ),
97 min_zoom: 0,
98 max_zoom: 14,
99 attribution: AzString::from("© OpenStreetMap contributors, ODbL"),
100 style_css: AzString::from(""),
101 }
102 }
103}
104
105#[derive(Debug, Clone, Copy, PartialEq)]
109#[repr(C)]
110pub struct MapViewport {
111 pub centre_lat_deg: f64,
112 pub centre_lon_deg: f64,
113 pub zoom: f32,
114 pub bearing_deg: f32,
115 pub pitch_deg: f32,
116}
117
118impl Default for MapViewport {
119 fn default() -> Self {
120 Self {
123 centre_lat_deg: 0.0,
124 centre_lon_deg: 0.0,
125 zoom: 2.0,
126 bearing_deg: 0.0,
127 pitch_deg: 0.0,
128 }
129 }
130}
131
132#[derive(Debug, Clone, Copy, PartialEq)]
135#[repr(C)]
136pub struct MapLatLon {
137 pub lat_deg: f64,
138 pub lon_deg: f64,
139}
140
141#[derive(Debug, Clone, PartialEq)]
149#[repr(C)]
150pub struct MapWidget {
151 pub layer: MapTileLayer,
152 pub viewport: MapViewport,
153 pub container_style: CssPropertyWithConditionsVec,
154 pub on_viewport_changed: OptionMapViewportChanged,
157 pub on_pin_tap: OptionMapPinTap,
160}
161
162impl MapWidget {
163 pub fn create(layer: MapTileLayer) -> Self {
164 Self {
165 layer,
166 viewport: MapViewport::default(),
167 container_style: CssPropertyWithConditionsVec::from_const_slice(&[]),
168 on_viewport_changed: OptionMapViewportChanged::None,
169 on_pin_tap: OptionMapPinTap::None,
170 }
171 }
172
173 pub fn with_viewport(mut self, viewport: MapViewport) -> Self {
174 self.viewport = viewport;
175 self
176 }
177
178 pub fn with_container_style(mut self, css: CssPropertyWithConditionsVec) -> Self {
179 self.container_style = css;
180 self
181 }
182
183 pub fn set_on_viewport_changed<C: Into<MapViewportChangedCallback>>(
187 &mut self,
188 data: RefAny,
189 callback: C,
190 ) {
191 self.on_viewport_changed = Some(MapViewportChanged {
192 refany: data,
193 callback: callback.into(),
194 })
195 .into();
196 }
197
198 pub fn with_on_viewport_changed<C: Into<MapViewportChangedCallback>>(
200 mut self,
201 data: RefAny,
202 callback: C,
203 ) -> Self {
204 self.set_on_viewport_changed(data, callback);
205 self
206 }
207
208 pub fn set_on_pin_tap<C: Into<MapPinTapCallback>>(&mut self, data: RefAny, callback: C) {
212 self.on_pin_tap = Some(MapPinTap {
213 refany: data,
214 callback: callback.into(),
215 })
216 .into();
217 }
218
219 pub fn with_on_pin_tap<C: Into<MapPinTapCallback>>(
221 mut self,
222 data: RefAny,
223 callback: C,
224 ) -> Self {
225 self.set_on_pin_tap(data, callback);
226 self
227 }
228
229 pub fn latlon_at_px(
235 viewport: MapViewport,
236 px: azul_core::geom::LogicalPosition,
237 container: azul_core::geom::LogicalSize,
238 ) -> MapLatLon {
239 let world = 256.0_f64 * 2.0_f64.powf(viewport.zoom as f64);
240 let dx = (px.x - container.width * 0.5) as f64;
241 let dy = (px.y - container.height * 0.5) as f64;
242 let lon = (viewport.centre_lon_deg + dx * 360.0 / world).clamp(-180.0, 180.0);
243 let cos_lat = viewport.centre_lat_deg.to_radians().cos();
244 let lat = (viewport.centre_lat_deg - dy * 360.0 / world * cos_lat).clamp(-85.0, 85.0);
245 MapLatLon {
246 lat_deg: lat,
247 lon_deg: lon,
248 }
249 }
250
251 pub fn px_at_latlon(
254 viewport: MapViewport,
255 coord: MapLatLon,
256 container: azul_core::geom::LogicalSize,
257 ) -> azul_core::geom::LogicalPosition {
258 let world = 256.0_f64 * 2.0_f64.powf(viewport.zoom as f64);
259 let cos_lat = viewport.centre_lat_deg.to_radians().cos();
260 let px = container.width as f64 * 0.5
261 + (coord.lon_deg - viewport.centre_lon_deg) * world / 360.0;
262 let py = container.height as f64 * 0.5
263 - (coord.lat_deg - viewport.centre_lat_deg) * world / (360.0 * cos_lat);
264 azul_core::geom::LogicalPosition::new(px as f32, py as f32)
265 }
266
267 pub fn dom(self) -> Dom {
283 self.build_dom(None)
284 }
285
286 pub fn dom_with_fetch(self, cb: crate::thread::ThreadCallback) -> Dom {
295 self.build_dom(Some(cb))
296 }
297
298 fn build_dom(self, fetch_cb: Option<crate::thread::ThreadCallback>) -> Dom {
299 use azul_core::dom::{ComponentEventFilter, EventFilter, HoverEventFilter};
300
301 let mut cache = MapTileCache::new(self.layer.clone(), self.viewport);
302 cache.fetch_callback = fetch_cb;
303 cache.on_viewport_changed = self.on_viewport_changed;
304 cache.on_pin_tap = self.on_pin_tap;
305 let dataset = RefAny::new(cache);
306 let virtual_view_data = dataset.clone();
307
308 Dom::create_div()
309 .with_dataset(OptionRefAny::Some(dataset.clone()))
310 .with_merge_callback(merge_map_tile_cache as DatasetMergeCallbackType)
311 .with_callback(
317 EventFilter::Component(ComponentEventFilter::AfterMount),
318 dataset.clone(),
319 crate::callbacks::Callback::from(map_on_after_mount as crate::callbacks::CallbackType),
320 )
321 .with_callback(
322 EventFilter::Hover(HoverEventFilter::MouseDown),
323 dataset.clone(),
324 crate::callbacks::Callback::from(map_on_pointer_down as crate::callbacks::CallbackType),
325 )
326 .with_callback(
327 EventFilter::Hover(HoverEventFilter::MouseOver),
328 dataset.clone(),
329 crate::callbacks::Callback::from(map_on_pointer_move as crate::callbacks::CallbackType),
330 )
331 .with_callback(
332 EventFilter::Hover(HoverEventFilter::MouseUp),
333 dataset.clone(),
334 crate::callbacks::Callback::from(map_on_pointer_up as crate::callbacks::CallbackType),
335 )
336 .with_callback(
337 EventFilter::Hover(HoverEventFilter::MouseLeave),
338 dataset.clone(),
339 crate::callbacks::Callback::from(map_on_pointer_up as crate::callbacks::CallbackType),
340 )
341 .with_callback(
342 EventFilter::Hover(HoverEventFilter::TouchStart),
343 dataset.clone(),
344 crate::callbacks::Callback::from(map_on_pointer_down as crate::callbacks::CallbackType),
345 )
346 .with_callback(
347 EventFilter::Hover(HoverEventFilter::TouchMove),
348 dataset.clone(),
349 crate::callbacks::Callback::from(map_on_pointer_move as crate::callbacks::CallbackType),
350 )
351 .with_callback(
352 EventFilter::Hover(HoverEventFilter::TouchEnd),
353 dataset.clone(),
354 crate::callbacks::Callback::from(map_on_pointer_up as crate::callbacks::CallbackType),
355 )
356 .with_callback(
357 EventFilter::Hover(HoverEventFilter::TouchCancel),
358 dataset.clone(),
359 crate::callbacks::Callback::from(map_on_pointer_up as crate::callbacks::CallbackType),
360 )
361 .with_callback(
366 EventFilter::Hover(HoverEventFilter::PinchIn),
367 dataset.clone(),
368 crate::callbacks::Callback::from(map_on_pointer_move as crate::callbacks::CallbackType),
369 )
370 .with_callback(
371 EventFilter::Hover(HoverEventFilter::PinchOut),
372 dataset,
373 crate::callbacks::Callback::from(map_on_pointer_move as crate::callbacks::CallbackType),
374 )
375 .with_child(Dom::create_virtual_view(
376 virtual_view_data,
377 map_widget_render as azul_core::callbacks::VirtualViewCallbackType,
378 ))
379 }
380}
381
382#[derive(Debug)]
385pub struct MapTileCache {
386 pub layer: MapTileLayer,
387 pub viewport: MapViewport,
388 pub tiles: BTreeMap<MapTileId, TileEntry>,
393 pub fetch_callback: Option<crate::thread::ThreadCallback>,
402 pub drag_anchor: Option<azul_core::geom::LogicalPosition>,
408 pub pinch_anchor: Option<f32>,
416 pub on_viewport_changed: OptionMapViewportChanged,
419 pub press_origin: Option<azul_core::geom::LogicalPosition>,
422 pub on_pin_tap: OptionMapPinTap,
425}
426
427impl MapTileCache {
428 pub fn new(layer: MapTileLayer, viewport: MapViewport) -> Self {
429 Self {
430 layer,
431 viewport,
432 tiles: BTreeMap::new(),
433 fetch_callback: None,
434 drag_anchor: None,
435 pinch_anchor: None,
436 press_origin: None,
437 on_viewport_changed: OptionMapViewportChanged::None,
438 on_pin_tap: OptionMapPinTap::None,
439 }
440 }
441
442 pub fn mark_tile_ready(&mut self, tile: MapTileId, svg: AzString) {
445 self.tiles.insert(tile, TileEntry::Ready { svg });
446 }
447
448 pub fn mark_tile_failed(&mut self, tile: MapTileId, error: AzString) {
451 self.tiles.insert(tile, TileEntry::Failed { error });
452 }
453}
454
455#[derive(Debug, Clone)]
456pub enum TileEntry {
457 Pending,
459 Fetching,
462 Ready { svg: AzString },
467 Failed { error: AzString },
471}
472
473#[derive(Debug, Clone)]
477pub struct TileFetchInit {
478 pub tile: MapTileId,
479 pub url: AzString,
480 pub style_css: AzString,
482}
483
484#[derive(Debug, Clone)]
488pub struct TileReadyMsg {
489 pub tile: MapTileId,
490 pub svg: AzString,
493 pub error: AzString,
495}
496
497extern "C" fn merge_map_tile_cache(mut new_data: RefAny, mut old_data: RefAny) -> RefAny {
503 {
504 let new_guard_opt = new_data.downcast_mut::<MapTileCache>();
505 let old_guard_opt = old_data.downcast_ref::<MapTileCache>();
506 if let (Some(mut new_g), Some(old_g)) = (new_guard_opt, old_guard_opt) {
507 for (id, entry) in old_g.tiles.iter() {
508 new_g.tiles.entry(*id).or_insert_with(|| entry.clone());
509 }
510 if new_g.fetch_callback.is_none() {
514 new_g.fetch_callback = old_g.fetch_callback.clone();
515 }
516 if let OptionMapViewportChanged::None = new_g.on_viewport_changed {
517 new_g.on_viewport_changed = old_g.on_viewport_changed.clone();
518 }
519 if let OptionMapPinTap::None = new_g.on_pin_tap {
520 new_g.on_pin_tap = old_g.on_pin_tap.clone();
521 }
522 }
525 }
526 new_data
527}
528
529use crate::callbacks::CallbackInfo;
532use azul_core::callbacks::Update;
533
534pub type MapViewportChangedCallbackType =
540 extern "C" fn(RefAny, CallbackInfo, MapViewport) -> Update;
541impl_widget_callback!(
542 MapViewportChanged,
543 OptionMapViewportChanged,
544 MapViewportChangedCallback,
545 MapViewportChangedCallbackType
546);
547azul_core::impl_managed_callback! {
548 wrapper: MapViewportChangedCallback,
549 info_ty: CallbackInfo,
550 return_ty: Update,
551 default_ret: Update::DoNothing,
552 invoker_static: MAP_VIEWPORT_CHANGED_INVOKER,
553 invoker_ty: AzMapViewportChangedCallbackInvoker,
554 thunk_fn: az_map_viewport_changed_callback_thunk,
555 setter_fn: AzApp_setMapViewportChangedCallbackInvoker,
556 from_handle_fn: AzMapViewportChangedCallback_createFromHostHandle,
557 extra_args: [ viewport: MapViewport ],
558}
559
560fn invoke_viewport_changed(
563 hook: &OptionMapViewportChanged,
564 info: &CallbackInfo,
565 viewport: MapViewport,
566) -> Update {
567 match hook {
568 OptionMapViewportChanged::Some(h) => {
569 (h.callback.cb)(h.refany.clone(), info.clone(), viewport)
570 }
571 OptionMapViewportChanged::None => Update::DoNothing,
572 }
573}
574
575pub type MapPinTapCallbackType = extern "C" fn(RefAny, CallbackInfo, MapLatLon) -> Update;
582impl_widget_callback!(
583 MapPinTap,
584 OptionMapPinTap,
585 MapPinTapCallback,
586 MapPinTapCallbackType
587);
588azul_core::impl_managed_callback! {
589 wrapper: MapPinTapCallback,
590 info_ty: CallbackInfo,
591 return_ty: Update,
592 default_ret: Update::DoNothing,
593 invoker_static: MAP_PIN_TAP_INVOKER,
594 invoker_ty: AzMapPinTapCallbackInvoker,
595 thunk_fn: az_map_pin_tap_callback_thunk,
596 setter_fn: AzApp_setMapPinTapCallbackInvoker,
597 from_handle_fn: AzMapPinTapCallback_createFromHostHandle,
598 extra_args: [ coord: MapLatLon ],
599}
600
601fn invoke_pin_tap(hook: &OptionMapPinTap, info: &CallbackInfo, coord: MapLatLon) -> Update {
603 match hook {
604 OptionMapPinTap::Some(h) => (h.callback.cb)(h.refany.clone(), info.clone(), coord),
605 OptionMapPinTap::None => Update::DoNothing,
606 }
607}
608
609extern "C" fn map_on_pointer_down(mut data: RefAny, info: CallbackInfo) -> Update {
613 let pos = match info.get_cursor_relative_to_node().into_option() {
614 Some(p) => azul_core::geom::LogicalPosition::new(p.x, p.y),
615 None => return Update::DoNothing,
616 };
617 if let Some(mut cache) = data.downcast_mut::<MapTileCache>() {
618 cache.drag_anchor = Some(pos);
619 cache.press_origin = Some(pos);
620 }
621 Update::DoNothing
622}
623
624extern "C" fn map_on_pointer_move(mut data: RefAny, info: CallbackInfo) -> Update {
635 if let Some(pinch) = info.get_pinch().into_option() {
637 let mut cache = match data.downcast_mut::<MapTileCache>() {
638 Some(c) => c,
639 None => return Update::DoNothing,
640 };
641 let anchor = *cache.pinch_anchor.get_or_insert(pinch.current_distance);
642 if anchor > 1.0 && pinch.current_distance > 1.0 {
643 let dz = (pinch.current_distance / anchor).log2();
644 let min = cache.layer.min_zoom as f32;
645 let max = cache.layer.max_zoom as f32;
646 cache.viewport.zoom = (cache.viewport.zoom + dz).clamp(min, max);
647 }
648 cache.pinch_anchor = Some(pinch.current_distance);
649 cache.drag_anchor = None;
652 let hook = cache.on_viewport_changed.clone();
653 let vp = cache.viewport;
654 drop(cache);
655 invoke_viewport_changed(&hook, &info, vp);
656 return Update::RefreshDom;
657 }
658
659 let pos = match info.get_cursor_relative_to_node().into_option() {
660 Some(p) => azul_core::geom::LogicalPosition::new(p.x, p.y),
661 None => return Update::DoNothing,
662 };
663 let mut cache_guard = match data.downcast_mut::<MapTileCache>() {
664 Some(c) => c,
665 None => return Update::DoNothing,
666 };
667 let anchor = match cache_guard.drag_anchor {
668 Some(a) => a,
669 None => return Update::DoNothing, };
671
672 let dx_px = (pos.x - anchor.x) as f64;
673 let dy_px = (pos.y - anchor.y) as f64;
674 if dx_px.abs() < 0.5 && dy_px.abs() < 0.5 {
675 return Update::DoNothing;
676 }
677
678 let (new_lon, new_lat) = pan_viewport(
679 cache_guard.viewport.centre_lat_deg,
680 cache_guard.viewport.centre_lon_deg,
681 cache_guard.viewport.zoom as f64,
682 dx_px,
683 dy_px,
684 );
685 cache_guard.viewport.centre_lon_deg = new_lon;
686 cache_guard.viewport.centre_lat_deg = new_lat;
687 cache_guard.drag_anchor = Some(pos);
688
689 let hook = cache_guard.on_viewport_changed.clone();
690 let vp = cache_guard.viewport;
691 drop(cache_guard);
692 invoke_viewport_changed(&hook, &info, vp);
693 Update::RefreshDom
694}
695
696extern "C" fn map_on_pointer_up(mut data: RefAny, mut info: CallbackInfo) -> Update {
700 let up_pos = info
702 .get_cursor_relative_to_node()
703 .into_option()
704 .map(|p| azul_core::geom::LogicalPosition::new(p.x, p.y));
705 let container = info
706 .get_hit_node_rect()
707 .map(|r| r.size)
708 .unwrap_or(azul_core::geom::LogicalSize::new(0.0, 0.0));
709 let (press, viewport, hook) = match data.downcast_mut::<MapTileCache>() {
710 Some(mut cache) => {
711 let out = (cache.press_origin, cache.viewport, cache.on_pin_tap.clone());
712 cache.drag_anchor = None;
713 cache.pinch_anchor = None;
714 cache.press_origin = None;
715 out
716 }
717 None => (None, MapViewport::default(), OptionMapPinTap::None),
718 };
719 if let (Some(origin), Some(up)) = (press, up_pos) {
722 let dx = (up.x - origin.x) as f64;
723 let dy = (up.y - origin.y) as f64;
724 if dx * dx + dy * dy < 36.0 {
725 let coord = MapWidget::latlon_at_px(viewport, up, container);
726 invoke_pin_tap(&hook, &info, coord);
727 }
728 }
729 spawn_pending_tile_fetches(&mut data, &mut info);
732 Update::RefreshDom
733}
734
735fn wrap_lon(lon: f64) -> f64 {
736 (lon + 180.0).rem_euclid(360.0) - 180.0
739}
740
741fn lon_to_tile_x(lon_deg: f64, tile_count: f64) -> f64 {
752 (lon_deg + 180.0) / 360.0 * tile_count
753}
754
755fn lat_to_tile_y(lat_deg: f64, tile_count: f64) -> f64 {
757 let lat_rad = lat_deg.to_radians();
758 let mercator =
759 (1.0 - (lat_rad.tan() + 1.0 / lat_rad.cos()).ln() / core::f64::consts::PI) / 2.0;
760 mercator * tile_count
761}
762
763#[allow(dead_code)]
767fn tile_x_to_lon(x: f64, tile_count: f64) -> f64 {
768 x / tile_count * 360.0 - 180.0
769}
770
771#[allow(dead_code)]
773fn tile_y_to_lat(y: f64, tile_count: f64) -> f64 {
774 let n = core::f64::consts::PI * (1.0 - 2.0 * y / tile_count);
775 n.sinh().atan().to_degrees()
776}
777
778fn pan_viewport(
788 centre_lat_deg: f64,
789 centre_lon_deg: f64,
790 zoom: f64,
791 dx_px: f64,
792 dy_px: f64,
793) -> (f64, f64) {
794 let world_px = 256.0 * (2.0_f64).powf(zoom);
796 let d_lon = -dx_px * 360.0 / world_px;
797 let d_lat = dy_px * 360.0 / world_px * centre_lat_deg.to_radians().cos();
798 let new_lon = wrap_lon(centre_lon_deg + d_lon);
799 let new_lat = (centre_lat_deg + d_lat).clamp(-85.0, 85.0);
800 (new_lon, new_lat)
801}
802
803#[cfg(feature = "xml")]
810fn svg_string_to_dom(svg: &str) -> Option<Dom> {
811 use azul_core::xml::{str_to_dom_unstyled, ComponentMap};
812
813 let wrapped = alloc::format!("<html><body>{}</body></html>", svg);
814 let nodes = crate::xml::parse_xml_string(&wrapped).ok()?;
815 let component_map = ComponentMap::default();
816 str_to_dom_unstyled(nodes.as_ref(), &component_map).ok()
817}
818
819#[cfg(not(feature = "xml"))]
820fn svg_string_to_dom(_svg: &str) -> Option<Dom> {
821 None
822}
823
824extern "C" fn map_on_after_mount(mut data: RefAny, mut info: CallbackInfo) -> Update {
831 spawn_pending_tile_fetches(&mut data, &mut info);
832 Update::RefreshDom
833}
834
835fn spawn_pending_tile_fetches(data: &mut RefAny, info: &mut CallbackInfo) {
845 use crate::thread::Thread;
846 use azul_core::task::ThreadId;
847
848 const MAX_SPAWN_PER_CALL: usize = 16;
850
851 let mut to_spawn: Vec<TileFetchInit> = Vec::new();
855 {
856 let mut cache = match data.downcast_mut::<MapTileCache>() {
857 Some(c) => c,
858 None => return,
859 };
860 if cache.fetch_callback.is_none() {
861 return; }
863 let template = cache.layer.url_template.as_str().to_string();
864 let style_css = cache.layer.style_css.clone();
865 let pending: Vec<MapTileId> = cache
866 .tiles
867 .iter()
868 .filter(|(_, e)| matches!(e, TileEntry::Pending))
869 .map(|(id, _)| *id)
870 .take(MAX_SPAWN_PER_CALL)
871 .collect();
872 for tile in pending {
873 let url = build_tile_url(&template, tile);
874 cache.tiles.insert(tile, TileEntry::Fetching);
875 to_spawn.push(TileFetchInit {
876 tile,
877 url: AzString::from(url),
878 style_css: style_css.clone(),
879 });
880 }
881 }
882
883 let cb = {
884 let cache = match data.downcast_ref::<MapTileCache>() {
885 Some(c) => c,
886 None => return,
887 };
888 match cache.fetch_callback.as_ref() {
889 Some(cb) => cb.clone(),
890 None => return,
891 }
892 };
893
894 for init in to_spawn {
895 let init_data = RefAny::new(init);
896 let writeback_data = data.clone(); let thread = Thread::create(init_data, writeback_data, cb.clone());
898 info.add_thread(ThreadId::unique(), thread);
899 }
900}
901
902fn build_tile_url(template: &str, tile: MapTileId) -> alloc::string::String {
905 use alloc::string::ToString;
906 template
907 .replace("{z}", &tile.z.to_string())
908 .replace("{x}", &tile.x.to_string())
909 .replace("{y}", &tile.y.to_string())
910}
911
912pub extern "C" fn map_tile_writeback(
918 mut cache_dataset: RefAny,
919 mut incoming: RefAny,
920 _info: CallbackInfo,
921) -> Update {
922 let msg = match incoming.downcast_ref::<TileReadyMsg>() {
923 Some(m) => (m.tile, m.svg.clone(), m.error.clone()),
924 None => return Update::DoNothing,
925 };
926 let mut cache = match cache_dataset.downcast_mut::<MapTileCache>() {
927 Some(c) => c,
928 None => return Update::DoNothing,
929 };
930 if msg.2.as_str().is_empty() {
931 cache.mark_tile_ready(msg.0, msg.1);
932 } else {
933 cache.mark_tile_failed(msg.0, msg.2);
934 }
935 Update::RefreshDom
936}
937
938fn visible_tile_range(
946 centre_x: f32,
947 centre_y: f32,
948 width_px: f32,
949 height_px: f32,
950 zoom_scale: f32,
951 tile_count: u32,
952) -> (i32, i32, i32, i32) {
953 let tile_px = 256.0 * zoom_scale;
954 let half_w = (width_px / tile_px).abs() * 0.5 + 1.0;
955 let half_h = (height_px / tile_px).abs() * 0.5 + 1.0;
956 let max_idx = tile_count as i32 - 1;
957 let x_min = ((centre_x - half_w).floor() as i32).max(0);
958 let x_max = ((centre_x + half_w).ceil() as i32).min(max_idx);
959 let y_min = ((centre_y - half_h).floor() as i32).max(0);
960 let y_max = ((centre_y + half_h).ceil() as i32).min(max_idx);
961 (x_min, x_max, y_min, y_max)
962}
963
964extern "C" fn map_widget_render(
967 data: RefAny,
968 info: VirtualViewCallbackInfo,
969) -> VirtualViewReturn {
970 let mut data = data;
971 let bounds = info.get_bounds();
972 let bounds_logical = bounds.get_logical_size();
973 let width_px = bounds_logical.width;
974 let height_px = bounds_logical.height;
975
976 let (layer, viewport) = match data.downcast_ref::<MapTileCache>() {
977 Some(c) => (c.layer.clone(), c.viewport),
978 None => {
979 return VirtualViewReturn {
980 dom: OptionDom::None,
981 scroll_size: bounds_logical,
982 scroll_offset: azul_core::geom::LogicalPosition::zero(),
983 virtual_scroll_size: bounds_logical,
984 virtual_scroll_offset: azul_core::geom::LogicalPosition::zero(),
985 };
986 }
987 };
988
989 let z_int = (viewport.zoom.floor() as i32)
992 .clamp(layer.min_zoom as i32, layer.max_zoom as i32)
993 as u8;
994 let tile_count = 1u32 << z_int as u32;
995 let frac_zoom = viewport.zoom - z_int as f32;
996 let zoom_scale = 2.0_f32.powf(frac_zoom);
997
998 let centre_x = lon_to_tile_x(viewport.centre_lon_deg, tile_count as f64) as f32;
1001 let centre_y = lat_to_tile_y(viewport.centre_lat_deg, tile_count as f64) as f32;
1002
1003 let tile_px = 256.0 * zoom_scale;
1006 let (x_min, x_max, y_min, y_max) =
1007 visible_tile_range(centre_x, centre_y, width_px, height_px, zoom_scale, tile_count);
1008
1009 if let Some(mut cache) = data.downcast_mut::<MapTileCache>() {
1013 for x in x_min..=x_max {
1014 for y in y_min..=y_max {
1015 let id = MapTileId {
1016 z: z_int,
1017 x: x as u32,
1018 y: y as u32,
1019 };
1020 cache.tiles.entry(id).or_insert(TileEntry::Pending);
1021 }
1022 }
1023 }
1024
1025 enum TileDisplay {
1031 Glyph(&'static str),
1032 Svg(AzString),
1033 }
1034 let states: BTreeMap<MapTileId, TileDisplay> = match data.downcast_ref::<MapTileCache>() {
1035 Some(c) => c
1036 .tiles
1037 .iter()
1038 .map(|(id, e)| {
1039 let disp = match e {
1040 TileEntry::Pending => TileDisplay::Glyph("…"),
1041 TileEntry::Fetching => TileDisplay::Glyph("⟳"),
1042 TileEntry::Ready { svg } => TileDisplay::Svg(svg.clone()),
1043 TileEntry::Failed { .. } => TileDisplay::Glyph("✗"),
1044 };
1045 (*id, disp)
1046 })
1047 .collect(),
1048 None => BTreeMap::new(),
1049 };
1050
1051 let mut grid = Dom::create_div().with_css(
1055 "position: absolute; left: 0; top: 0; width: 100%; height: 100%; overflow: hidden;",
1056 );
1057
1058 for x in x_min..=x_max {
1059 for y in y_min..=y_max {
1060 let id = MapTileId {
1061 z: z_int,
1062 x: x as u32,
1063 y: y as u32,
1064 };
1065 let screen_x =
1066 ((x as f32 - centre_x) * tile_px + width_px * 0.5).round() as i32;
1067 let screen_y =
1068 ((y as f32 - centre_y) * tile_px + height_px * 0.5).round() as i32;
1069 let size_px = tile_px.round().max(1.0) as i32;
1070
1071 let style = alloc::format!(
1072 "position: absolute; left: {}px; top: {}px; \
1073 width: {}px; height: {}px; \
1074 background: #e7e9ec; border: 1px solid #d0d4d9;",
1075 screen_x, screen_y, size_px, size_px
1076 );
1077
1078 let mut tile_div = Dom::create_div().with_css(style.as_str());
1079
1080 match states.get(&id) {
1085 Some(TileDisplay::Svg(svg)) => match svg_string_to_dom(svg.as_str()) {
1086 Some(svg_dom) => {
1087 tile_div = tile_div.with_child(svg_dom);
1088 }
1089 None => {
1090 tile_div = tile_div.with_child(
1091 Dom::create_text(alloc::format!("✓? z{}/{}/{}", z_int, x, y))
1092 .with_css("position: absolute; left: 4px; top: 4px; font-size: 11px; color: #888;"),
1093 );
1094 }
1095 },
1096 other => {
1097 let state_tag = match other {
1098 Some(TileDisplay::Glyph(g)) => *g,
1099 _ => "",
1100 };
1101 tile_div = tile_div.with_child(
1102 Dom::create_text(alloc::format!("{} z{}/{}/{}", state_tag, z_int, x, y))
1103 .with_css("position: absolute; left: 4px; top: 4px; font-size: 11px; color: #888;"),
1104 );
1105 }
1106 }
1107
1108 grid = grid.with_child(tile_div);
1109 }
1110 }
1111
1112 VirtualViewReturn {
1113 dom: OptionDom::Some(grid),
1114 scroll_size: bounds_logical,
1115 scroll_offset: azul_core::geom::LogicalPosition::zero(),
1116 virtual_scroll_size: bounds_logical,
1117 virtual_scroll_offset: azul_core::geom::LogicalPosition::zero(),
1118 }
1119}
1120
1121#[cfg(test)]
1122mod tests {
1123 use super::*;
1124
1125 fn approx(a: f64, b: f64, eps: f64) {
1126 assert!((a - b).abs() < eps, "expected {a} ≈ {b} (within {eps})");
1127 }
1128
1129 #[test]
1130 fn wrap_lon_keeps_in_range() {
1131 approx(wrap_lon(0.0), 0.0, 1e-9);
1132 approx(wrap_lon(179.0), 179.0, 1e-9);
1133 approx(wrap_lon(-179.0), -179.0, 1e-9);
1134 approx(wrap_lon(181.0), -179.0, 1e-9);
1136 approx(wrap_lon(-181.0), 179.0, 1e-9);
1137 approx(wrap_lon(540.0), -180.0, 1e-9);
1139 for raw in [-1234.5, -360.0, 360.0, 999.9] {
1141 let w = wrap_lon(raw);
1142 assert!((-180.0..=180.0).contains(&w), "{raw} → {w} out of range");
1143 }
1144 }
1145
1146 #[test]
1147 fn build_tile_url_substitutes_zxy() {
1148 let tile = MapTileId { z: 11, x: 327, y: 791 };
1149 assert_eq!(
1150 build_tile_url("https://t.example/{z}/{x}/{y}.pbf", tile),
1151 "https://t.example/11/327/791.pbf"
1152 );
1153 assert_eq!(
1155 build_tile_url("{y}-{x}-{z}-{z}", MapTileId { z: 3, x: 4, y: 5 }),
1156 "5-4-3-3"
1157 );
1158 }
1159
1160 #[test]
1161 fn lon_tile_endpoints() {
1162 approx(lon_to_tile_x(-180.0, 1.0), 0.0, 1e-9);
1164 approx(lon_to_tile_x(180.0, 1.0), 1.0, 1e-9);
1165 approx(lon_to_tile_x(0.0, 1.0), 0.5, 1e-9);
1166 approx(lon_to_tile_x(0.0, 2.0), 1.0, 1e-9);
1168 }
1169
1170 #[test]
1171 fn lat_tile_equator_and_symmetry() {
1172 approx(lat_to_tile_y(0.0, 1.0), 0.5, 1e-9);
1174 let north = lat_to_tile_y(45.0, 1.0);
1176 let south = lat_to_tile_y(-45.0, 1.0);
1177 assert!(north < 0.5 && south > 0.5);
1178 approx(north + south, 1.0, 1e-9);
1179 }
1180
1181 #[test]
1182 fn projection_round_trips() {
1183 let points = [
1186 (37.7749, -122.4194), (51.5074, -0.1278), (-33.8688, 151.2093), (0.0, 0.0), ];
1191 for z in [0u32, 5, 11, 18] {
1192 let tc = (1u64 << z) as f64;
1193 for (lat, lon) in points {
1194 let x = lon_to_tile_x(lon, tc);
1195 let y = lat_to_tile_y(lat, tc);
1196 approx(tile_x_to_lon(x, tc), lon, 1e-6);
1197 approx(tile_y_to_lat(y, tc), lat, 1e-6);
1198 }
1199 }
1200 }
1201
1202 #[test]
1203 fn pan_zero_drag_is_identity() {
1204 let (lon, lat) = pan_viewport(37.0, -122.0, 11.0, 0.0, 0.0);
1206 approx(lon, -122.0, 1e-9);
1207 approx(lat, 37.0, 1e-9);
1208 }
1209
1210 #[test]
1211 fn pan_right_decreases_longitude() {
1212 let (lon, _) = pan_viewport(0.0, 0.0, 0.0, 100.0, 0.0);
1214 assert!(lon < 0.0, "drag right should lower longitude, got {lon}");
1215 let (lon_left, _) = pan_viewport(0.0, 0.0, 0.0, -100.0, 0.0);
1217 approx(lon_left, -lon, 1e-9);
1218 }
1219
1220 #[test]
1221 fn pan_step_scales_inversely_with_zoom() {
1222 let (lon_z0, _) = pan_viewport(0.0, 0.0, 0.0, 50.0, 0.0);
1225 let (lon_z1, _) = pan_viewport(0.0, 0.0, 1.0, 50.0, 0.0);
1226 approx(lon_z1, lon_z0 / 2.0, 1e-9);
1227 }
1228
1229 #[test]
1230 fn pan_clamps_latitude_to_mercator_limit() {
1231 let (_, lat_north) = pan_viewport(84.0, 0.0, 0.0, 0.0, 1.0e6);
1233 assert!(lat_north <= 85.0 && lat_north >= -85.0);
1234 let (_, lat_south) = pan_viewport(-84.0, 0.0, 0.0, 0.0, -1.0e6);
1235 assert!(lat_south <= 85.0 && lat_south >= -85.0);
1236 }
1237
1238 #[test]
1239 fn pan_wraps_longitude_across_antimeridian() {
1240 let (lon, _) = pan_viewport(0.0, 179.0, 0.0, -100.0, 0.0);
1243 assert!((-180.0..180.0).contains(&lon), "lon {lon} out of range");
1244 }
1245
1246 fn viewport_at(zoom: f32) -> MapViewport {
1247 MapViewport {
1248 centre_lat_deg: 0.0,
1249 centre_lon_deg: 0.0,
1250 zoom,
1251 bearing_deg: 0.0,
1252 pitch_deg: 0.0,
1253 }
1254 }
1255
1256 #[test]
1257 fn merge_preserves_old_tiles_and_keeps_new_viewport() {
1258 let tile = MapTileId { z: 5, x: 1, y: 2 };
1262 let mut old_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(5.0));
1263 old_cache.mark_tile_ready(tile, AzString::from("<svg/>"));
1264 let new_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(9.0));
1266
1267 let mut merged =
1268 merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(old_cache));
1269 let g = merged.downcast_ref::<MapTileCache>().unwrap();
1270
1271 assert!(g.tiles.contains_key(&tile), "old tile must survive relayout");
1273 approx(g.viewport.zoom as f64, 9.0, 1e-6);
1275 }
1276
1277 #[test]
1278 fn merge_keeps_new_tile_over_old() {
1279 let tile = MapTileId { z: 5, x: 1, y: 2 };
1282 let mut old_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(5.0));
1283 old_cache.mark_tile_ready(tile, AzString::from("OLD"));
1284 let mut new_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(5.0));
1285 new_cache.mark_tile_ready(tile, AzString::from("NEW"));
1286
1287 let mut merged =
1288 merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(old_cache));
1289 let g = merged.downcast_ref::<MapTileCache>().unwrap();
1290
1291 match g.tiles.get(&tile) {
1292 Some(TileEntry::Ready { svg }) => {
1293 assert_eq!(svg.as_str(), "NEW", "new frame's tile must not be clobbered");
1294 }
1295 other => panic!("expected Ready, got {other:?}"),
1296 }
1297 }
1298
1299 #[test]
1300 fn tile_range_covers_centre_with_margin() {
1301 let (x0, x1, y0, y1) = visible_tile_range(8.0, 8.0, 512.0, 512.0, 1.0, 16);
1304 assert_eq!((x0, x1), (6, 10));
1305 assert_eq!((y0, y1), (6, 10));
1306 }
1307
1308 #[test]
1309 fn tile_range_clamps_to_single_tile_world_at_zoom0() {
1310 let (x0, x1, y0, y1) = visible_tile_range(0.5, 0.5, 256.0, 256.0, 1.0, 1);
1313 assert_eq!((x0, x1, y0, y1), (0, 0, 0, 0));
1314 }
1315
1316 #[test]
1317 fn tile_range_widens_with_viewport() {
1318 let (nx0, nx1, ..) = visible_tile_range(8.0, 8.0, 512.0, 512.0, 1.0, 16);
1319 let (wx0, wx1, ..) = visible_tile_range(8.0, 8.0, 1024.0, 512.0, 1.0, 16);
1320 assert!(
1321 (wx1 - wx0) > (nx1 - nx0),
1322 "a wider viewport must request more columns"
1323 );
1324 }
1325
1326 #[test]
1327 fn tile_range_clamps_at_grid_edges() {
1328 let (x0, _, y0, _) = visible_tile_range(0.0, 0.0, 512.0, 512.0, 1.0, 16);
1330 assert!(x0 >= 0 && y0 >= 0);
1331 let (_, x1, _, y1) = visible_tile_range(15.0, 15.0, 512.0, 512.0, 1.0, 16);
1333 assert!(x1 <= 15 && y1 <= 15);
1334 }
1335}