azul_layout/widgets/map.rs
1//! AzulMaps map widget. The P3 goal-app's central primitive.
2//!
3//! Architecture (per the user's design in MOBILE_SESSION_LOG and the
4//! follow-up clarification):
5//!
6//! - **Widget, not a NodeType.** `MapWidget` builds a regular `<div>`
7//! that owns a `MapTileCache` `RefAny` dataset. The cache holds
8//! decoded SVG bytes per `MapTileId`; the dataset is the unit of
9//! persistence across relayout.
10//! - **Tile cache survives relayout** via a `DatasetMergeCallback`.
11//! Every relayout creates a fresh `MapTileCache` skeleton; the
12//! merge callback transfers all `Ready` / `Pending` entries from
13//! the old dataset into the new one, so in-flight fetches and
14//! already-decoded SVGs aren't dropped.
15//! - **VirtualView drives lazy rendering.** The widget's body is a
16//! `VirtualView` callback that:
17//! 1. Computes which tile XYZs are visible from the current
18//! viewport + viewport size.
19//! 2. For each visible tile not yet in the cache, marks it
20//! `Pending` and (eventually) enqueues an HTTP fetch.
21//! 3. Returns a `Dom` whose children are one `<div>` per visible
22//! tile, GPU-translated into screen space via
23//! `transform: translate(x, y) scale(z)`. Each tile div's
24//! inner content is the cached SVG DOM, or an empty
25//! placeholder while the fetch is in flight.
26//! - **MVT + MapCSS → SVG → DOM.** The decode pipeline (MVT protobuf
27//! bytes + a MapCSS stylesheet → an `<svg>` tree → the framework's
28//! existing svg-to-dom path) lands in a follow-up tick. This tick
29//! provides the widget shell + the dataset / merge-callback / virtual-
30//! view wiring; tiles render as empty placeholders.
31//! - **Geolocation dot composes on top.** Users stack a normal child
32//! `Dom` (with a `NodeType::GeolocationProbe` deeper in the
33//! subtree) on top of the map widget - the widget doesn't bake in
34//! any geolocation feature itself.
35//!
36//! Compile gate: no new HTTP / MVT / proj4 dependencies in this tick.
37//! Those land alongside the actual decode pipeline.
38
39use 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; // for impl_widget_callback!'s impl_option!
48use azul_css::AzString;
49
50// ────────── POD types (api.json + codegen surface) ─────────────────────
51
52/// Identity of one tile in a tiled-map XYZ scheme. Matches Leaflet /
53/// `OpenLayers` / Mapbox conventions (Web Mercator, origin top-left).
54#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
55#[repr(C)]
56pub struct MapTileId {
57 /// Zoom level. `0` = whole world in one tile, `~14` = street level
58 /// for vector tiles, `~19` for raster.
59 pub z: u8,
60 /// Tile column at this zoom.
61 pub x: u32,
62 /// Tile row at this zoom.
63 pub y: u32,
64}
65
66/// Configuration of one map tile layer - usually the base raster /
67/// vector layer. Additional layers (heatmaps, custom `GeoJSON`) compose
68/// as further `MapWidget` instances stacked atop.
69#[derive(Debug, Clone, PartialEq, Eq)]
70#[repr(C)]
71pub struct MapTileLayer {
72 /// `{z}` / `{x}` / `{y}` placeholders are substituted at fetch
73 /// time. Matches Leaflet's `tileLayer(url_template)`.
74 pub url_template: AzString,
75 /// Minimum integer zoom this layer supports.
76 pub min_zoom: u8,
77 /// Maximum integer zoom this layer supports.
78 pub max_zoom: u8,
79 /// Attribution string the user MUST display (`ODbL` "© OpenStreetMap
80 /// contributors" or similar). Most providers require it.
81 pub attribution: AzString,
82 /// MapCSS-style stylesheet driving per-layer fill / stroke /
83 /// stroke-width. Empty = use the built-in default palette. Each
84 /// rule is `selector { fill: …; stroke: …; stroke-width: …; }`
85 /// where the selector's trailing token is matched against the MVT
86 /// layer name (e.g. `water { fill: #9ecae1; }`, `.buildings { … }`).
87 /// Parsed by `azul_dll::desktop::extra::map`'s tile decoder.
88 pub style_css: AzString,
89}
90
91impl Default for MapTileLayer {
92 fn default() -> Self {
93 Self {
94 // OpenFreeMap's public planet vector tiles (full-detail OSM, z0–14, no
95 // API key). The tile path is VERSIONED by planet-build date — the
96 // unversioned `/planet/{z}/{x}/{y}.pbf` returns empty tiles. The version
97 // below is the current build from the TileJSON at
98 // `https://tiles.openfreemap.org/planet` (`tiles[0]`); when OpenFreeMap
99 // rebuilds the planet this goes stale, so the proper long-term path is to
100 // resolve it on the background thread by fetching that TileJSON first (a
101 // follow-up to the Leaflet-style layer work). Raster relief is also
102 // available at `…/natural_earth/ne2sr/{z}/{x}/{y}.png` (z0–6).
103 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/// Centre + zoom + rotation state. The Leaflet shape
117/// (`map.setView([lat, lon], zoom)`). `bearing_deg` + `pitch_deg` are
118/// reserved for future 3D-camera work; most callers leave them at zero.
119#[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 // A neutral "whole world, slightly zoomed in" default. Apps
132 // care will replace this immediately.
133 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/// A geographic coordinate in degrees. Returned by
144/// [`MapWidget::latlon_at_px`] and (P3) the map's `on_pin_tap` hook.
145#[derive(Debug, Clone, Copy, PartialEq)]
146#[repr(C)]
147pub struct MapLatLon {
148 pub lat_deg: f64,
149 pub lon_deg: f64,
150}
151
152// ────────── MapWidget builder ──────────────────────────────────────────
153
154// NOTE: `MapWidget` mirrors the api.json struct field-for-field so the
155// codegen FFI transmute stays sound. Callback fields (e.g.
156// `on_viewport_changed`) ARE allowed: codegen keeps `AzMapWidget` in sync
157// (the Button / Camera pattern). The Rust-only tile-fetch worker stays in
158// the FFI-opaque `MapTileCache` dataset (supplied via `dom_with_fetch`).
159#[derive(Debug, Clone, PartialEq)]
160#[repr(C)]
161pub struct MapWidget {
162 pub layer: MapTileLayer,
163 pub viewport: MapViewport,
164 pub container_style: CssPropertyWithConditionsVec,
165 /// Optional hook fired when the user pans / zooms (effects / persist
166 /// the viewport). FFI-exposed; re-set on each fresh build.
167 pub on_viewport_changed: OptionMapViewportChanged,
168 /// Optional hook fired when the user taps the map, with the tapped
169 /// lat/lon. FFI-exposed; re-set on each fresh build.
170 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 /// Set a hook fired when the user pans / zooms the map. The map owns its
195 /// own pan/pinch state; this lets your app observe or persist the
196 /// resulting `MapViewport`. The backreference DI pattern (architecture.md).
197 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 /// Builder form of [`set_on_viewport_changed`](Self::set_on_viewport_changed).
210 #[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 /// Set a hook fired when the user taps the map (a press + release at ~the
221 /// same point, no drag), with the tapped lat/lon. The backreference DI
222 /// pattern (architecture.md).
223 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 /// Builder form of [`set_on_pin_tap`](Self::set_on_pin_tap).
232 #[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 /// Project a screen pixel `px` (relative to the map node's top-left, in a
243 /// node of size `container`) to a lat/lon on the map at `viewport`. Small-
244 /// angle Mercator (accurate at city zooms). Inverse of
245 /// [`px_at_latlon`](Self::px_at_latlon). Exposed so apps don't reimplement
246 /// the projection (e.g. to drop a pin where the user tapped).
247 #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
248 #[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 /// Inverse of [`latlon_at_px`](Self::latlon_at_px): where `coord` lands in
266 /// container pixels at `viewport`.
267 #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
268 #[allow(clippy::cast_possible_truncation)] // bounded layout/render numeric cast
269 #[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 /// Construct the rendered `Dom`. The returned `Dom` is a single
284 /// `<div>` with:
285 /// - A `MapTileCache` `RefAny` dataset (initialised from this
286 /// widget's `viewport` + `layer`).
287 /// - A `DatasetMergeCallback` so the cache survives relayout.
288 /// - A `VirtualView` child that re-renders the visible-tile grid
289 /// on bounds change.
290 /// - Mouse-down / mouse-move / mouse-up callbacks that pan the
291 /// viewport while a drag is active (the widget owns the
292 /// pan state via `MapTileCache::drag_anchor`, so user code
293 /// doesn't have to wire anything).
294 /// - Pinch callbacks that zoom in / out.
295 ///
296 /// No tile-fetch worker is wired - tiles render as placeholders.
297 /// Use [`dom_with_fetch`](Self::dom_with_fetch) to supply one.
298 #[must_use] pub fn dom(self) -> Dom {
299 self.build_dom(None)
300 }
301
302 /// Like [`dom`](Self::dom), but wires a tile-fetch worker thread.
303 /// `cb` runs on a framework `Thread` per visible tile: it reads the
304 /// `TileFetchInit`, fetches + decodes, then
305 /// `sender.send(ThreadReceiveMsg::WriteBack(...))` a `TileReadyMsg`
306 /// targeting `map_tile_writeback`. The standard worker is
307 /// `azul_dll::desktop::extra::map::tile_fetch_worker`; wrap it in a
308 /// `ThreadCallback` to pass it here. See the recipe in
309 /// `MOBILE_SESSION_LOG.md`.
310 #[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 // Fill the container (the Leaflet contract) via absolute inset:0 rather
326 // than height:100%. A percentage height only resolves against a parent
327 // with a DEFINITE height; the usual map container is a `flex-grow` item
328 // whose height is not definite for percentage children, so height:100%
329 // there resolves to INFINITY → the VirtualView gets infinite bounds and
330 // positions every tile at y=∞ (off-screen → blank map). Absolute inset:0
331 // instead sizes against the container's final, finite content box. The
332 // container MUST be a positioned box (the demo's `position: relative`);
333 // a non-empty `container_style` (via `with_container_style`) overrides.
334 .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 // AfterMount fires once when the widget first appears (and
338 // again after a DOM-structure change re-mounts it). It's the
339 // earliest point with a `CallbackInfo`, so we kick the
340 // initial tile fetches here — without it the first frame's
341 // tiles would stay `Pending` until the user panned/tapped.
342 .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 // Native gesture events (UIPinchGestureRecognizer on iOS,
388 // ScaleGestureDetector on Android, NSMagnificationGestureRecognizer
389 // on macOS) — fire through the same map_on_pointer_move handler
390 // which reads `info.get_pinch()` and applies the zoom delta.
391 .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 // Fill the widget div with a PERCENTAGE box (not absolute). The
407 // outer div above is absolutely sized, so its height IS definite —
408 // height:100% here resolves against it (441px), giving the
409 // VirtualView a finite box. (Absolute-against-absolute collapses to
410 // 0 in the solver; percentage-against-a-definite-parent does not.)
411 .with_css("width: 100%; height: 100%; overflow: hidden;"),
412 );
413
414 // A caller-supplied container style replaces the default fill above
415 // (`with_css_props` replaces the inline style) — the caller then owns sizing.
416 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// ────────── Tile cache (dataset RefAny payload) ───────────────────────
425
426#[derive(Debug)]
427pub struct MapTileCache {
428 pub layer: MapTileLayer,
429 pub viewport: MapViewport,
430 /// `Ready(svg)` once the tile has been fetched + decoded;
431 /// `Pending` while queued, `Fetching` while a worker thread is
432 /// in flight; absent otherwise. `BTreeMap` for deterministic
433 /// iteration so the debug log + e2e snapshots are stable.
434 pub tiles: BTreeMap<MapTileId, TileEntry>,
435 /// Worker thread entry point that fetches + decodes one tile.
436 /// Supplied by `MapWidget::dom_with_fetch` (the caller, usually
437 /// `azul_dll`'s map-tiles glue, provides this because the MVT
438 /// decoder lives in `azul-dll`, which `azul-layout` can't depend
439 /// on). `None` means "no fetch wired": tiles stay `Pending` and
440 /// the placeholder grid renders. The merge callback carries this
441 /// across relayout. Held as the `ThreadCallback` wrapper (not the
442 /// raw fn pointer) so it round-trips through the FFI codegen.
443 pub fetch_callback: Option<crate::thread::ThreadCallback>,
444 /// Pixel coordinates of the cursor at the last mouse-down /
445 /// touch-down on the widget. `Some` while a drag is in flight,
446 /// `None` between drags. The framework consults this on every
447 /// mouse-move to derive the pixel delta, which then converts to a
448 /// lat/lon delta via the Web Mercator inverse.
449 pub drag_anchor: Option<azul_core::geom::LogicalPosition>,
450 /// Pinch reference distance (pixels) - the two-finger separation
451 /// the last time a pinch event was observed for this widget.
452 /// `Some` while a pinch is in flight, `None` between gestures.
453 /// On each subsequent pinch update we compute
454 /// `dz = log2(current_distance / pinch_anchor)` and add it to
455 /// `viewport.zoom`, then reset the anchor to the current
456 /// distance - so the gesture stays continuous across many frames.
457 pub pinch_anchor: Option<f32>,
458 /// The user's `on_viewport_changed` hook, copied here from the builder
459 /// so the pan / pinch callbacks can fire it. Carried across relayout.
460 pub on_viewport_changed: OptionMapViewportChanged,
461 /// Pixel position of the last pointer-down (the original press point, not
462 /// overwritten by pan moves). Used to tell a tap from a drag in pointer-up.
463 pub press_origin: Option<azul_core::geom::LogicalPosition>,
464 /// The user's `on_pin_tap` hook, copied from the builder so pointer-up can
465 /// fire it. Carried across relayout.
466 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 /// Worker-thread → main-thread write path. Set the decoded SVG for
485 /// a tile (called from `map_tile_writeback`). Stamps `Ready`.
486 pub fn mark_tile_ready(&mut self, tile: MapTileId, svg: AzString) {
487 self.tiles.insert(tile, TileEntry::Ready { svg });
488 }
489
490 /// Mark a tile's fetch as failed so the grid doesn't re-spawn it
491 /// every frame.
492 pub fn mark_tile_failed(&mut self, tile: MapTileId, error: AzString) {
493 self.tiles.insert(tile, TileEntry::Failed { error });
494 }
495
496 /// Bound the tile cache by evicting tiles far from the current viewport.
497 ///
498 /// Without this, `tiles` grows without limit - panning across the world or
499 /// zooming in and out keeps every tile ever fetched (each decoded SVG is
500 /// tens-to-hundreds of KB), so a long session leaks memory. Called after a
501 /// viewport change once the new view's tiles are queued.
502 ///
503 /// Eviction is viewport-distance based (the right policy for spatial data,
504 /// stronger than plain LRU): each tile is scored by zoom mismatch + squared
505 /// distance from the viewport centre (projected into the current zoom's tile
506 /// space), and the farthest are dropped first. IN-FLIGHT tiles
507 /// (`Pending`/`Fetching`) are never evicted (their worker would write into a
508 /// gone entry), and on-screen tiles score near-zero so they survive.
509 #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
510 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded layout/render numeric cast
511 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 // Higher score = evict sooner.
525 let score = |id: &MapTileId| -> f64 {
526 let zt_count = 1u32 << u32::from(id.z);
527 // Project the tile's centre into the CURRENT zoom's tile space so
528 // distances across zoom levels are comparable.
529 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 // Farthest first.
545 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 /// Needed by the viewport, fetch not yet spawned.
561 Pending,
562 /// A worker thread is fetching / decoding this tile right now.
563 /// Distinct from `Pending` so the spawn pass doesn't double-fire.
564 Fetching,
565 /// Tile decoded into an SVG document. Held as the raw SVG
566 /// string for now; the `VirtualView` callback will feed it
567 /// through the framework's svg-to-dom pipeline on the next
568 /// re-render.
569 Ready { svg: AzString },
570 /// Fetch failed. Held so the framework doesn't immediately
571 /// re-try the same URL - caller can choose to clear failed
572 /// entries on retry.
573 Failed { error: AzString },
574}
575
576/// Worker-thread input: which tile to fetch, the resolved URL, and the
577/// `MapCSS` stylesheet to apply when converting features to SVG. Boxed
578/// into the `Thread::create` init `RefAny`.
579#[derive(Debug, Clone)]
580pub struct TileFetchInit {
581 pub tile: MapTileId,
582 pub url: AzString,
583 /// Copy of `MapTileLayer::style_css` (empty = default palette).
584 pub style_css: AzString,
585}
586
587/// Worker-thread output, sent back via `ThreadWriteBackMsg`. The
588/// `map_tile_writeback` callback downcasts to this and stamps the
589/// cache.
590#[derive(Debug, Clone)]
591pub struct TileReadyMsg {
592 pub tile: MapTileId,
593 /// Decoded SVG document for the tile, or empty on failure (with
594 /// `error` set).
595 pub svg: AzString,
596 /// Empty on success; an error message on failure.
597 pub error: AzString,
598}
599
600// ────────── Merge callback — cache survives relayout ─────────────────
601
602/// Copy every entry from the previous frame's cache into the new
603/// frame's cache. The next layout pass thus sees the same in-flight /
604/// decoded set without re-fetching anything.
605extern "C" fn merge_map_tile_cache(mut new_data: RefAny, mut old_data: RefAny) -> RefAny {
606 // SHARE the previous cache across the relayout — do NOT copy its tiles into
607 // the freshly-built one. The tile-fetch worker threads each hold a clone of
608 // THIS very `RefAny` (handed to them at spawn time); returning it keeps their
609 // writebacks landing in the same cache the VirtualView reads. The reconcile
610 // pass re-points the VirtualView node's `refany` at this returned dataset
611 // (core::diff::transfer_states), so the pure content callback reads it too.
612 //
613 // The old behaviour returned a fresh `new_data` with the old tiles *copied*
614 // in. That orphaned the workers' clone after the first relayout: every tile
615 // arriving later was written into the old, no-longer-rendered cache, so the
616 // map stayed blank. Returning the persistent (old) cache fixes it at the root
617 // — workers, dataset and VirtualView all reference one underlying allocation.
618 //
619 // The freshly-built `new_data` carries the layout-callback-controlled
620 // CONFIG: the fetch worker the `.dom()` shim wired, and — critically — the
621 // viewport/layer the app passed to `with_viewport()` / `create()` for THIS
622 // build. Adopt those into the persistent cache: app callbacks (zoom
623 // buttons, Recentre, Locate) mutate app state and return RefreshDom, and
624 // the merge previously discarded that new viewport ("viewport intact"),
625 // so external viewport changes never took effect — only the widget's
626 // internal drag/wheel (which mutate the persistent cache directly)
627 // worked. Widget-internal changes stay consistent because every build's
628 // `with_viewport()` receives the app state, which the on_viewport_changed
629 // hook keeps in sync with internal pans/zooms.
630 {
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
645// ────────── Pan + zoom callbacks ─────────────────────────────────────
646
647use 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
653// --- User hook: on_viewport_changed (backreference DI, FFI-exposed) ---
654
655/// User hook fired when the user pans or zooms the map.
656///
657/// Lets app code observe
658/// or persist the widget-driven `MapViewport` (which otherwise lives only in
659/// the opaque `MapTileCache`). The backreference DI pattern (architecture.md).
660pub 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
681/// Invoke a map widget's optional `on_viewport_changed` hook with the new
682/// viewport, returning the user's `Update` (`DoNothing` if no hook is set).
683fn 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
696// --- User hook: on_pin_tap (backreference DI, FFI-exposed) ---
697
698/// User hook fired when the user taps the map (a press + release at ~the same
699/// point, no pan/pinch).
700///
701/// Receives the tapped [`MapLatLon`] (projected via
702/// [`MapWidget::latlon_at_px`]) so apps can drop a pin without wiring their own
703/// tap handling + projection. The backreference DI pattern (architecture.md).
704pub 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
724/// Invoke a map widget's optional `on_pin_tap` hook with the tapped coordinate.
725fn 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
732/// Pointer down → record the drag anchor. The widget knows nothing
733/// about the user's overall state `RefAny` - only its own dataset -
734/// so the anchor lives in `MapTileCache::drag_anchor`.
735extern "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/// Pointer move during an active drag → translate the pixel delta
752/// into a lat/lon delta via the Web Mercator inverse and update
753/// `viewport.centre_lat_deg / centre_lon_deg`. Updates the anchor so
754/// the next move computes a fresh delta.
755///
756/// If a pinch gesture is in flight (two fingers on the widget), the
757/// pan branch is skipped and the move event drives zoom instead -
758/// `dz = log2(current_distance / pinch_anchor)`. The next move resets
759/// the anchor to the current distance so the gesture stays
760/// continuous across many frames.
761#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
762extern "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 // Active pinch wins over single-finger pan.
771 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 // Pinch is exclusive with pan — clear the drag anchor so the
784 // pinch end doesn't accidentally drop into a pan.
785 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 // Re-render the VirtualView in place so the new zoom's tiles compute
791 // immediately, without a DOM rebuild. (See map_tile_writeback for why
792 // RefreshDom is avoided.)
793 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; // no active drag
806 };
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 // Pan moved the viewport — re-render the VirtualView in place so the newly
830 // visible tiles are computed (and marked Pending) right away. No RefreshDom.
831 info.trigger_all_virtual_view_rerender();
832 Update::DoNothing
833}
834
835/// Pointer up / pointer leave → end the drag *and* the pinch. Either
836/// can be in flight (and pinch supersedes pan in the move handler);
837/// clear both anchors on release.
838#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
839extern "C" fn map_on_pointer_up(mut data: RefAny, mut info: CallbackInfo) -> Update {
840 // Cursor + container size for tap projection (read before borrowing data).
841 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 // A press + release at ~the same point (no pan/pinch) is a tap: project it
856 // to lat/lon and fire the user's on_pin_tap hook.
857 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 // After a pan / pinch settles, kick off fetches for any tiles the new
866 // viewport needs. (Only a `CallbackInfo`-bearing callback can spawn them.)
867 spawn_pending_tile_fetches(&mut data, &mut info);
868 // Re-render in place so Fetching/Ready states show as tiles arrive. The
869 // worker writebacks will trigger further re-renders themselves. No RefreshDom.
870 info.trigger_all_virtual_view_rerender();
871 Update::DoNothing
872}
873
874/// Mouse-wheel / trackpad scroll over the map = ZOOM (Leaflet / Google-Maps
875/// convention), not content scroll. The map's `VirtualView` has no scroll overflow,
876/// so the framework's queued wheel deltas would otherwise be wasted - drain them
877/// and apply as a zoom step, then queue + spawn the tiles the new zoom needs and
878/// re-render in place.
879extern "C" fn map_on_scroll(mut data: RefAny, mut info: CallbackInfo) -> Update {
880 // Wheel delta that triggered this Scroll callback (sign = direction). The map
881 // is not a scroll container, so this comes from the per-pass wheel delta, not
882 // the scroll-physics input queue (which only feeds scrollable nodes).
883 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 // The grid's on-screen rect is the widget size (needed to recompute the tiles
895 // the new zoom needs).
896 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 // ~0.5 zoom levels per wheel notch. X11 delivers wheel-up as dy > 0;
906 // wheel-up zooms IN, wheel-down zooms OUT (Leaflet / Google-Maps).
907 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 // `rem_euclid` (not `%`) so even large negative deltas normalise:
924 // `%` follows the dividend's sign and would leak values < -180.
925 (lon + 180.0).rem_euclid(360.0) - 180.0
926}
927
928// ────────── Web-Mercator (WGS-84 ↔ XYZ tile space) ───────────────────
929//
930// `tile_count` is `2^zoom`. Tile-space x grows east (0 at lon -180,
931// `tile_count` at lon +180); y grows south (0 at the north edge
932// ~85.05°, `tile_count` at the south edge). These four functions are
933// exact inverses of each other and are the single source of truth for
934// the widget's projection — `map_widget_render` forward-projects the
935// viewport centre through them; tap-to-pin will inverse-project taps.
936
937/// Longitude (deg) → fractional tile-x at the given `tile_count`.
938fn lon_to_tile_x(lon_deg: f64, tile_count: f64) -> f64 {
939 (lon_deg + 180.0) / 360.0 * tile_count
940}
941
942/// Latitude (deg) → fractional tile-y at the given `tile_count`.
943fn 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/// Fractional tile-x → longitude (deg). Inverse of [`lon_to_tile_x`].
951/// Verified against the forward direction in the tests below; the
952/// upcoming tap-to-pin handler reuses it to turn a tap into a lat/lon.
953#[allow(dead_code)]
954#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
955fn tile_x_to_lon(x: f64, tile_count: f64) -> f64 {
956 x / tile_count * 360.0 - 180.0
957}
958
959/// Fractional tile-y → latitude (deg). Inverse of [`lat_to_tile_y`].
960#[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/// Apply a drag of `(dx_px, dy_px)` screen pixels to a viewport centre,
967/// returning the new `(centre_lon_deg, centre_lat_deg)`. Dragging right
968/// (+dx) pans the map content right, i.e. recentres on a *lower* longitude
969/// (hence the minus). Latitude uses the small-angle Mercator approximation
970/// (`d_lat ≈ dy·cos(lat)·360/world`), accurate to a few metres at city
971/// zooms; the exact inverse only matters for very long drags near the
972/// poles. Longitude wraps to [-180, 180); latitude clamps to the
973/// Web-Mercator ±85.05° limit. The shared, unit-tested core of
974/// `map_on_pointer_move`.
975#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
976#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
977fn 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 // World pixels at the current fractional zoom (256 px / tile).
985 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/// Parse a standalone `<svg>…</svg>` string into a `Dom` subtree via
994/// the framework's existing XML→DOM path.
995///
996/// The SVG is wrapped in a
997/// minimal `<html><body>` envelope because `str_to_dom_unstyled`
998/// expects a document root; the wrapper divs are zero-impact in
999/// layout. Returns `None` if the `xml` feature is off or parsing
1000/// fails - the caller then falls back to the placeholder glyph.
1001// Render the decoded tile SVG to a COLOUR image node, reusing the framework's
1002// `render_svg_group` rasteriser (the one that renders the tiger), which honours
1003// the SVG `fill`/`stroke` attrs that `features_to_svg` emits. The DOM SVG path
1004// (`str_to_dom_unstyled` → `SvgNodeData::Path`) only produces a clip mask, so it
1005// cannot paint the feature colours — hence the tiles rendered grey.
1006#[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
1030/// Fires once when the widget first mounts. Kicks the initial tile
1031/// fetches so the map populates without waiting for a user gesture.
1032/// (The `VirtualView` marks the viewport's tiles `Pending` during the
1033/// layout pass that precedes mount-event dispatch; this handler then
1034/// spawns the workers for them.) Returns `RefreshDom` so the
1035/// `Fetching` state shows immediately.
1036extern "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 // Install a low-frequency sweep timer. Pointer/scroll/after_mount spawn
1043 // fetches directly, but a viewport change that originates from a *rebuild*
1044 // (an app's zoom/recentre button → with_viewport) marks new tiles `Pending`
1045 // in the VirtualView render, which has no `add_thread` — so without this
1046 // sweep the map would sit grey after a button-zoom until the next
1047 // drag/wheel. The timer's cache clone tracks the persistent dataset
1048 // `transfer_states` keeps across rebuilds, so it stays unified.
1049 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 // Re-render the VirtualView IN PLACE (not RefreshDom). RefreshDom would
1057 // rebuild the DOM, allocate a fresh MapTileCache, and orphan the clone of
1058 // the cache we just handed the worker threads — their tiles would then write
1059 // to a cache nobody renders. The dataset is shared via the construction-time
1060 // RefAny::clone(), so re-invoking in place lets the workers' writes land in
1061 // the same cache the VirtualView reads.
1062 info.trigger_all_virtual_view_rerender();
1063 Update::DoNothing
1064}
1065
1066/// Scan the cache for `Pending` tiles and spawn one framework `Thread`
1067/// per tile (capped per call so a big viewport jump doesn't spawn
1068/// hundreds at once). Each thread gets:
1069/// - init `RefAny` = `TileFetchInit { tile, url }`
1070/// - writeback `RefAny` = a clone of the cache dataset, so
1071/// `map_tile_writeback` mutates the same cache the `VirtualView` reads.
1072///
1073/// Tiles transition `Pending → Fetching` here so they aren't
1074/// re-spawned next frame. No-op when the cache has no `fetch_callback`.
1075fn spawn_pending_tile_fetches(data: &mut RefAny, info: &mut CallbackInfo) {
1076 use crate::thread::Thread;
1077 use azul_core::task::ThreadId;
1078
1079 // Per-call spawn cap — bounds the burst on a big viewport jump.
1080 const MAX_SPAWN_PER_CALL: usize = 16;
1081
1082 // Collect the work first (URL build + state flip) under one borrow,
1083 // then spawn outside it so we don't hold the cache lock across
1084 // `info.add_thread`.
1085 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; // no worker wired — leave tiles Pending (placeholder grid)
1092 }
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 // Now that the current view's tiles are queued (Fetching, so eviction
1112 // protects them), bound the cache by dropping tiles far from the
1113 // viewport — otherwise panning/zooming grows it without limit.
1114 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(); // same cache dataset
1132 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
1141/// Low-frequency timer that spawns fetches for any `Pending` tiles the
1142/// `VirtualView` marked since the last spawn - the path that the
1143/// `pointer/scroll/after_mount` handlers can't cover (a rebuild-driven viewport
1144/// change marks tiles `Pending` in the `VirtualView` render, which has no
1145/// `add_thread`). Installed once in `map_on_after_mount`. The `data` clone
1146/// tracks the persistent dataset, so writebacks land in the rendered cache.
1147/// Cheap no-op when nothing is `Pending`; never `RefreshDom`s (that would
1148/// orphan the cache the workers write to - tile writebacks drive re-render).
1149extern "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
1160/// `{z}/{x}/{y}` substitution. Mirrors `azul_dll`'s `build_tile_url`
1161/// (the widget can't reach the dll, so it's duplicated here - trivial).
1162fn 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/// Worker-thread → main-thread writeback.
1171///
1172/// `cache_dataset` is the
1173/// `writeback_data` handed to `Thread::create` (the same
1174/// `MapTileCache` the widget reads); `incoming` is the `TileReadyMsg`
1175/// the worker sent. Stamps the tile `Ready` (or `Failed`) and asks for
1176/// a relayout so the `VirtualView` renders the new content.
1177#[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 } // drop the cache borrow before touching `info`
1204
1205 // Re-render the VirtualView(s) IN PLACE so the pure content callback re-reads
1206 // the shared cache we just mutated. NOT `RefreshDom`: a DOM rebuild would
1207 // allocate a fresh `MapTileCache` and orphan THIS worker's clone of it (the
1208 // VirtualView's `refany`, the node dataset and the worker's writeback handle
1209 // are all clones of one `RefAny` — same underlying data — only while the DOM
1210 // is not rebuilt). Re-invoking in place keeps that share intact, so this tile
1211 // and every later one reach the rendered view.
1212 info.trigger_all_virtual_view_rerender();
1213 Update::DoNothing
1214}
1215
1216/// Inclusive `(x_min, x_max, y_min, y_max)` tile range covering a
1217/// `width_px x height_px` viewport centred at tile-space `(centre_x,
1218/// centre_y)`, at fractional `zoom_scale` and integer `tile_count` (2^z).
1219/// A one-tile margin (`+ 1.0`) is added each side so a tile scrolling into
1220/// view is already requested; the result is clamped to the valid
1221/// `0..=tile_count-1` grid. The pure core of `map_widget_render`'s grid
1222/// loop - what decides which tiles get fetched.
1223#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
1224#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] // bounded layout/render numeric cast
1225fn 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 // x is NOT clamped: the map wraps horizontally. Callers take the tile id mod
1238 // `tile_count` (so a column past the antimeridian shows the far side of the
1239 // world) while positioning the div at the un-wrapped column — seamless pan
1240 // across ±180° with no empty gutter. y IS clamped: there is no data beyond
1241 // the Web-Mercator poles, so vertical over-scan must not request bogus rows.
1242 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/// Wrap a (possibly negative or over-range) tile column into the valid
1250/// `0..tile_count` band - the horizontal world-wrap. `rem_euclid` (not `%`)
1251/// so columns west of the antimeridian map to the east side: at `tile_count`
1252/// = 4, column `-1` → `3`, column `4` → `0`.
1253#[allow(clippy::cast_possible_wrap)] // bounded layout/render numeric cast
1254fn wrap_tile_x(x: i32, tile_count: u32) -> u32 {
1255 x.rem_euclid(tile_count.max(1) as i32) as u32
1256}
1257
1258/// `f(view)` - the tile ids a `viewport` needs to fill a `bounds`-sized widget.
1259/// Shared by the `VirtualView` render and the pan/zoom handlers so a handler can
1260/// mark + spawn the NEW viewport's tiles immediately, rather than waiting for the
1261/// next render pass to discover them. Mirrors `map_widget_render`'s grid math.
1262#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
1263#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded layout/render numeric cast
1264fn 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// ────────── VirtualView callback — visible-tile rendering ─────────────
1288
1289#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
1290#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)] // bounded layout/render numeric cast
1291#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
1292extern "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 // Defensive: if the widget was placed in a container that gives it no definite
1307 // size, the bounds come through as 0 or non-finite. Computing a tile grid then
1308 // positions tiles at NaN/∞ (off-screen → blank) and can allocate unboundedly, so
1309 // render nothing until the layout settles to a finite box.
1310 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 // Round the requested fractional zoom down to the nearest integer
1337 // tile zoom the layer supports.
1338 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 // Convert WGS-84 → Web-Mercator-XYZ tile-space via the shared
1346 // projection helpers (the single source of truth, unit-tested below).
1347 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 // 256 is the Mercator tile pixel size at integer zoom; tile_px is also
1351 // used below to position each tile div.
1352 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 // Opt-in render trace (`AZ_MAP_DEBUG=1`): the VirtualView callback fires only
1357 // when the framework finds this node with real bounds — so seeing this line at
1358 // all confirms invocation, and the values reveal a zero / infinite / off-screen
1359 // grid (the usual causes of a blank map).
1360 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 // Patch in any missing tiles as `Pending`. Real fetch dispatch
1369 // lands in the follow-up tick that adds the HTTP client; for now
1370 // we just track which tiles the viewport needs.
1371 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 // Snapshot the per-tile state under a short borrow, then drop it
1385 // before building DOM. `Ready` tiles carry their decoded SVG so the
1386 // render loop can parse it into a DOM child; the rest carry a glyph
1387 // (`…` Pending / `⟳` Fetching / `✗` Failed) so the fetch path stays
1388 // observable.
1389 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 // Build the visible-tile grid. Each tile div is GPU-translated
1407 // into its screen position; the (CSS-driven) `transform` keeps
1408 // pan / zoom O(1) — no relayout per frame.
1409 let mut grid = Dom::create_div().with_css(
1410 "position: absolute; left: 0; top: 0; width: 100%; height: 100%; overflow: hidden;",
1411 );
1412
1413 // Pan / zoom handlers live HERE, on the VirtualView content — NOT on the
1414 // outer widget div. The VirtualView renders as a separate DomId painted on
1415 // top of the outer div, so pointer events hit-test to these tiles and never
1416 // bubble to the outer div's handlers (which is why mouse-drag panning did
1417 // nothing). `data` is the shared cache the handlers mutate; the in-place
1418 // re-render they trigger re-reads it.
1419 {
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 // Tile id wraps horizontally (the column past ±180° shows the far
1453 // side of the world); the *screen* position uses the raw un-wrapped
1454 // column so the wrapped tile lands seamlessly in the gutter.
1455 let id = MapTileId {
1456 z: z_int,
1457 x: wrap_tile_x(x, tile_count),
1458 y: y as u32,
1459 };
1460 // Derive each tile's on-screen box from the ROUNDED origins of THIS
1461 // tile and the NEXT one along each axis, so neighbours always share an
1462 // exact edge — no gaps, no overlaps — at fractional zoom too. A fixed
1463 // `tile_px.round()` size drifts out of step with the per-tile rounded
1464 // origin the moment `tile_px` isn't a whole number (any non-integer
1465 // zoom, e.g. a scroll-wheel notch), scattering the tiles into a
1466 // disconnected grid. At integer zoom `tile_px` is exactly 256, so each
1467 // span is exactly 256 and this is identical to the previous behaviour.
1468 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) - screen_x).max(1);
1474 let size_h = (proj(y as f32 + 1.0, centre_y, height_px) - screen_y).max(1);
1475
1476 // Placeholder (still-loading) tiles show the loading grid — a grey
1477 // background + 1px border — so fetch state is visible. A LOADED tile
1478 // drops that chrome entirely: the decoded SVG covers the tile, and
1479 // keeping the per-tile border would draw a grey seam-grid over the
1480 // whole map (user-reported "small grey borders around the tiles").
1481 let is_ready = matches!(states.get(&id), Some(TileDisplay::Svg(_)));
1482 let chrome = if is_ready {
1483 ""
1484 } else {
1485 "background: #e7e9ec; border: 1px solid #d0d4d9;"
1486 };
1487 let style = alloc::format!(
1488 "position: absolute; left: {screen_x}px; top: {screen_y}px; \
1489 width: {size_w}px; height: {size_h}px; {chrome}"
1490 );
1491
1492 let mut tile_div = Dom::create_div().with_css(style.as_str());
1493
1494 // `Ready` tiles render their decoded SVG as a child DOM
1495 // tree (parsed via the framework's existing XML→DOM path);
1496 // everything else shows a state glyph + tile id so the grid
1497 // math + fetch state stay observable.
1498 match states.get(&id) {
1499 Some(TileDisplay::Svg(svg)) => match svg_string_to_dom(svg.as_str()) {
1500 Some(svg_dom) => {
1501 tile_div = tile_div.with_child(svg_dom);
1502 }
1503 None => {
1504 tile_div = tile_div.with_child(
1505 Dom::create_text(alloc::format!("✓? z{z_int}/{x}/{y}"))
1506 .with_css("position: absolute; left: 4px; top: 4px; font-size: 11px; color: #888;"),
1507 );
1508 }
1509 },
1510 other => {
1511 let state_tag = match other {
1512 Some(TileDisplay::Glyph(g)) => *g,
1513 _ => "",
1514 };
1515 tile_div = tile_div.with_child(
1516 Dom::create_text(alloc::format!("{state_tag} z{z_int}/{x}/{y}"))
1517 .with_css("position: absolute; left: 4px; top: 4px; font-size: 11px; color: #888;"),
1518 );
1519 }
1520 }
1521
1522 grid = grid.with_child(tile_div);
1523 }
1524 }
1525
1526 VirtualViewReturn {
1527 dom: OptionDom::Some(grid),
1528 scroll_size: bounds_logical,
1529 scroll_offset: azul_core::geom::LogicalPosition::zero(),
1530 virtual_scroll_size: bounds_logical,
1531 virtual_scroll_offset: azul_core::geom::LogicalPosition::zero(),
1532 }
1533}
1534
1535#[cfg(test)]
1536mod tests {
1537 use super::*;
1538
1539 fn approx(a: f64, b: f64, eps: f64) {
1540 assert!((a - b).abs() < eps, "expected {a} ≈ {b} (within {eps})");
1541 }
1542
1543 #[test]
1544 fn wrap_lon_keeps_in_range() {
1545 approx(wrap_lon(0.0), 0.0, 1e-9);
1546 approx(wrap_lon(179.0), 179.0, 1e-9);
1547 approx(wrap_lon(-179.0), -179.0, 1e-9);
1548 // Past the antimeridian wraps to the other side.
1549 approx(wrap_lon(181.0), -179.0, 1e-9);
1550 approx(wrap_lon(-181.0), 179.0, 1e-9);
1551 // 540° ≡ 180° ≡ -180° — the antimeridian normalises to -180.
1552 approx(wrap_lon(540.0), -180.0, 1e-9);
1553 // Anything fed in must come out within [-180, 180].
1554 for raw in [-1234.5, -360.0, 360.0, 999.9] {
1555 let w = wrap_lon(raw);
1556 assert!((-180.0..=180.0).contains(&w), "{raw} → {w} out of range");
1557 }
1558 }
1559
1560 #[test]
1561 fn build_tile_url_substitutes_zxy() {
1562 let tile = MapTileId { z: 11, x: 327, y: 791 };
1563 assert_eq!(
1564 build_tile_url("https://t.example/{z}/{x}/{y}.pbf", tile),
1565 "https://t.example/11/327/791.pbf"
1566 );
1567 // Repeated and out-of-order placeholders both resolve.
1568 assert_eq!(
1569 build_tile_url("{y}-{x}-{z}-{z}", MapTileId { z: 3, x: 4, y: 5 }),
1570 "5-4-3-3"
1571 );
1572 }
1573
1574 #[test]
1575 fn lon_tile_endpoints() {
1576 // At zoom 0 the world is one tile: -180° → 0, +180° → 1.
1577 approx(lon_to_tile_x(-180.0, 1.0), 0.0, 1e-9);
1578 approx(lon_to_tile_x(180.0, 1.0), 1.0, 1e-9);
1579 approx(lon_to_tile_x(0.0, 1.0), 0.5, 1e-9);
1580 // Greenwich at zoom 1 (2 tiles wide) sits on the seam.
1581 approx(lon_to_tile_x(0.0, 2.0), 1.0, 1e-9);
1582 }
1583
1584 #[test]
1585 fn lat_tile_equator_and_symmetry() {
1586 // Equator maps to the vertical centre of the map.
1587 approx(lat_to_tile_y(0.0, 1.0), 0.5, 1e-9);
1588 // North is above (smaller y) and is mirror-symmetric to south.
1589 let north = lat_to_tile_y(45.0, 1.0);
1590 let south = lat_to_tile_y(-45.0, 1.0);
1591 assert!(north < 0.5 && south > 0.5);
1592 approx(north + south, 1.0, 1e-9);
1593 }
1594
1595 #[test]
1596 #[allow(clippy::cast_precision_loss)] // bounded layout/render numeric cast
1597 fn projection_round_trips() {
1598 // Forward then inverse must return the original coordinate, for
1599 // a handful of real-world points across several zooms.
1600 let points = [
1601 (37.7749, -122.4194), // San Francisco
1602 (51.5074, -0.1278), // London
1603 (-33.8688, 151.2093), // Sydney
1604 (0.0, 0.0), // null island
1605 ];
1606 for z in [0u32, 5, 11, 18] {
1607 let tc = (1u64 << z) as f64;
1608 for (lat, lon) in points {
1609 let x = lon_to_tile_x(lon, tc);
1610 let y = lat_to_tile_y(lat, tc);
1611 approx(tile_x_to_lon(x, tc), lon, 1e-6);
1612 approx(tile_y_to_lat(y, tc), lat, 1e-6);
1613 }
1614 }
1615 }
1616
1617 #[test]
1618 fn pan_zero_drag_is_identity() {
1619 // No movement → centre unchanged (lon/lat already in range).
1620 let (lon, lat) = pan_viewport(37.0, -122.0, 11.0, 0.0, 0.0);
1621 approx(lon, -122.0, 1e-9);
1622 approx(lat, 37.0, 1e-9);
1623 }
1624
1625 #[test]
1626 fn pan_right_decreases_longitude() {
1627 // Dragging content right (+dx) recentres on a lower longitude.
1628 let (lon, _) = pan_viewport(0.0, 0.0, 0.0, 100.0, 0.0);
1629 assert!(lon < 0.0, "drag right should lower longitude, got {lon}");
1630 // Dragging left (-dx) is the mirror.
1631 let (lon_left, _) = pan_viewport(0.0, 0.0, 0.0, -100.0, 0.0);
1632 approx(lon_left, -lon, 1e-9);
1633 }
1634
1635 #[test]
1636 fn pan_step_scales_inversely_with_zoom() {
1637 // Each extra zoom level doubles the world size, so the same pixel
1638 // drag should move the centre half as far in degrees.
1639 let (lon_z0, _) = pan_viewport(0.0, 0.0, 0.0, 50.0, 0.0);
1640 let (lon_z1, _) = pan_viewport(0.0, 0.0, 1.0, 50.0, 0.0);
1641 approx(lon_z1, lon_z0 / 2.0, 1e-9);
1642 }
1643
1644 #[test]
1645 fn pan_clamps_latitude_to_mercator_limit() {
1646 // A huge vertical drag can't push the centre past ±85°.
1647 let (_, lat_north) = pan_viewport(84.0, 0.0, 0.0, 0.0, 1.0e6);
1648 assert!((-85.0..=85.0).contains(&lat_north));
1649 let (_, lat_south) = pan_viewport(-84.0, 0.0, 0.0, 0.0, -1.0e6);
1650 assert!((-85.0..=85.0).contains(&lat_south));
1651 }
1652
1653 #[test]
1654 fn pan_wraps_longitude_across_antimeridian() {
1655 // Starting near +180 and panning further east wraps into negatives
1656 // rather than producing an out-of-range longitude.
1657 let (lon, _) = pan_viewport(0.0, 179.0, 0.0, -100.0, 0.0);
1658 assert!((-180.0..180.0).contains(&lon), "lon {lon} out of range");
1659 }
1660
1661 fn viewport_at(zoom: f32) -> MapViewport {
1662 MapViewport {
1663 centre_lat_deg: 0.0,
1664 centre_lon_deg: 0.0,
1665 zoom,
1666 bearing_deg: 0.0,
1667 pitch_deg: 0.0,
1668 }
1669 }
1670
1671 #[test]
1672 fn merge_shares_old_cache_so_worker_writebacks_survive_relayout() {
1673 // THE regression behind the blank map: the merge must SHARE the previous
1674 // cache (the very `RefAny` the fetch-worker threads cloned at spawn), not
1675 // copy its tiles into a freshly-built one. With a copy, a tile that writes
1676 // back AFTER a relayout lands in the orphaned old cache and never renders.
1677 // Here we prove a post-merge writeback through a retained handle is
1678 // visible in the merged cache — i.e. they are one shared allocation.
1679 let tile = MapTileId { z: 5, x: 1, y: 2 };
1680 let old_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(5.0));
1681 let old_ref = RefAny::new(old_cache);
1682 // A worker thread keeps THIS clone and writes into it after the relayout.
1683 let mut worker_handle = old_ref.clone();
1684 // dom() rebuilds a fresh, empty cache (default viewport) each relayout.
1685 let new_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(9.0));
1686
1687 let mut merged = merge_map_tile_cache(RefAny::new(new_cache), old_ref);
1688
1689 // Worker finishes a fetch AFTER the merge and stamps the tile Ready on its
1690 // retained handle...
1691 worker_handle
1692 .downcast_mut::<MapTileCache>()
1693 .unwrap()
1694 .mark_tile_ready(tile, AzString::from("<svg/>"));
1695
1696 // ...and it IS visible through the merged cache (shared storage). With the
1697 // old copy-merge this assertion failed — the tile was stranded.
1698 let g = merged.downcast_ref::<MapTileCache>().unwrap();
1699 assert!(
1700 g.tiles.contains_key(&tile),
1701 "a worker writeback after relayout must reach the rendered cache"
1702 );
1703 }
1704
1705 #[test]
1706 fn merge_adopts_build_viewport_but_keeps_tiles() {
1707 // CONTRACT (changed 2026-06-10): `with_viewport()` is authoritative on
1708 // every rebuild. App callbacks (zoom buttons / Recentre / Locate)
1709 // mutate app state and RefreshDom; the old merge kept the persistent
1710 // cache's viewport "intact", silently discarding those changes — the
1711 // demo's +/− buttons fired but did nothing. Widget-internal drags stay
1712 // consistent because the on_viewport_changed hook mirrors them into
1713 // app state, which the next build passes back via with_viewport().
1714 // Tiles and the fetch worker stay with the persistent cache: workers
1715 // hold clones of that very RefAny, so writebacks keep landing in it.
1716 let mut old_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(5.0));
1717 old_cache.viewport.zoom = 7.0; // internal state from previous frames
1718 let tile = MapTileId { z: 2, x: 1, y: 1 };
1719 old_cache.tiles.insert(tile, TileEntry::Ready { svg: "<svg/>".into() });
1720
1721 let new_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(2.0));
1722
1723 let mut merged =
1724 merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(old_cache));
1725 let g = merged.downcast_ref::<MapTileCache>().unwrap();
1726 // The build's viewport wins…
1727 approx(f64::from(g.viewport.zoom), f64::from(viewport_at(2.0).zoom), 1e-6);
1728 // …while the fetched tiles survive in the same allocation.
1729 assert!(
1730 g.tiles.contains_key(&tile),
1731 "fetched tiles must survive the merge (workers write into this cache)"
1732 );
1733 }
1734
1735 #[test]
1736 fn tile_range_covers_centre_with_margin() {
1737 // 512x512 viewport at zoom-scale 1 (256 px tiles) = 2 tiles across;
1738 // half-extent 2 (incl. the +1 margin) → 5 tiles each axis, centred.
1739 let (x0, x1, y0, y1) = visible_tile_range(8.0, 8.0, 512.0, 512.0, 1.0, 16);
1740 assert_eq!((x0, x1), (6, 10));
1741 assert_eq!((y0, y1), (6, 10));
1742 }
1743
1744 #[test]
1745 fn wrap_tile_x_wraps_both_directions() {
1746 // rem_euclid semantics: west of the antimeridian wraps to the east side.
1747 assert_eq!(wrap_tile_x(-1, 4), 3);
1748 assert_eq!(wrap_tile_x(0, 4), 0);
1749 assert_eq!(wrap_tile_x(3, 4), 3);
1750 assert_eq!(wrap_tile_x(4, 4), 0);
1751 assert_eq!(wrap_tile_x(-5, 4), 3);
1752 // Single-tile world: every column resolves to the one tile.
1753 assert_eq!(wrap_tile_x(7, 1), 0);
1754 assert_eq!(wrap_tile_x(-3, 1), 0);
1755 }
1756
1757 #[test]
1758 fn tile_range_y_clamps_but_x_wraps_at_zoom0() {
1759 // zoom 0 → tile_count 1. y stays pinned to row 0 (no data past the
1760 // poles); x is unclamped (the column over-scans to fill the width) but
1761 // every column wraps to the single tile.
1762 let (x0, x1, y0, y1) = visible_tile_range(0.5, 0.5, 256.0, 256.0, 1.0, 1);
1763 assert_eq!((y0, y1), (0, 0));
1764 for x in x0..=x1 {
1765 assert_eq!(wrap_tile_x(x, 1), 0);
1766 }
1767 }
1768
1769 #[test]
1770 fn tile_range_widens_with_viewport() {
1771 let (nx0, nx1, ..) = visible_tile_range(8.0, 8.0, 512.0, 512.0, 1.0, 16);
1772 let (wx0, wx1, ..) = visible_tile_range(8.0, 8.0, 1024.0, 512.0, 1.0, 16);
1773 assert!(
1774 (wx1 - wx0) > (nx1 - nx0),
1775 "a wider viewport must request more columns"
1776 );
1777 }
1778
1779 #[test]
1780 fn tile_range_clamps_y_but_wraps_x_at_edges() {
1781 // y is clamped to the valid band at both poles (no over-scan past the
1782 // Web-Mercator edges)…
1783 let (x0, _, y0, _) = visible_tile_range(0.0, 0.0, 512.0, 512.0, 1.0, 16);
1784 assert!(y0 >= 0);
1785 let (_, x1, _, y1) = visible_tile_range(15.0, 15.0, 512.0, 512.0, 1.0, 16);
1786 assert!(y1 <= 15);
1787 // …but x is unclamped so the world wraps: a west-edge viewport over-scans
1788 // into negative columns and an east-edge one past tile_count-1; both wrap
1789 // back into 0..tile_count via wrap_tile_x.
1790 assert!(x0 < 0, "west-edge viewport should over-scan into wrapped columns");
1791 assert!(x1 > 15, "east-edge viewport should over-scan into wrapped columns");
1792 assert_eq!(wrap_tile_x(x0, 16), x0.rem_euclid(16) as u32);
1793 assert_eq!(wrap_tile_x(x1, 16), x1.rem_euclid(16) as u32);
1794 }
1795
1796 fn test_cache() -> MapTileCache {
1797 let layer = MapTileLayer {
1798 url_template: AzString::from("{z}/{x}/{y}"),
1799 min_zoom: 0,
1800 max_zoom: 19,
1801 attribution: AzString::from(""),
1802 style_css: AzString::from(""),
1803 };
1804 let viewport = MapViewport {
1805 centre_lat_deg: 0.0,
1806 centre_lon_deg: 0.0,
1807 zoom: 4.0,
1808 bearing_deg: 0.0,
1809 pitch_deg: 0.0,
1810 };
1811 MapTileCache::new(layer, viewport)
1812 }
1813
1814 #[test]
1815 fn prune_evicts_distant_tiles_keeps_near_and_inflight() {
1816 let mut cache = test_cache();
1817 // Centre at z4 is tile (8, 8). Fill a big z4 grid (Ready) — far more than
1818 // the 192 cap — plus a near Pending tile and a near Ready tile.
1819 for x in 0..20u32 {
1820 for y in 0..20u32 {
1821 cache
1822 .tiles
1823 .insert(MapTileId { z: 4, x, y }, TileEntry::Ready { svg: AzString::from("<svg/>") });
1824 }
1825 }
1826 // A near, in-flight tile (must NEVER be evicted).
1827 cache.tiles.insert(MapTileId { z: 4, x: 8, y: 8 }, TileEntry::Pending);
1828 // A near, ready tile (should survive — low distance score).
1829 cache.tiles.insert(MapTileId { z: 4, x: 9, y: 8 }, TileEntry::Ready { svg: AzString::from("<svg/>") });
1830 // A very far ready tile (should be evicted first).
1831 cache.tiles.insert(MapTileId { z: 4, x: 0, y: 0 }, TileEntry::Ready { svg: AzString::from("<svg/>") });
1832
1833 assert!(cache.tiles.len() > 192, "precondition: over the cap");
1834 cache.prune_distant_tiles();
1835
1836 assert!(cache.tiles.len() <= 192, "cache must be bounded after prune");
1837 // In-flight tile survives.
1838 assert!(matches!(
1839 cache.tiles.get(&MapTileId { z: 4, x: 8, y: 8 }),
1840 Some(TileEntry::Pending)
1841 ));
1842 // Near tile survives; the corner tile is gone.
1843 assert!(cache.tiles.contains_key(&MapTileId { z: 4, x: 9, y: 8 }));
1844 assert!(!cache.tiles.contains_key(&MapTileId { z: 4, x: 0, y: 0 }));
1845 }
1846
1847 #[test]
1848 fn prune_is_noop_under_cap() {
1849 let mut cache = test_cache();
1850 for x in 0..4u32 {
1851 cache
1852 .tiles
1853 .insert(MapTileId { z: 4, x, y: 8 }, TileEntry::Ready { svg: AzString::from("<svg/>") });
1854 }
1855 cache.prune_distant_tiles();
1856 assert_eq!(cache.tiles.len(), 4, "under the cap → nothing evicted");
1857 }
1858}