Skip to main content

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            // `saturating_sub`: `proj` ends in `as i32`, which SATURATES a
1474            // non-finite or out-of-range float to i32::MIN / i32::MAX. A viewport
1475            // whose zoom is `f32::INFINITY` therefore puts the two projected
1476            // origins at opposite ends of i32, and a plain `-` overflows — an
1477            // abort in an overflow-checked build (these run inside an `extern "C"`
1478            // render callback, so the panic does not unwind) and a wrapped,
1479            // nonsensical tile size in release.
1480            let size_w = proj(x as f32 + 1.0, centre_x, width_px)
1481                .saturating_sub(screen_x)
1482                .max(1);
1483            let size_h = proj(y as f32 + 1.0, centre_y, height_px)
1484                .saturating_sub(screen_y)
1485                .max(1);
1486
1487            // Placeholder (still-loading) tiles show the loading grid — a grey
1488            // background + 1px border — so fetch state is visible. A LOADED tile
1489            // drops that chrome entirely: the decoded SVG covers the tile, and
1490            // keeping the per-tile border would draw a grey seam-grid over the
1491            // whole map (user-reported "small grey borders around the tiles").
1492            let is_ready = matches!(states.get(&id), Some(TileDisplay::Svg(_)));
1493            let chrome = if is_ready {
1494                ""
1495            } else {
1496                "background: #e7e9ec; border: 1px solid #d0d4d9;"
1497            };
1498            let style = alloc::format!(
1499                "position: absolute; left: {screen_x}px; top: {screen_y}px; \
1500                 width: {size_w}px; height: {size_h}px; {chrome}"
1501            );
1502
1503            let mut tile_div = Dom::create_div().with_css(style.as_str());
1504
1505            // `Ready` tiles render their decoded SVG as a child DOM
1506            // tree (parsed via the framework's existing XML→DOM path);
1507            // everything else shows a state glyph + tile id so the grid
1508            // math + fetch state stay observable.
1509            match states.get(&id) {
1510                Some(TileDisplay::Svg(svg)) => match svg_string_to_dom(svg.as_str()) {
1511                    Some(svg_dom) => {
1512                        tile_div = tile_div.with_child(svg_dom);
1513                    }
1514                    None => {
1515                        tile_div = tile_div.with_child(
1516                            Dom::create_text(alloc::format!("✓? z{z_int}/{x}/{y}"))
1517                                .with_css("position: absolute; left: 4px; top: 4px; font-size: 11px; color: #888;"),
1518                        );
1519                    }
1520                },
1521                other => {
1522                    let state_tag = match other {
1523                        Some(TileDisplay::Glyph(g)) => *g,
1524                        _ => "",
1525                    };
1526                    tile_div = tile_div.with_child(
1527                        Dom::create_text(alloc::format!("{state_tag} z{z_int}/{x}/{y}"))
1528                            .with_css("position: absolute; left: 4px; top: 4px; font-size: 11px; color: #888;"),
1529                    );
1530                }
1531            }
1532
1533            grid = grid.with_child(tile_div);
1534        }
1535    }
1536
1537    VirtualViewReturn {
1538        dom: OptionDom::Some(grid),
1539        scroll_size: bounds_logical,
1540        scroll_offset: azul_core::geom::LogicalPosition::zero(),
1541        virtual_scroll_size: bounds_logical,
1542        virtual_scroll_offset: azul_core::geom::LogicalPosition::zero(),
1543    }
1544}
1545
1546#[cfg(test)]
1547mod tests {
1548    use super::*;
1549
1550    fn approx(a: f64, b: f64, eps: f64) {
1551        assert!((a - b).abs() < eps, "expected {a} ≈ {b} (within {eps})");
1552    }
1553
1554    #[test]
1555    fn wrap_lon_keeps_in_range() {
1556        approx(wrap_lon(0.0), 0.0, 1e-9);
1557        approx(wrap_lon(179.0), 179.0, 1e-9);
1558        approx(wrap_lon(-179.0), -179.0, 1e-9);
1559        // Past the antimeridian wraps to the other side.
1560        approx(wrap_lon(181.0), -179.0, 1e-9);
1561        approx(wrap_lon(-181.0), 179.0, 1e-9);
1562        // 540° ≡ 180° ≡ -180° — the antimeridian normalises to -180.
1563        approx(wrap_lon(540.0), -180.0, 1e-9);
1564        // Anything fed in must come out within [-180, 180].
1565        for raw in [-1234.5, -360.0, 360.0, 999.9] {
1566            let w = wrap_lon(raw);
1567            assert!((-180.0..=180.0).contains(&w), "{raw} → {w} out of range");
1568        }
1569    }
1570
1571    #[test]
1572    fn build_tile_url_substitutes_zxy() {
1573        let tile = MapTileId { z: 11, x: 327, y: 791 };
1574        assert_eq!(
1575            build_tile_url("https://t.example/{z}/{x}/{y}.pbf", tile),
1576            "https://t.example/11/327/791.pbf"
1577        );
1578        // Repeated and out-of-order placeholders both resolve.
1579        assert_eq!(
1580            build_tile_url("{y}-{x}-{z}-{z}", MapTileId { z: 3, x: 4, y: 5 }),
1581            "5-4-3-3"
1582        );
1583    }
1584
1585    #[test]
1586    fn lon_tile_endpoints() {
1587        // At zoom 0 the world is one tile: -180° → 0, +180° → 1.
1588        approx(lon_to_tile_x(-180.0, 1.0), 0.0, 1e-9);
1589        approx(lon_to_tile_x(180.0, 1.0), 1.0, 1e-9);
1590        approx(lon_to_tile_x(0.0, 1.0), 0.5, 1e-9);
1591        // Greenwich at zoom 1 (2 tiles wide) sits on the seam.
1592        approx(lon_to_tile_x(0.0, 2.0), 1.0, 1e-9);
1593    }
1594
1595    #[test]
1596    fn lat_tile_equator_and_symmetry() {
1597        // Equator maps to the vertical centre of the map.
1598        approx(lat_to_tile_y(0.0, 1.0), 0.5, 1e-9);
1599        // North is above (smaller y) and is mirror-symmetric to south.
1600        let north = lat_to_tile_y(45.0, 1.0);
1601        let south = lat_to_tile_y(-45.0, 1.0);
1602        assert!(north < 0.5 && south > 0.5);
1603        approx(north + south, 1.0, 1e-9);
1604    }
1605
1606    #[test]
1607    #[allow(clippy::cast_precision_loss)] // bounded layout/render numeric cast
1608    fn projection_round_trips() {
1609        // Forward then inverse must return the original coordinate, for
1610        // a handful of real-world points across several zooms.
1611        let points = [
1612            (37.7749, -122.4194), // San Francisco
1613            (51.5074, -0.1278),   // London
1614            (-33.8688, 151.2093), // Sydney
1615            (0.0, 0.0),           // null island
1616        ];
1617        for z in [0u32, 5, 11, 18] {
1618            let tc = (1u64 << z) as f64;
1619            for (lat, lon) in points {
1620                let x = lon_to_tile_x(lon, tc);
1621                let y = lat_to_tile_y(lat, tc);
1622                approx(tile_x_to_lon(x, tc), lon, 1e-6);
1623                approx(tile_y_to_lat(y, tc), lat, 1e-6);
1624            }
1625        }
1626    }
1627
1628    #[test]
1629    fn pan_zero_drag_is_identity() {
1630        // No movement → centre unchanged (lon/lat already in range).
1631        let (lon, lat) = pan_viewport(37.0, -122.0, 11.0, 0.0, 0.0);
1632        approx(lon, -122.0, 1e-9);
1633        approx(lat, 37.0, 1e-9);
1634    }
1635
1636    #[test]
1637    fn pan_right_decreases_longitude() {
1638        // Dragging content right (+dx) recentres on a lower longitude.
1639        let (lon, _) = pan_viewport(0.0, 0.0, 0.0, 100.0, 0.0);
1640        assert!(lon < 0.0, "drag right should lower longitude, got {lon}");
1641        // Dragging left (-dx) is the mirror.
1642        let (lon_left, _) = pan_viewport(0.0, 0.0, 0.0, -100.0, 0.0);
1643        approx(lon_left, -lon, 1e-9);
1644    }
1645
1646    #[test]
1647    fn pan_step_scales_inversely_with_zoom() {
1648        // Each extra zoom level doubles the world size, so the same pixel
1649        // drag should move the centre half as far in degrees.
1650        let (lon_z0, _) = pan_viewport(0.0, 0.0, 0.0, 50.0, 0.0);
1651        let (lon_z1, _) = pan_viewport(0.0, 0.0, 1.0, 50.0, 0.0);
1652        approx(lon_z1, lon_z0 / 2.0, 1e-9);
1653    }
1654
1655    #[test]
1656    fn pan_clamps_latitude_to_mercator_limit() {
1657        // A huge vertical drag can't push the centre past ±85°.
1658        let (_, lat_north) = pan_viewport(84.0, 0.0, 0.0, 0.0, 1.0e6);
1659        assert!((-85.0..=85.0).contains(&lat_north));
1660        let (_, lat_south) = pan_viewport(-84.0, 0.0, 0.0, 0.0, -1.0e6);
1661        assert!((-85.0..=85.0).contains(&lat_south));
1662    }
1663
1664    #[test]
1665    fn pan_wraps_longitude_across_antimeridian() {
1666        // Starting near +180 and panning further east wraps into negatives
1667        // rather than producing an out-of-range longitude.
1668        let (lon, _) = pan_viewport(0.0, 179.0, 0.0, -100.0, 0.0);
1669        assert!((-180.0..180.0).contains(&lon), "lon {lon} out of range");
1670    }
1671
1672    fn viewport_at(zoom: f32) -> MapViewport {
1673        MapViewport {
1674            centre_lat_deg: 0.0,
1675            centre_lon_deg: 0.0,
1676            zoom,
1677            bearing_deg: 0.0,
1678            pitch_deg: 0.0,
1679        }
1680    }
1681
1682    #[test]
1683    fn merge_shares_old_cache_so_worker_writebacks_survive_relayout() {
1684        // THE regression behind the blank map: the merge must SHARE the previous
1685        // cache (the very `RefAny` the fetch-worker threads cloned at spawn), not
1686        // copy its tiles into a freshly-built one. With a copy, a tile that writes
1687        // back AFTER a relayout lands in the orphaned old cache and never renders.
1688        // Here we prove a post-merge writeback through a retained handle is
1689        // visible in the merged cache — i.e. they are one shared allocation.
1690        let tile = MapTileId { z: 5, x: 1, y: 2 };
1691        let old_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(5.0));
1692        let old_ref = RefAny::new(old_cache);
1693        // A worker thread keeps THIS clone and writes into it after the relayout.
1694        let mut worker_handle = old_ref.clone();
1695        // dom() rebuilds a fresh, empty cache (default viewport) each relayout.
1696        let new_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(9.0));
1697
1698        let mut merged = merge_map_tile_cache(RefAny::new(new_cache), old_ref);
1699
1700        // Worker finishes a fetch AFTER the merge and stamps the tile Ready on its
1701        // retained handle...
1702        worker_handle
1703            .downcast_mut::<MapTileCache>()
1704            .unwrap()
1705            .mark_tile_ready(tile, AzString::from("<svg/>"));
1706
1707        // ...and it IS visible through the merged cache (shared storage). With the
1708        // old copy-merge this assertion failed — the tile was stranded.
1709        let g = merged.downcast_ref::<MapTileCache>().unwrap();
1710        assert!(
1711            g.tiles.contains_key(&tile),
1712            "a worker writeback after relayout must reach the rendered cache"
1713        );
1714    }
1715
1716    #[test]
1717    fn merge_adopts_build_viewport_but_keeps_tiles() {
1718        // CONTRACT (changed 2026-06-10): `with_viewport()` is authoritative on
1719        // every rebuild. App callbacks (zoom buttons / Recentre / Locate)
1720        // mutate app state and RefreshDom; the old merge kept the persistent
1721        // cache's viewport "intact", silently discarding those changes — the
1722        // demo's +/− buttons fired but did nothing. Widget-internal drags stay
1723        // consistent because the on_viewport_changed hook mirrors them into
1724        // app state, which the next build passes back via with_viewport().
1725        // Tiles and the fetch worker stay with the persistent cache: workers
1726        // hold clones of that very RefAny, so writebacks keep landing in it.
1727        let mut old_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(5.0));
1728        old_cache.viewport.zoom = 7.0; // internal state from previous frames
1729        let tile = MapTileId { z: 2, x: 1, y: 1 };
1730        old_cache.tiles.insert(tile, TileEntry::Ready { svg: "<svg/>".into() });
1731
1732        let new_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(2.0));
1733
1734        let mut merged =
1735            merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(old_cache));
1736        let g = merged.downcast_ref::<MapTileCache>().unwrap();
1737        // The build's viewport wins…
1738        approx(f64::from(g.viewport.zoom), f64::from(viewport_at(2.0).zoom), 1e-6);
1739        // …while the fetched tiles survive in the same allocation.
1740        assert!(
1741            g.tiles.contains_key(&tile),
1742            "fetched tiles must survive the merge (workers write into this cache)"
1743        );
1744    }
1745
1746    #[test]
1747    fn tile_range_covers_centre_with_margin() {
1748        // 512x512 viewport at zoom-scale 1 (256 px tiles) = 2 tiles across;
1749        // half-extent 2 (incl. the +1 margin) → 5 tiles each axis, centred.
1750        let (x0, x1, y0, y1) = visible_tile_range(8.0, 8.0, 512.0, 512.0, 1.0, 16);
1751        assert_eq!((x0, x1), (6, 10));
1752        assert_eq!((y0, y1), (6, 10));
1753    }
1754
1755    #[test]
1756    fn wrap_tile_x_wraps_both_directions() {
1757        // rem_euclid semantics: west of the antimeridian wraps to the east side.
1758        assert_eq!(wrap_tile_x(-1, 4), 3);
1759        assert_eq!(wrap_tile_x(0, 4), 0);
1760        assert_eq!(wrap_tile_x(3, 4), 3);
1761        assert_eq!(wrap_tile_x(4, 4), 0);
1762        assert_eq!(wrap_tile_x(-5, 4), 3);
1763        // Single-tile world: every column resolves to the one tile.
1764        assert_eq!(wrap_tile_x(7, 1), 0);
1765        assert_eq!(wrap_tile_x(-3, 1), 0);
1766    }
1767
1768    #[test]
1769    fn tile_range_y_clamps_but_x_wraps_at_zoom0() {
1770        // zoom 0 → tile_count 1. y stays pinned to row 0 (no data past the
1771        // poles); x is unclamped (the column over-scans to fill the width) but
1772        // every column wraps to the single tile.
1773        let (x0, x1, y0, y1) = visible_tile_range(0.5, 0.5, 256.0, 256.0, 1.0, 1);
1774        assert_eq!((y0, y1), (0, 0));
1775        for x in x0..=x1 {
1776            assert_eq!(wrap_tile_x(x, 1), 0);
1777        }
1778    }
1779
1780    #[test]
1781    fn tile_range_widens_with_viewport() {
1782        let (nx0, nx1, ..) = visible_tile_range(8.0, 8.0, 512.0, 512.0, 1.0, 16);
1783        let (wx0, wx1, ..) = visible_tile_range(8.0, 8.0, 1024.0, 512.0, 1.0, 16);
1784        assert!(
1785            (wx1 - wx0) > (nx1 - nx0),
1786            "a wider viewport must request more columns"
1787        );
1788    }
1789
1790    #[test]
1791    fn tile_range_clamps_y_but_wraps_x_at_edges() {
1792        // y is clamped to the valid band at both poles (no over-scan past the
1793        // Web-Mercator edges)…
1794        let (x0, _, y0, _) = visible_tile_range(0.0, 0.0, 512.0, 512.0, 1.0, 16);
1795        assert!(y0 >= 0);
1796        let (_, x1, _, y1) = visible_tile_range(15.0, 15.0, 512.0, 512.0, 1.0, 16);
1797        assert!(y1 <= 15);
1798        // …but x is unclamped so the world wraps: a west-edge viewport over-scans
1799        // into negative columns and an east-edge one past tile_count-1; both wrap
1800        // back into 0..tile_count via wrap_tile_x.
1801        assert!(x0 < 0, "west-edge viewport should over-scan into wrapped columns");
1802        assert!(x1 > 15, "east-edge viewport should over-scan into wrapped columns");
1803        assert_eq!(wrap_tile_x(x0, 16), x0.rem_euclid(16) as u32);
1804        assert_eq!(wrap_tile_x(x1, 16), x1.rem_euclid(16) as u32);
1805    }
1806
1807    fn test_cache() -> MapTileCache {
1808        let layer = MapTileLayer {
1809            url_template: AzString::from("{z}/{x}/{y}"),
1810            min_zoom: 0,
1811            max_zoom: 19,
1812            attribution: AzString::from(""),
1813            style_css: AzString::from(""),
1814        };
1815        let viewport = MapViewport {
1816            centre_lat_deg: 0.0,
1817            centre_lon_deg: 0.0,
1818            zoom: 4.0,
1819            bearing_deg: 0.0,
1820            pitch_deg: 0.0,
1821        };
1822        MapTileCache::new(layer, viewport)
1823    }
1824
1825    #[test]
1826    fn prune_evicts_distant_tiles_keeps_near_and_inflight() {
1827        let mut cache = test_cache();
1828        // Centre at z4 is tile (8, 8). Fill a big z4 grid (Ready) — far more than
1829        // the 192 cap — plus a near Pending tile and a near Ready tile.
1830        for x in 0..20u32 {
1831            for y in 0..20u32 {
1832                cache
1833                    .tiles
1834                    .insert(MapTileId { z: 4, x, y }, TileEntry::Ready { svg: AzString::from("<svg/>") });
1835            }
1836        }
1837        // A near, in-flight tile (must NEVER be evicted).
1838        cache.tiles.insert(MapTileId { z: 4, x: 8, y: 8 }, TileEntry::Pending);
1839        // A near, ready tile (should survive — low distance score).
1840        cache.tiles.insert(MapTileId { z: 4, x: 9, y: 8 }, TileEntry::Ready { svg: AzString::from("<svg/>") });
1841        // A very far ready tile (should be evicted first).
1842        cache.tiles.insert(MapTileId { z: 4, x: 0, y: 0 }, TileEntry::Ready { svg: AzString::from("<svg/>") });
1843
1844        assert!(cache.tiles.len() > 192, "precondition: over the cap");
1845        cache.prune_distant_tiles();
1846
1847        assert!(cache.tiles.len() <= 192, "cache must be bounded after prune");
1848        // In-flight tile survives.
1849        assert!(matches!(
1850            cache.tiles.get(&MapTileId { z: 4, x: 8, y: 8 }),
1851            Some(TileEntry::Pending)
1852        ));
1853        // Near tile survives; the corner tile is gone.
1854        assert!(cache.tiles.contains_key(&MapTileId { z: 4, x: 9, y: 8 }));
1855        assert!(!cache.tiles.contains_key(&MapTileId { z: 4, x: 0, y: 0 }));
1856    }
1857
1858    #[test]
1859    fn prune_is_noop_under_cap() {
1860        let mut cache = test_cache();
1861        for x in 0..4u32 {
1862            cache
1863                .tiles
1864                .insert(MapTileId { z: 4, x, y: 8 }, TileEntry::Ready { svg: AzString::from("<svg/>") });
1865        }
1866        cache.prune_distant_tiles();
1867        assert_eq!(cache.tiles.len(), 4, "under the cap → nothing evicted");
1868    }
1869}
1870
1871// ────────── Adversarial autotest coverage ────────────────────────────
1872//
1873// Boundary / malformed / overflow probes for the widget's pure numeric core,
1874// its builders, and its callback surface. Everything here is deliberately fed
1875// values a real app can produce (a zero-size container, a NaN viewport, a
1876// tile id past the antimeridian, a garbage tile payload) and asserts the
1877// function *contains* them rather than panicking.
1878#[cfg(test)]
1879#[allow(
1880    clippy::cast_possible_truncation,
1881    clippy::cast_precision_loss,
1882    clippy::cast_sign_loss,
1883    clippy::cast_possible_wrap,
1884    clippy::float_cmp,
1885    clippy::too_many_lines,
1886    clippy::unreadable_literal
1887)]
1888mod autotest_generated {
1889    use std::sync::{Arc, Mutex};
1890
1891    use azul_core::{
1892        callbacks::{HidpiAdjustedBounds, VirtualViewCallbackReason},
1893        dom::{DomId, DomNodeId},
1894        geom::{LogicalPosition, LogicalSize, OptionLogicalPosition},
1895        gl::OptionGlContextPtr,
1896        hit_test::ScrollPosition,
1897        resources::{DpiScaleFactor, ImageCache, RendererResources},
1898        styled_dom::NodeHierarchyItemId,
1899        window::{MonitorVec, RawWindowHandle, WindowTheme},
1900    };
1901    use azul_css::system::SystemStyle;
1902    use rust_fontconfig::FcFontCache;
1903
1904    use super::*;
1905    #[cfg(feature = "icu")]
1906    use crate::icu::IcuLocalizerHandle;
1907    use azul_core::task::ThreadReceiver;
1908
1909    use crate::{
1910        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
1911        thread::{ThreadCallback, ThreadCallbackType, ThreadSender},
1912        window::LayoutWindow,
1913        window_state::FullWindowState,
1914    };
1915
1916    // ------------------------------------------------------------------
1917    // Helpers
1918    // ------------------------------------------------------------------
1919
1920    fn close(a: f64, b: f64, eps: f64) {
1921        assert!((a - b).abs() <= eps, "expected {a} ≈ {b} (within {eps})");
1922    }
1923
1924    fn layer_zoom(min_zoom: u8, max_zoom: u8) -> MapTileLayer {
1925        MapTileLayer {
1926            url_template: AzString::from("https://tiles.invalid/{z}/{x}/{y}.pbf"),
1927            min_zoom,
1928            max_zoom,
1929            attribution: AzString::from("attr"),
1930            style_css: AzString::from(""),
1931        }
1932    }
1933
1934    fn view(lat: f64, lon: f64, zoom: f32) -> MapViewport {
1935        MapViewport {
1936            centre_lat_deg: lat,
1937            centre_lon_deg: lon,
1938            zoom,
1939            bearing_deg: 0.0,
1940            pitch_deg: 0.0,
1941        }
1942    }
1943
1944    fn cache_at(lat: f64, lon: f64, zoom: f32) -> MapTileCache {
1945        MapTileCache::new(layer_zoom(0, 19), view(lat, lon, zoom))
1946    }
1947
1948    /// Records everything the widget's user hooks are handed.
1949    #[derive(Default)]
1950    struct HookLog {
1951        viewports: Vec<MapViewport>,
1952        coords: Vec<MapLatLon>,
1953    }
1954
1955    extern "C" fn record_viewport(
1956        mut data: RefAny,
1957        _: CallbackInfo,
1958        viewport: MapViewport,
1959    ) -> Update {
1960        if let Some(mut log) = data.downcast_mut::<HookLog>() {
1961            log.viewports.push(viewport);
1962        }
1963        Update::DoNothing
1964    }
1965
1966    extern "C" fn record_pin(mut data: RefAny, _: CallbackInfo, coord: MapLatLon) -> Update {
1967        if let Some(mut log) = data.downcast_mut::<HookLog>() {
1968            log.coords.push(coord);
1969        }
1970        Update::RefreshDom
1971    }
1972
1973    /// A worker that returns immediately - enough to exercise the spawn path
1974    /// without any I/O.
1975    extern "C" fn noop_worker(_: RefAny, _: ThreadSender, _: ThreadReceiver) {}
1976    extern "C" fn other_noop_worker(_: RefAny, _: ThreadSender, _: ThreadReceiver) {}
1977
1978    fn hook_log(data: &mut RefAny) -> (usize, usize) {
1979        let log = data
1980            .downcast_ref::<HookLog>()
1981            .expect("payload must still be a HookLog");
1982        (log.viewports.len(), log.coords.len())
1983    }
1984
1985    /// Runs `f` against a real `CallbackInfo` over an empty `LayoutWindow`,
1986    /// with `cursor` reported as the cursor position relative to the hit node.
1987    /// Returns `f`'s value plus every `CallbackChange` the callback recorded.
1988    fn with_callback_info_at<R>(
1989        cursor: OptionLogicalPosition,
1990        f: impl FnOnce(CallbackInfo) -> R,
1991    ) -> (R, Vec<CallbackChange>) {
1992        let layout_window =
1993            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
1994        let renderer_resources = RendererResources::default();
1995        let previous_window_state: Option<FullWindowState> = None;
1996        let current_window_state = FullWindowState::default();
1997        let gl_context = OptionGlContextPtr::None;
1998        let scroll_states: alloc::collections::BTreeMap<
1999            DomId,
2000            alloc::collections::BTreeMap<NodeHierarchyItemId, ScrollPosition>,
2001        > = alloc::collections::BTreeMap::new();
2002        let window_handle = RawWindowHandle::Unsupported;
2003        let system_callbacks = ExternalSystemCallbacks::rust_internal();
2004
2005        let ref_data = CallbackInfoRefData {
2006            layout_window: &layout_window,
2007            renderer_resources: &renderer_resources,
2008            previous_window_state: &previous_window_state,
2009            current_window_state: &current_window_state,
2010            gl_context: &gl_context,
2011            current_scroll_manager: &scroll_states,
2012            current_window_handle: &window_handle,
2013            system_callbacks: &system_callbacks,
2014            system_style: Arc::new(SystemStyle::default()),
2015            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
2016            #[cfg(feature = "icu")]
2017            icu_localizer: IcuLocalizerHandle::default(),
2018            ctx: OptionRefAny::None,
2019        };
2020
2021        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
2022        let info = CallbackInfo::new(
2023            &ref_data,
2024            &changes,
2025            DomNodeId {
2026                dom: DomId::ROOT_ID,
2027                node: NodeHierarchyItemId::NONE,
2028            },
2029            cursor,
2030            OptionLogicalPosition::None,
2031        );
2032
2033        let out = f(info);
2034        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
2035        (out, recorded)
2036    }
2037
2038    fn with_callback_info<R>(f: impl FnOnce(CallbackInfo) -> R) -> (R, Vec<CallbackChange>) {
2039        with_callback_info_at(OptionLogicalPosition::None, f)
2040    }
2041
2042    fn cursor_at(x: f32, y: f32) -> OptionLogicalPosition {
2043        OptionLogicalPosition::Some(LogicalPosition::new(x, y))
2044    }
2045
2046    /// Runs `f` against a `VirtualViewCallbackInfo` reporting `w x h` bounds.
2047    fn with_virtual_view_info<R>(
2048        w: f32,
2049        h: f32,
2050        f: impl FnOnce(VirtualViewCallbackInfo) -> R,
2051    ) -> R {
2052        let fonts = FcFontCache::default();
2053        let images = ImageCache::default();
2054        let size = LogicalSize::new(w, h);
2055        let info = VirtualViewCallbackInfo::new(
2056            VirtualViewCallbackReason::InitialRender,
2057            &fonts,
2058            &images,
2059            WindowTheme::LightMode,
2060            HidpiAdjustedBounds {
2061                logical_size: size,
2062                hidpi_factor: DpiScaleFactor::new(1.0),
2063            },
2064            size,
2065            LogicalPosition::zero(),
2066            size,
2067            LogicalPosition::zero(),
2068        );
2069        f(info)
2070    }
2071
2072    fn rendered_child_count(ret: &VirtualViewReturn) -> Option<usize> {
2073        match &ret.dom {
2074            OptionDom::Some(d) => Some(d.children.as_slice().len()),
2075            OptionDom::None => None,
2076        }
2077    }
2078
2079    // ==================================================================
2080    // wrap_lon  (numeric)
2081    // ==================================================================
2082
2083    #[test]
2084    fn wrap_lon_zero_and_negative_zero_are_zero() {
2085        assert_eq!(wrap_lon(0.0), 0.0);
2086        assert_eq!(wrap_lon(-0.0), 0.0);
2087    }
2088
2089    #[test]
2090    fn wrap_lon_nan_and_infinities_are_nan_not_panic() {
2091        // `rem_euclid` on a non-finite dividend is NaN (fmod(inf, x) == NaN);
2092        // the documented, non-panicking outcome.
2093        assert!(wrap_lon(f64::NAN).is_nan());
2094        assert!(wrap_lon(f64::INFINITY).is_nan());
2095        assert!(wrap_lon(f64::NEG_INFINITY).is_nan());
2096    }
2097
2098    #[test]
2099    fn wrap_lon_extreme_finite_inputs_stay_bounded_and_finite() {
2100        for raw in [
2101            f64::MAX,
2102            f64::MIN,
2103            f64::MIN_POSITIVE,
2104            -f64::MIN_POSITIVE,
2105            1.0e300,
2106            -1.0e300,
2107            1.0e18,
2108            -1.0e18,
2109            360.0 * 1.0e9,
2110        ] {
2111            let w = wrap_lon(raw);
2112            assert!(w.is_finite(), "{raw} → {w} is not finite");
2113            assert!((-180.0..=180.0).contains(&w), "{raw} → {w} out of range");
2114        }
2115    }
2116
2117    #[test]
2118    fn wrap_lon_is_idempotent_on_representative_inputs() {
2119        for (raw, expected) in [
2120            (0.0_f64, 0.0_f64),
2121            (45.0, 45.0),
2122            (-45.0, -45.0),
2123            (181.0, -179.0),
2124            (-181.0, 179.0),
2125            (720.0, 0.0),
2126            (-720.0, 0.0),
2127            (1.0e6, -80.0),
2128        ] {
2129            let once = wrap_lon(raw);
2130            close(once, expected, 1e-9);
2131            close(wrap_lon(once), once, 1e-9);
2132        }
2133    }
2134
2135    // ==================================================================
2136    // lon_to_tile_x / tile_x_to_lon  (numeric)
2137    // ==================================================================
2138
2139    #[test]
2140    fn lon_to_tile_x_zero_tile_count_collapses_to_zero() {
2141        for lon in [-180.0, -1.0, 0.0, 1.0, 180.0] {
2142            assert_eq!(lon_to_tile_x(lon, 0.0), 0.0, "lon {lon} at tile_count 0");
2143        }
2144    }
2145
2146    #[test]
2147    fn lon_to_tile_x_nan_inf_are_defined_not_panics() {
2148        assert!(lon_to_tile_x(f64::NAN, 4.0).is_nan());
2149        assert!(lon_to_tile_x(0.0, f64::NAN).is_nan());
2150        assert_eq!(lon_to_tile_x(f64::INFINITY, 4.0), f64::INFINITY);
2151        assert_eq!(lon_to_tile_x(f64::NEG_INFINITY, 4.0), f64::NEG_INFINITY);
2152        // inf * 0 is the one genuinely undefined product → NaN, not a panic.
2153        assert!(lon_to_tile_x(f64::INFINITY, 0.0).is_nan());
2154    }
2155
2156    #[test]
2157    fn lon_to_tile_x_is_monotonic_and_saturates_on_huge_counts() {
2158        let mut prev = f64::NEG_INFINITY;
2159        for lon in [-180.0, -90.0, -0.5, 0.0, 0.5, 90.0, 180.0] {
2160            let x = lon_to_tile_x(lon, 256.0);
2161            assert!(x > prev, "lon_to_tile_x must increase with longitude");
2162            prev = x;
2163        }
2164        assert_eq!(lon_to_tile_x(180.0, f64::MAX), f64::MAX);
2165        assert!(lon_to_tile_x(180.0, f64::INFINITY).is_infinite());
2166    }
2167
2168    #[test]
2169    fn tile_x_to_lon_degenerate_tile_counts_do_not_panic() {
2170        // 0/0 is the only NaN; a non-zero column over a zero-wide world is +inf.
2171        assert!(tile_x_to_lon(0.0, 0.0).is_nan());
2172        assert!(tile_x_to_lon(1.0, 0.0).is_infinite());
2173        assert!(tile_x_to_lon(f64::NAN, 4.0).is_nan());
2174        assert!(tile_x_to_lon(f64::MAX, f64::MIN_POSITIVE).is_infinite());
2175    }
2176
2177    #[test]
2178    fn lon_tile_x_round_trips_across_zooms_and_edges() {
2179        for z in [0u32, 1, 5, 14, 22] {
2180            let tc = f64::from(1u32 << z);
2181            for lon in [-180.0, -179.999, -122.4194, 0.0, 0.1, 151.2093, 180.0] {
2182                let x = lon_to_tile_x(lon, tc);
2183                close(tile_x_to_lon(x, tc), lon, 1e-9);
2184            }
2185        }
2186    }
2187
2188    // ==================================================================
2189    // lat_to_tile_y / tile_y_to_lat  (numeric)
2190    // ==================================================================
2191
2192    #[test]
2193    fn lat_to_tile_y_inside_the_mercator_band_is_finite_and_ordered() {
2194        let tc = 256.0;
2195        let mut prev = f64::NEG_INFINITY;
2196        // y grows southward, so iterate north → south and expect a rise.
2197        for lat in [85.0, 60.0, 30.0, 0.0, -30.0, -60.0, -85.0] {
2198            let y = lat_to_tile_y(lat, tc);
2199            assert!(y.is_finite(), "lat {lat} → {y}");
2200            assert!((0.0..=tc).contains(&y), "lat {lat} → {y} outside the grid");
2201            assert!(y > prev, "tile-y must increase as latitude decreases");
2202            prev = y;
2203        }
2204    }
2205
2206    #[test]
2207    fn lat_to_tile_y_nan_and_infinite_latitudes_are_nan() {
2208        assert!(lat_to_tile_y(f64::NAN, 4.0).is_nan());
2209        // tan(±inf) is NaN, so the whole Mercator term degrades to NaN.
2210        assert!(lat_to_tile_y(f64::INFINITY, 4.0).is_nan());
2211        assert!(lat_to_tile_y(f64::NEG_INFINITY, 4.0).is_nan());
2212        assert!(lat_to_tile_y(0.0, f64::NAN).is_nan());
2213    }
2214
2215    #[test]
2216    fn lat_to_tile_y_past_the_poles_does_not_panic() {
2217        // Beyond ±85.05° the projection is undefined; assert only that every
2218        // one of these returns (reaching the length check means no panic).
2219        let outs: Vec<f64> = [90.0, -90.0, 89.9999, -89.9999, 180.0, -180.0, 1.0e9]
2220            .iter()
2221            .map(|lat| lat_to_tile_y(*lat, 4.0))
2222            .collect();
2223        assert_eq!(outs.len(), 7);
2224    }
2225
2226    #[test]
2227    fn tile_y_to_lat_saturates_at_the_poles_for_out_of_range_rows() {
2228        for (y, expected) in [(-1.0e9_f64, 90.0_f64), (1.0e9, -90.0)] {
2229            let lat = tile_y_to_lat(y, 4.0);
2230            close(lat, expected, 1e-9);
2231        }
2232        // No finite row can ever escape ±90°.
2233        for y in [-1.0e300, -1000.0, -1.0, 0.0, 2.0, 1000.0, 1.0e300] {
2234            let lat = tile_y_to_lat(y, 4.0);
2235            assert!(
2236                (-90.0 - 1e-9..=90.0 + 1e-9).contains(&lat),
2237                "row {y} → {lat} outside ±90"
2238            );
2239        }
2240    }
2241
2242    #[test]
2243    fn tile_y_to_lat_degenerate_tile_counts_do_not_panic() {
2244        assert!(tile_y_to_lat(0.0, 0.0).is_nan()); // 0/0
2245        close(tile_y_to_lat(1.0, 0.0), -90.0, 1e-9); // +inf rows south
2246        assert!(tile_y_to_lat(f64::NAN, 4.0).is_nan());
2247    }
2248
2249    #[test]
2250    fn lat_tile_y_round_trips_at_the_mercator_edges() {
2251        for z in [0u32, 3, 14, 22] {
2252            let tc = f64::from(1u32 << z);
2253            for lat in [-85.05, -45.0, -0.0001, 0.0, 0.0001, 45.0, 85.05] {
2254                let y = lat_to_tile_y(lat, tc);
2255                close(tile_y_to_lat(y, tc), lat, 1e-6);
2256            }
2257        }
2258    }
2259
2260    // ==================================================================
2261    // pan_viewport  (numeric)
2262    // ==================================================================
2263
2264    #[test]
2265    fn pan_viewport_nan_inputs_propagate_without_panicking() {
2266        let (lon, lat) = pan_viewport(f64::NAN, 0.0, 2.0, 10.0, 10.0);
2267        assert!(lat.is_nan(), "NaN centre latitude must stay NaN, got {lat}");
2268        assert!(lon.is_nan() || (-180.0..=180.0).contains(&lon));
2269
2270        let (lon, _) = pan_viewport(0.0, f64::NAN, 2.0, 10.0, 10.0);
2271        assert!(lon.is_nan());
2272
2273        let (lon, lat) = pan_viewport(0.0, 0.0, 2.0, f64::NAN, f64::NAN);
2274        assert!(lon.is_nan() && lat.is_nan());
2275    }
2276
2277    #[test]
2278    fn pan_viewport_infinite_zoom_is_a_no_op() {
2279        // world_px = inf → every pixel delta maps to a zero angular delta.
2280        let (lon, lat) = pan_viewport(37.0, -122.0, f64::INFINITY, 1.0e6, -1.0e6);
2281        close(lon, -122.0, 1e-9);
2282        close(lat, 37.0, 1e-9);
2283    }
2284
2285    #[test]
2286    fn pan_viewport_negative_infinite_zoom_saturates_latitude_not_panics() {
2287        // world_px underflows to 0 → the longitude delta is ±inf (→ NaN through
2288        // wrap_lon) and the latitude delta saturates against the ±85 clamp.
2289        let (lon, lat) = pan_viewport(0.0, 0.0, f64::NEG_INFINITY, 100.0, 100.0);
2290        assert!(lon.is_nan(), "expected NaN longitude, got {lon}");
2291        close(lat, 85.0, 1e-9);
2292        let (_, lat_south) = pan_viewport(0.0, 0.0, f64::NEG_INFINITY, 0.0, -100.0);
2293        close(lat_south, -85.0, 1e-9);
2294    }
2295
2296    #[test]
2297    fn pan_viewport_extreme_pixel_deltas_stay_inside_the_world() {
2298        for dx in [-1.0e18_f64, -1.0e9, -1.0, 0.0, 1.0, 1.0e9, 1.0e18] {
2299            for dy in [-1.0e18_f64, 0.0, 1.0e18] {
2300                let (lon, lat) = pan_viewport(37.0, -122.0, 0.0, dx, dy);
2301                assert!(lon.is_finite(), "dx {dx} dy {dy} → lon {lon}");
2302                assert!((-180.0..=180.0).contains(&lon), "lon {lon} out of range");
2303                assert!((-85.0..=85.0).contains(&lat), "lat {lat} out of range");
2304            }
2305        }
2306    }
2307
2308    #[test]
2309    fn pan_viewport_zero_zoom_and_zero_delta_is_the_identity() {
2310        let (lon, lat) = pan_viewport(0.0, 0.0, 0.0, 0.0, 0.0);
2311        assert_eq!((lon, lat), (0.0, 0.0));
2312    }
2313
2314    #[test]
2315    fn pan_viewport_latitude_step_shrinks_towards_the_poles() {
2316        // d_lat carries a cos(lat) factor, so the same drag moves less near a pole.
2317        let (_, at_equator) = pan_viewport(0.0, 0.0, 2.0, 0.0, 100.0);
2318        let (_, at_80) = pan_viewport(80.0, 0.0, 2.0, 0.0, 100.0);
2319        assert!(
2320            (at_80 - 80.0).abs() < at_equator.abs(),
2321            "pole-adjacent pan {at_80} must move less than equatorial {at_equator}"
2322        );
2323    }
2324
2325    // ==================================================================
2326    // MapWidget::latlon_at_px / px_at_latlon  (numeric)
2327    // ==================================================================
2328
2329    #[test]
2330    fn latlon_at_px_centre_pixel_is_the_viewport_centre() {
2331        let viewport = view(51.5074, -0.1278, 11.0);
2332        let container = LogicalSize::new(800.0, 600.0);
2333        let coord = MapWidget::latlon_at_px(
2334            viewport,
2335            LogicalPosition::new(400.0, 300.0),
2336            container,
2337        );
2338        close(coord.lat_deg, 51.5074, 1e-9);
2339        close(coord.lon_deg, -0.1278, 1e-9);
2340    }
2341
2342    #[test]
2343    fn latlon_at_px_result_is_always_clamped_or_nan() {
2344        let container = LogicalSize::new(800.0, 600.0);
2345        for zoom in [0.0_f32, 2.0, 11.0, 22.0] {
2346            for px in [
2347                LogicalPosition::new(0.0, 0.0),
2348                LogicalPosition::new(-1.0e9, -1.0e9),
2349                LogicalPosition::new(1.0e9, 1.0e9),
2350                LogicalPosition::new(f32::MAX, f32::MIN),
2351            ] {
2352                let c = MapWidget::latlon_at_px(view(0.0, 0.0, zoom), px, container);
2353                assert!(
2354                    (-180.0..=180.0).contains(&c.lon_deg),
2355                    "zoom {zoom} px {px:?} → lon {}",
2356                    c.lon_deg
2357                );
2358                assert!(
2359                    (-85.0..=85.0).contains(&c.lat_deg),
2360                    "zoom {zoom} px {px:?} → lat {}",
2361                    c.lat_deg
2362                );
2363            }
2364        }
2365    }
2366
2367    #[test]
2368    fn latlon_at_px_non_finite_zoom_does_not_panic() {
2369        let container = LogicalSize::new(800.0, 600.0);
2370        let px = LogicalPosition::new(10.0, 10.0);
2371        // +inf zoom → infinitely large world → the centre pixel wins.
2372        let c = MapWidget::latlon_at_px(view(0.0, 0.0, f32::INFINITY), px, container);
2373        close(c.lon_deg, 0.0, 1e-9);
2374        close(c.lat_deg, 0.0, 1e-9);
2375        // -inf zoom → zero-size world → the clamp saturates instead of overflowing.
2376        let c = MapWidget::latlon_at_px(view(0.0, 0.0, f32::NEG_INFINITY), px, container);
2377        assert!((-180.0..=180.0).contains(&c.lon_deg));
2378        assert!((-85.0..=85.0).contains(&c.lat_deg));
2379        // NaN zoom → NaN out, never a panic.
2380        let c = MapWidget::latlon_at_px(view(0.0, 0.0, f32::NAN), px, container);
2381        assert!(c.lon_deg.is_nan() && c.lat_deg.is_nan());
2382    }
2383
2384    #[test]
2385    fn latlon_at_px_zero_sized_container_is_still_defined() {
2386        let c = MapWidget::latlon_at_px(
2387            view(10.0, 20.0, 4.0),
2388            LogicalPosition::new(0.0, 0.0),
2389            LogicalSize::new(0.0, 0.0),
2390        );
2391        close(c.lat_deg, 10.0, 1e-9);
2392        close(c.lon_deg, 20.0, 1e-9);
2393    }
2394
2395    #[test]
2396    fn px_at_latlon_centre_coord_is_the_container_centre() {
2397        let viewport = view(37.7749, -122.4194, 12.0);
2398        let container = LogicalSize::new(1024.0, 768.0);
2399        let p = MapWidget::px_at_latlon(
2400            viewport,
2401            MapLatLon {
2402                lat_deg: viewport.centre_lat_deg,
2403                lon_deg: viewport.centre_lon_deg,
2404            },
2405            container,
2406        );
2407        close(f64::from(p.x), 512.0, 1e-3);
2408        close(f64::from(p.y), 384.0, 1e-3);
2409    }
2410
2411    #[test]
2412    fn px_at_latlon_saturates_instead_of_overflowing_f32() {
2413        // world = 256 * 2^f32::MAX overflows to +inf; the f64→f32 cast must
2414        // saturate (Rust's `as` is saturating) rather than trap.
2415        let container = LogicalSize::new(800.0, 600.0);
2416        let p = MapWidget::px_at_latlon(
2417            view(0.0, 0.0, f32::MAX),
2418            MapLatLon {
2419                lat_deg: 10.0,
2420                lon_deg: 10.0,
2421            },
2422            container,
2423        );
2424        assert!(!p.x.is_finite(), "expected a saturated x, got {}", p.x);
2425        assert!(!p.y.is_finite(), "expected a saturated y, got {}", p.y);
2426    }
2427
2428    #[test]
2429    fn px_at_latlon_at_a_pole_centre_does_not_panic() {
2430        // cos(90°) is ~6e-17, not exactly 0 — the division is huge but finite.
2431        let container = LogicalSize::new(800.0, 600.0);
2432        let p = MapWidget::px_at_latlon(
2433            view(90.0, 0.0, 2.0),
2434            MapLatLon {
2435                lat_deg: 0.0,
2436                lon_deg: 0.0,
2437            },
2438            container,
2439        );
2440        assert!(p.x.is_finite(), "x should stay finite, got {}", p.x);
2441        assert!(!p.y.is_nan(), "y must be a number or an infinity, got {}", p.y);
2442    }
2443
2444    #[test]
2445    fn projection_px_round_trips_within_the_clamped_band() {
2446        let container = LogicalSize::new(800.0, 600.0);
2447        for zoom in [2.0_f32, 8.0, 14.0] {
2448            let viewport = view(37.7749, -122.4194, zoom);
2449            for (dlat, dlon) in [(0.0, 0.0), (0.01, 0.02), (-0.03, 0.04)] {
2450                let coord = MapLatLon {
2451                    lat_deg: viewport.centre_lat_deg + dlat,
2452                    lon_deg: viewport.centre_lon_deg + dlon,
2453                };
2454                let px = MapWidget::px_at_latlon(viewport, coord, container);
2455                let back = MapWidget::latlon_at_px(viewport, px, container);
2456                close(back.lat_deg, coord.lat_deg, 1e-4);
2457                close(back.lon_deg, coord.lon_deg, 1e-4);
2458            }
2459        }
2460    }
2461
2462    // ==================================================================
2463    // visible_tile_range  (numeric)
2464    // ==================================================================
2465
2466    #[test]
2467    fn visible_tile_range_zero_zoom_scale_saturates_to_the_i32_extremes() {
2468        // tile_px = 0 → the half-extent is +inf → the floor/ceil casts saturate.
2469        // x is unclamped (world wrap), so the caller receives the FULL i32 span.
2470        let (x0, x1, y0, y1) = visible_tile_range(8.0, 8.0, 800.0, 600.0, 0.0, 16);
2471        assert_eq!((x0, x1), (i32::MIN, i32::MAX));
2472        assert_eq!((y0, y1), (0, 15));
2473    }
2474
2475    #[test]
2476    fn visible_tile_range_infinite_dimensions_saturate_the_same_way() {
2477        let (x0, x1, y0, y1) =
2478            visible_tile_range(8.0, 8.0, f32::INFINITY, f32::INFINITY, 1.0, 16);
2479        assert_eq!((x0, x1), (i32::MIN, i32::MAX));
2480        assert_eq!((y0, y1), (0, 15));
2481    }
2482
2483    #[test]
2484    fn visible_tile_range_nan_inputs_collapse_to_a_single_cell() {
2485        // `NaN as i32` is 0 in Rust (saturating cast), so a non-finite viewport
2486        // degenerates to the (0,0) cell instead of an unbounded loop.
2487        assert_eq!(
2488            visible_tile_range(8.0, 8.0, f32::NAN, f32::NAN, 1.0, 16),
2489            (0, 0, 0, 0)
2490        );
2491        assert_eq!(
2492            visible_tile_range(f32::NAN, f32::NAN, 512.0, 512.0, 1.0, 16),
2493            (0, 0, 0, 0)
2494        );
2495        assert_eq!(
2496            visible_tile_range(8.0, 8.0, 512.0, 512.0, f32::NAN, 16),
2497            (0, 0, 0, 0)
2498        );
2499    }
2500
2501    #[test]
2502    fn visible_tile_range_negative_dimensions_are_taken_absolutely() {
2503        let positive = visible_tile_range(8.0, 8.0, 512.0, 384.0, 1.0, 16);
2504        let negative = visible_tile_range(8.0, 8.0, -512.0, -384.0, 1.0, 16);
2505        assert_eq!(positive, negative);
2506    }
2507
2508    #[test]
2509    fn visible_tile_range_zero_tile_count_yields_an_empty_row_span() {
2510        // max_idx = -1, so y_min (>= 0) is above y_max — the caller's
2511        // `for y in y_min..=y_max` loop body never runs. No tiles, no panic.
2512        let (_, _, y0, y1) = visible_tile_range(0.0, 0.0, 512.0, 512.0, 1.0, 0);
2513        assert!(y0 > y1, "expected an empty row span, got {y0}..={y1}");
2514    }
2515
2516    #[test]
2517    fn visible_tile_range_u32_max_tile_count_wraps_to_an_empty_row_span() {
2518        // `u32::MAX as i32` is -1 → max_idx -2 → again an empty (safe) span.
2519        let (_, _, y0, y1) = visible_tile_range(0.0, 0.0, 512.0, 512.0, 1.0, u32::MAX);
2520        assert!(y0 > y1, "expected an empty row span, got {y0}..={y1}");
2521    }
2522
2523    #[test]
2524    fn visible_tile_range_always_keeps_a_one_tile_margin() {
2525        // Even a 1x1-pixel viewport must over-scan by a whole tile each side.
2526        let (x0, x1, y0, y1) = visible_tile_range(8.0, 8.0, 1.0, 1.0, 1.0, 16);
2527        assert!(x0 <= 7 && x1 >= 9, "x span {x0}..={x1} lost its margin");
2528        assert!(y0 <= 7 && y1 >= 9, "y span {y0}..={y1} lost its margin");
2529    }
2530
2531    #[test]
2532    fn visible_tile_range_extreme_zoom_scale_shrinks_to_the_margin() {
2533        // A gigantic tile_px makes the viewport sub-tile: only the margin remains.
2534        let (x0, x1, y0, y1) = visible_tile_range(8.0, 8.0, 800.0, 600.0, f32::MAX, 16);
2535        assert_eq!((x0, x1), (7, 9));
2536        assert_eq!((y0, y1), (7, 9));
2537    }
2538
2539    // ==================================================================
2540    // wrap_tile_x  (numeric)
2541    // ==================================================================
2542
2543    #[test]
2544    fn wrap_tile_x_zero_tile_count_is_zero_never_a_division_by_zero() {
2545        for x in [i32::MIN, -7, -1, 0, 1, 7, i32::MAX] {
2546            assert_eq!(wrap_tile_x(x, 0), 0, "column {x} at tile_count 0");
2547        }
2548    }
2549
2550    #[test]
2551    fn wrap_tile_x_extremes_stay_inside_the_band() {
2552        for tile_count in [1u32, 2, 4, 256, 65_536, 1 << 30, 1 << 31] {
2553            for x in [i32::MIN, -1_000_000, -1, 0, 1, 1_000_000, i32::MAX] {
2554                let wrapped = wrap_tile_x(x, tile_count);
2555                assert!(
2556                    wrapped < tile_count,
2557                    "column {x} at tile_count {tile_count} → {wrapped} (out of band)"
2558                );
2559            }
2560        }
2561    }
2562
2563    #[test]
2564    fn wrap_tile_x_matches_rem_euclid_for_realistic_zooms() {
2565        for z in 0u32..=20 {
2566            let tile_count = 1u32 << z;
2567            for x in [i32::MIN, -3, -1, 0, 1, 3, i32::MAX] {
2568                assert_eq!(
2569                    wrap_tile_x(x, tile_count),
2570                    x.rem_euclid(tile_count as i32) as u32
2571                );
2572            }
2573        }
2574    }
2575
2576    #[test]
2577    fn wrap_tile_x_is_periodic_in_tile_count() {
2578        for tile_count in [1u32, 2, 4, 16, 1024] {
2579            for x in [-9i32, -1, 0, 1, 9] {
2580                let shifted = x
2581                    .checked_add(tile_count as i32)
2582                    .expect("shift must stay in range");
2583                assert_eq!(wrap_tile_x(x, tile_count), wrap_tile_x(shifted, tile_count));
2584            }
2585        }
2586    }
2587
2588    // ==================================================================
2589    // map_visible_tiles  (numeric)
2590    // ==================================================================
2591
2592    #[test]
2593    fn map_visible_tiles_zero_bounds_still_covers_the_centre() {
2594        let layer = MapTileLayer::default();
2595        let tiles = map_visible_tiles(&view(0.0, 0.0, 2.0), LogicalSize::new(0.0, 0.0), &layer);
2596        assert!(!tiles.is_empty(), "the one-tile margin must survive 0x0 bounds");
2597        for t in &tiles {
2598            assert_eq!(t.z, 2);
2599            assert!(t.x < 4 && t.y < 4, "tile {t:?} escaped the z2 grid");
2600        }
2601    }
2602
2603    #[test]
2604    fn map_visible_tiles_non_finite_bounds_degenerate_to_one_tile() {
2605        let layer = MapTileLayer::default();
2606        let tiles = map_visible_tiles(
2607            &view(0.0, 0.0, 2.0),
2608            LogicalSize::new(f32::NAN, f32::NAN),
2609            &layer,
2610        );
2611        assert_eq!(tiles.len(), 1, "NaN bounds must not enumerate a grid");
2612    }
2613
2614    #[test]
2615    fn map_visible_tiles_nan_zoom_degenerates_to_one_tile() {
2616        let layer = MapTileLayer::default();
2617        let tiles = map_visible_tiles(
2618            &view(0.0, 0.0, f32::NAN),
2619            LogicalSize::new(800.0, 600.0),
2620            &layer,
2621        );
2622        assert_eq!(tiles.len(), 1);
2623        assert_eq!(tiles[0].z, 0, "a NaN zoom clamps to the layer minimum");
2624    }
2625
2626    #[test]
2627    fn map_visible_tiles_positive_infinite_zoom_stays_bounded() {
2628        let layer = MapTileLayer::default();
2629        let tiles = map_visible_tiles(
2630            &view(0.0, 0.0, f32::INFINITY),
2631            LogicalSize::new(800.0, 600.0),
2632            &layer,
2633        );
2634        assert!(!tiles.is_empty());
2635        assert!(tiles.len() < 64, "+inf zoom produced {} tiles", tiles.len());
2636        for t in &tiles {
2637            assert_eq!(t.z, layer.max_zoom, "+inf zoom must clamp to max_zoom");
2638        }
2639    }
2640
2641    #[test]
2642    fn map_visible_tiles_negative_bounds_match_positive_bounds() {
2643        let layer = MapTileLayer::default();
2644        let viewport = view(48.0, 11.0, 6.0);
2645        let positive = map_visible_tiles(&viewport, LogicalSize::new(640.0, 480.0), &layer);
2646        let negative = map_visible_tiles(&viewport, LogicalSize::new(-640.0, -480.0), &layer);
2647        assert_eq!(positive, negative);
2648    }
2649
2650    #[test]
2651    fn map_visible_tiles_ids_always_live_inside_the_grid() {
2652        for (min_zoom, max_zoom) in [(0u8, 14u8), (3, 5), (0, 0)] {
2653            let layer = layer_zoom(min_zoom, max_zoom);
2654            for zoom in [-1.0_f32, 0.0, 2.5, 7.0, 99.0] {
2655                for (lat, lon) in [(0.0, 0.0), (85.0, 180.0), (-85.0, -180.0), (60.0, 179.99)] {
2656                    let viewport = view(lat, lon, zoom);
2657                    let tiles =
2658                        map_visible_tiles(&viewport, LogicalSize::new(800.0, 600.0), &layer);
2659                    let expected_z = (zoom.floor() as i32)
2660                        .clamp(i32::from(min_zoom), i32::from(max_zoom))
2661                        as u8;
2662                    let tile_count = 1u32 << u32::from(expected_z);
2663                    for t in &tiles {
2664                        assert_eq!(t.z, expected_z, "zoom {zoom} produced z {}", t.z);
2665                        assert!(t.x < tile_count, "column {} >= {tile_count}", t.x);
2666                        assert!(t.y < tile_count, "row {} >= {tile_count}", t.y);
2667                    }
2668                }
2669            }
2670        }
2671    }
2672
2673    #[test]
2674    fn map_visible_tiles_wraps_columns_across_the_antimeridian() {
2675        // A viewport pinned to +180° over-scans past the east edge; every id
2676        // must still be a legal column (the wrap), never tile_count or above.
2677        let layer = layer_zoom(0, 14);
2678        let tiles = map_visible_tiles(
2679            &view(0.0, 180.0, 3.0),
2680            LogicalSize::new(1024.0, 256.0),
2681            &layer,
2682        );
2683        assert!(!tiles.is_empty());
2684        assert!(
2685            tiles.iter().any(|t| t.x == 0),
2686            "panning past +180° must surface the west-edge column"
2687        );
2688        for t in &tiles {
2689            assert!(t.x < 8, "column {} escaped the z3 grid", t.x);
2690        }
2691    }
2692
2693    // ==================================================================
2694    // MapWidget builders  (constructors)
2695    // ==================================================================
2696
2697    #[test]
2698    fn create_uses_the_given_layer_and_neutral_defaults() {
2699        let layer = layer_zoom(2, 9);
2700        let widget = MapWidget::create(layer.clone());
2701        assert_eq!(widget.layer, layer);
2702        assert_eq!(widget.viewport, MapViewport::default());
2703        assert!(widget.container_style.as_slice().is_empty());
2704        assert!(matches!(
2705            widget.on_viewport_changed,
2706            OptionMapViewportChanged::None
2707        ));
2708        assert!(matches!(widget.on_pin_tap, OptionMapPinTap::None));
2709    }
2710
2711    #[test]
2712    fn create_accepts_degenerate_layers_without_panicking() {
2713        // An inverted zoom band and an empty template are nonsense but must
2714        // still build - the widget validates nothing at construction time.
2715        let widget = MapWidget::create(MapTileLayer {
2716            url_template: AzString::from(""),
2717            min_zoom: 30,
2718            max_zoom: 0,
2719            attribution: AzString::from(""),
2720            style_css: AzString::from(""),
2721        });
2722        assert_eq!(widget.layer.min_zoom, 30);
2723        assert_eq!(widget.layer.max_zoom, 0);
2724    }
2725
2726    #[test]
2727    fn with_viewport_stores_extreme_values_verbatim() {
2728        let widget = MapWidget::create(MapTileLayer::default())
2729            .with_viewport(view(1.0e300, -1.0e300, f32::MAX));
2730        assert_eq!(widget.viewport.centre_lat_deg, 1.0e300);
2731        assert_eq!(widget.viewport.centre_lon_deg, -1.0e300);
2732        assert_eq!(widget.viewport.zoom, f32::MAX);
2733
2734        let widget = MapWidget::create(MapTileLayer::default())
2735            .with_viewport(view(f64::NAN, f64::INFINITY, f32::NEG_INFINITY));
2736        assert!(widget.viewport.centre_lat_deg.is_nan());
2737        assert!(widget.viewport.centre_lon_deg.is_infinite());
2738        assert!(widget.viewport.zoom.is_infinite());
2739    }
2740
2741    #[test]
2742    fn with_viewport_is_last_write_wins() {
2743        let widget = MapWidget::create(MapTileLayer::default())
2744            .with_viewport(view(1.0, 2.0, 3.0))
2745            .with_viewport(view(4.0, 5.0, 6.0));
2746        assert_eq!(widget.viewport, view(4.0, 5.0, 6.0));
2747    }
2748
2749    #[test]
2750    fn with_container_style_replaces_the_style_vec() {
2751        let css = CssPropertyWithConditionsVec::parse("width: 100px; height: 50px;");
2752        let parsed_len = css.as_slice().len();
2753        assert!(parsed_len > 0, "positive control: the style must parse");
2754        let widget = MapWidget::create(MapTileLayer::default()).with_container_style(css);
2755        assert_eq!(widget.container_style.as_slice().len(), parsed_len);
2756
2757        // An unparseable style yields an empty vec, and the builder stores it.
2758        let widget = MapWidget::create(MapTileLayer::default())
2759            .with_container_style(CssPropertyWithConditionsVec::parse(""));
2760        assert!(widget.container_style.as_slice().is_empty());
2761    }
2762
2763    #[test]
2764    fn with_container_style_tolerates_garbage_and_unicode() {
2765        for style in [
2766            "\u{1F600}: \u{1F600};",
2767            "   ",
2768            ";;;;",
2769            "width",
2770            "width: ;",
2771            "}{",
2772            "color: \u{0301}\u{0301};",
2773        ] {
2774            let widget = MapWidget::create(MapTileLayer::default())
2775                .with_container_style(CssPropertyWithConditionsVec::parse(style));
2776            // Reaching here means neither the parser nor the builder panicked.
2777            let _ = widget.container_style.as_slice().len();
2778        }
2779    }
2780
2781    #[test]
2782    fn with_on_viewport_changed_installs_and_replaces_the_hook() {
2783        let mut widget = MapWidget::create(MapTileLayer::default()).with_on_viewport_changed(
2784            RefAny::new(HookLog::default()),
2785            record_viewport as MapViewportChangedCallbackType,
2786        );
2787        assert!(matches!(
2788            widget.on_viewport_changed,
2789            OptionMapViewportChanged::Some(_)
2790        ));
2791        // Re-setting must overwrite, not accumulate.
2792        widget.set_on_viewport_changed(
2793            RefAny::new(HookLog::default()),
2794            record_viewport as MapViewportChangedCallbackType,
2795        );
2796        assert!(matches!(
2797            widget.on_viewport_changed,
2798            OptionMapViewportChanged::Some(_)
2799        ));
2800        assert!(matches!(widget.on_pin_tap, OptionMapPinTap::None));
2801    }
2802
2803    #[test]
2804    fn with_on_pin_tap_installs_and_replaces_the_hook() {
2805        let mut widget = MapWidget::create(MapTileLayer::default())
2806            .with_on_pin_tap(RefAny::new(HookLog::default()), record_pin as MapPinTapCallbackType);
2807        assert!(matches!(widget.on_pin_tap, OptionMapPinTap::Some(_)));
2808        widget.set_on_pin_tap(
2809            RefAny::new(HookLog::default()),
2810            record_pin as MapPinTapCallbackType,
2811        );
2812        assert!(matches!(widget.on_pin_tap, OptionMapPinTap::Some(_)));
2813        assert!(matches!(
2814            widget.on_viewport_changed,
2815            OptionMapViewportChanged::None
2816        ));
2817    }
2818
2819    #[test]
2820    fn builder_chain_order_does_not_matter_for_independent_fields() {
2821        let layer = layer_zoom(1, 12);
2822        let viewport = view(10.0, 20.0, 5.0);
2823        let a = MapWidget::create(layer.clone())
2824            .with_viewport(viewport)
2825            .with_container_style(CssPropertyWithConditionsVec::parse("width: 10px;"));
2826        let b = MapWidget::create(layer)
2827            .with_container_style(CssPropertyWithConditionsVec::parse("width: 10px;"))
2828            .with_viewport(viewport);
2829        assert_eq!(a, b);
2830    }
2831
2832    // ==================================================================
2833    // MapWidget::dom / dom_with_fetch / build_dom
2834    // ==================================================================
2835
2836    #[test]
2837    fn dom_builds_a_single_virtual_view_child_with_a_dataset() {
2838        let mut dom = MapWidget::create(MapTileLayer::default())
2839            .with_viewport(view(0.0, 0.0, 3.0))
2840            .dom();
2841        assert_eq!(dom.children.as_slice().len(), 1, "one VirtualView child");
2842        let dataset = dom
2843            .root
2844            .get_dataset_mut()
2845            .expect("the widget div must carry a MapTileCache dataset");
2846        let cache = dataset
2847            .downcast_ref::<MapTileCache>()
2848            .expect("the dataset must be a MapTileCache");
2849        assert_eq!(cache.viewport.zoom, 3.0);
2850        assert!(cache.tiles.is_empty());
2851        assert!(cache.fetch_callback.is_none(), "dom() wires no worker");
2852    }
2853
2854    #[test]
2855    fn dom_survives_degenerate_viewports_and_layers() {
2856        for viewport in [
2857            view(f64::NAN, f64::NAN, f32::NAN),
2858            view(1.0e300, -1.0e300, f32::INFINITY),
2859            view(0.0, 0.0, f32::NEG_INFINITY),
2860        ] {
2861            let dom = MapWidget::create(layer_zoom(200, 1))
2862                .with_viewport(viewport)
2863                .dom();
2864            assert_eq!(dom.children.as_slice().len(), 1);
2865        }
2866    }
2867
2868    #[test]
2869    fn dom_with_a_container_style_still_builds_the_grid() {
2870        let dom = MapWidget::create(MapTileLayer::default())
2871            .with_container_style(CssPropertyWithConditionsVec::parse(
2872                "position: relative; width: 320px; height: 240px;",
2873            ))
2874            .dom();
2875        assert_eq!(dom.children.as_slice().len(), 1);
2876    }
2877
2878    #[test]
2879    fn dom_with_fetch_records_the_worker_in_the_dataset() {
2880        let mut dom = MapWidget::create(MapTileLayer::default())
2881            .dom_with_fetch(ThreadCallback::new(noop_worker));
2882        let dataset = dom.root.get_dataset_mut().expect("dataset");
2883        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
2884        let cb = cache
2885            .fetch_callback
2886            .as_ref()
2887            .expect("dom_with_fetch must record the worker");
2888        assert_eq!(cb.cb as usize, noop_worker as ThreadCallbackType as usize);
2889    }
2890
2891    #[test]
2892    fn dom_carries_the_user_hooks_into_the_cache() {
2893        let mut dom = MapWidget::create(MapTileLayer::default())
2894            .with_on_viewport_changed(
2895                RefAny::new(HookLog::default()),
2896                record_viewport as MapViewportChangedCallbackType,
2897            )
2898            .with_on_pin_tap(
2899                RefAny::new(HookLog::default()),
2900                record_pin as MapPinTapCallbackType,
2901            )
2902            .dom();
2903        let dataset = dom.root.get_dataset_mut().expect("dataset");
2904        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
2905        assert!(matches!(
2906            cache.on_viewport_changed,
2907            OptionMapViewportChanged::Some(_)
2908        ));
2909        assert!(matches!(cache.on_pin_tap, OptionMapPinTap::Some(_)));
2910    }
2911
2912    // ==================================================================
2913    // MapTileCache  (constructor + mutators)
2914    // ==================================================================
2915
2916    #[test]
2917    fn tile_cache_new_starts_empty_and_idle() {
2918        let cache = MapTileCache::new(layer_zoom(0, 14), view(1.0, 2.0, 3.0));
2919        assert!(cache.tiles.is_empty());
2920        assert!(cache.fetch_callback.is_none());
2921        assert!(cache.drag_anchor.is_none());
2922        assert!(cache.pinch_anchor.is_none());
2923        assert!(cache.press_origin.is_none());
2924        assert_eq!(cache.viewport, view(1.0, 2.0, 3.0));
2925        assert_eq!(cache.layer.max_zoom, 14);
2926    }
2927
2928    #[test]
2929    fn mark_tile_ready_and_failed_overwrite_each_other() {
2930        let mut cache = cache_at(0.0, 0.0, 4.0);
2931        let tile = MapTileId { z: 4, x: 8, y: 8 };
2932        cache.mark_tile_ready(tile, AzString::from("<svg/>"));
2933        assert!(matches!(cache.tiles.get(&tile), Some(TileEntry::Ready { .. })));
2934        cache.mark_tile_failed(tile, AzString::from("boom"));
2935        assert!(matches!(cache.tiles.get(&tile), Some(TileEntry::Failed { .. })));
2936        cache.mark_tile_ready(tile, AzString::from(""));
2937        assert!(matches!(cache.tiles.get(&tile), Some(TileEntry::Ready { .. })));
2938        assert_eq!(cache.tiles.len(), 1, "the same id must not duplicate");
2939    }
2940
2941    #[test]
2942    fn mark_tile_ready_accepts_empty_unicode_and_huge_payloads() {
2943        let mut cache = cache_at(0.0, 0.0, 4.0);
2944        let payloads = [
2945            AzString::from(""),
2946            AzString::from("\u{1F600}\u{4F60}\u{597D}e\u{0301}"),
2947            AzString::from("\0\u{FFFD}\r\n\t"),
2948            AzString::from("x".repeat(1_000_000)),
2949        ];
2950        for (i, svg) in payloads.into_iter().enumerate() {
2951            cache.mark_tile_ready(
2952                MapTileId {
2953                    z: 4,
2954                    x: i as u32,
2955                    y: 0,
2956                },
2957                svg,
2958            );
2959        }
2960        assert_eq!(cache.tiles.len(), 4);
2961    }
2962
2963    #[test]
2964    fn mark_tile_ready_accepts_out_of_range_tile_ids() {
2965        // Nothing validates an id at insert time - a bogus id from an FFI
2966        // caller must land in the map rather than panic.
2967        let mut cache = cache_at(0.0, 0.0, 4.0);
2968        for tile in [
2969            MapTileId { z: 0, x: 0, y: 0 },
2970            MapTileId {
2971                z: 31,
2972                x: u32::MAX,
2973                y: u32::MAX,
2974            },
2975            MapTileId {
2976                z: 4,
2977                x: u32::MAX,
2978                y: 0,
2979            },
2980        ] {
2981            cache.mark_tile_failed(tile, AzString::from("e"));
2982        }
2983        assert_eq!(cache.tiles.len(), 3);
2984    }
2985
2986    // ==================================================================
2987    // MapTileCache::prune_distant_tiles
2988    // ==================================================================
2989
2990    fn fill_ready_grid(cache: &mut MapTileCache, z: u8, side: u32) {
2991        for x in 0..side {
2992            for y in 0..side {
2993                cache
2994                    .tiles
2995                    .insert(MapTileId { z, x, y }, TileEntry::Ready { svg: AzString::from("<svg/>") });
2996            }
2997        }
2998    }
2999
3000    #[test]
3001    fn prune_never_evicts_in_flight_tiles_even_far_over_the_cap() {
3002        let mut cache = cache_at(0.0, 0.0, 4.0);
3003        for x in 0..16u32 {
3004            for y in 0..16u32 {
3005                cache.tiles.insert(
3006                    MapTileId { z: 4, x, y },
3007                    if (x + y) % 2 == 0 {
3008                        TileEntry::Pending
3009                    } else {
3010                        TileEntry::Fetching
3011                    },
3012                );
3013            }
3014        }
3015        assert_eq!(cache.tiles.len(), 256);
3016        cache.prune_distant_tiles();
3017        assert_eq!(
3018            cache.tiles.len(),
3019            256,
3020            "in-flight tiles are unevictable, so the cap can be exceeded"
3021        );
3022    }
3023
3024    #[test]
3025    fn prune_with_a_nan_viewport_centre_still_bounds_the_cache() {
3026        let mut cache = MapTileCache::new(layer_zoom(0, 19), view(f64::NAN, f64::NAN, 4.0));
3027        fill_ready_grid(&mut cache, 4, 16);
3028        assert_eq!(cache.tiles.len(), 256);
3029        // Every score is NaN → `partial_cmp` returns None → the sort falls back
3030        // to Equal. The eviction count must still be honoured.
3031        cache.prune_distant_tiles();
3032        assert_eq!(cache.tiles.len(), 192);
3033    }
3034
3035    #[test]
3036    fn prune_with_non_finite_zoom_clamps_to_the_layer_band() {
3037        for zoom in [f32::INFINITY, f32::NEG_INFINITY, f32::NAN, 1.0e30, -1.0e30] {
3038            let mut cache = MapTileCache::new(layer_zoom(0, 19), view(0.0, 0.0, zoom));
3039            fill_ready_grid(&mut cache, 4, 16);
3040            cache.prune_distant_tiles();
3041            assert_eq!(cache.tiles.len(), 192, "zoom {zoom} must still bound the cache");
3042        }
3043    }
3044
3045    #[test]
3046    fn prune_is_idempotent_once_under_the_cap() {
3047        let mut cache = cache_at(0.0, 0.0, 4.0);
3048        fill_ready_grid(&mut cache, 4, 16);
3049        cache.prune_distant_tiles();
3050        let first = cache.tiles.len();
3051        let survivors: Vec<MapTileId> = cache.tiles.keys().copied().collect();
3052        cache.prune_distant_tiles();
3053        assert_eq!(cache.tiles.len(), first);
3054        assert_eq!(
3055            cache.tiles.keys().copied().collect::<Vec<_>>(),
3056            survivors,
3057            "a second prune under the cap must change nothing"
3058        );
3059    }
3060
3061    #[test]
3062    fn prune_drops_the_farthest_tiles_first_across_zoom_levels() {
3063        // A mixed-zoom cache: same-zoom near tiles must outlive a wrong-zoom
3064        // tile (the score adds 10_000 per zoom level of mismatch).
3065        let mut cache = cache_at(0.0, 0.0, 4.0);
3066        fill_ready_grid(&mut cache, 4, 16);
3067        let wrong_zoom = MapTileId { z: 9, x: 256, y: 256 };
3068        cache
3069            .tiles
3070            .insert(wrong_zoom, TileEntry::Ready { svg: AzString::from("<svg/>") });
3071        let near = MapTileId { z: 4, x: 8, y: 8 };
3072        cache.prune_distant_tiles();
3073        assert!(cache.tiles.len() <= 192);
3074        assert!(
3075            !cache.tiles.contains_key(&wrong_zoom),
3076            "a zoom-mismatched tile must be evicted before same-zoom neighbours"
3077        );
3078        assert!(cache.tiles.contains_key(&near), "the centre tile must survive");
3079    }
3080
3081    // ==================================================================
3082    // merge_map_tile_cache
3083    // ==================================================================
3084
3085    #[test]
3086    fn merge_with_a_wrong_typed_new_dataset_returns_the_old_one_intact() {
3087        let mut old_cache = cache_at(0.0, 0.0, 5.0);
3088        let tile = MapTileId { z: 5, x: 1, y: 1 };
3089        old_cache.mark_tile_ready(tile, AzString::from("<svg/>"));
3090        let mut merged = merge_map_tile_cache(RefAny::new(0u32), RefAny::new(old_cache));
3091        let cache = merged.downcast_ref::<MapTileCache>().expect("old cache");
3092        assert_eq!(cache.viewport.zoom, 5.0, "no adoption from a bogus new dataset");
3093        assert!(cache.tiles.contains_key(&tile));
3094    }
3095
3096    #[test]
3097    fn merge_with_a_wrong_typed_old_dataset_returns_it_unchanged() {
3098        let new_cache = cache_at(0.0, 0.0, 9.0);
3099        let mut merged = merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(7u64));
3100        assert!(
3101            merged.downcast_ref::<MapTileCache>().is_none(),
3102            "the merge must not fabricate a cache out of a wrong-typed dataset"
3103        );
3104        assert_eq!(*merged.downcast_ref::<u64>().expect("u64 payload"), 7);
3105    }
3106
3107    #[test]
3108    fn merge_of_two_aliases_of_one_dataset_does_not_panic() {
3109        // Both handles share one allocation, so the shared borrow taken for
3110        // `new_data` blocks the exclusive borrow for `old_data`. The merge must
3111        // degrade to "no adoption" rather than deadlock or panic.
3112        let dataset = RefAny::new(cache_at(0.0, 0.0, 5.0));
3113        let mut merged = merge_map_tile_cache(dataset.clone(), dataset);
3114        let cache = merged.downcast_ref::<MapTileCache>().expect("cache");
3115        assert_eq!(cache.viewport.zoom, 5.0);
3116    }
3117
3118    #[test]
3119    fn merge_adopts_the_worker_only_when_the_old_cache_has_none() {
3120        // Old has no worker → adopt the build's.
3121        let old_cache = cache_at(0.0, 0.0, 5.0);
3122        let mut new_cache = cache_at(0.0, 0.0, 6.0);
3123        new_cache.fetch_callback = Some(ThreadCallback::new(noop_worker));
3124        let mut merged = merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(old_cache));
3125        {
3126            let cache = merged.downcast_ref::<MapTileCache>().expect("cache");
3127            let cb = cache.fetch_callback.as_ref().expect("adopted worker");
3128            assert_eq!(cb.cb as usize, noop_worker as ThreadCallbackType as usize);
3129        }
3130
3131        // Old already has one → keep it (the workers already hold its handle).
3132        let mut old_cache = cache_at(0.0, 0.0, 5.0);
3133        old_cache.fetch_callback = Some(ThreadCallback::new(noop_worker));
3134        let mut new_cache = cache_at(0.0, 0.0, 6.0);
3135        new_cache.fetch_callback = Some(ThreadCallback::new(other_noop_worker));
3136        let mut merged = merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(old_cache));
3137        let cache = merged.downcast_ref::<MapTileCache>().expect("cache");
3138        let cb = cache.fetch_callback.as_ref().expect("kept worker");
3139        assert_eq!(cb.cb as usize, noop_worker as ThreadCallbackType as usize);
3140    }
3141
3142    #[test]
3143    fn merge_adopts_the_build_layer_and_viewport_even_when_degenerate() {
3144        let old_cache = MapTileCache::new(layer_zoom(0, 19), view(10.0, 20.0, 5.0));
3145        let new_cache =
3146            MapTileCache::new(layer_zoom(3, 7), view(f64::NAN, f64::INFINITY, f32::NAN));
3147        let mut merged = merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(old_cache));
3148        let cache = merged.downcast_ref::<MapTileCache>().expect("cache");
3149        assert!(cache.viewport.centre_lat_deg.is_nan());
3150        assert!(cache.viewport.zoom.is_nan());
3151        assert_eq!(cache.layer.min_zoom, 3);
3152        assert_eq!(cache.layer.max_zoom, 7);
3153    }
3154
3155    // ==================================================================
3156    // build_tile_url  (parser-ish substitution)
3157    // ==================================================================
3158
3159    #[test]
3160    fn build_tile_url_without_placeholders_is_the_identity() {
3161        let tile = MapTileId { z: 1, x: 2, y: 3 };
3162        assert_eq!(build_tile_url("", tile), "");
3163        assert_eq!(build_tile_url("https://t.example/fixed", tile), "https://t.example/fixed");
3164        // Unknown placeholders are left verbatim, not eaten.
3165        assert_eq!(build_tile_url("{q}/{Z}/{ x }", tile), "{q}/{Z}/{ x }");
3166    }
3167
3168    #[test]
3169    fn build_tile_url_substitutes_extreme_tile_ids() {
3170        let tile = MapTileId {
3171            z: u8::MAX,
3172            x: u32::MAX,
3173            y: 0,
3174        };
3175        assert_eq!(build_tile_url("{z}/{x}/{y}", tile), "255/4294967295/0");
3176    }
3177
3178    #[test]
3179    fn build_tile_url_handles_unicode_and_unbalanced_braces() {
3180        let tile = MapTileId { z: 7, x: 8, y: 9 };
3181        assert_eq!(
3182            build_tile_url("\u{1F600}/{z}/\u{4F60}\u{597D}/{y}", tile),
3183            "\u{1F600}/7/\u{4F60}\u{597D}/9"
3184        );
3185        assert_eq!(build_tile_url("{{z}}", tile), "{7}");
3186        assert_eq!(build_tile_url("{z", tile), "{z");
3187        assert_eq!(build_tile_url("z}", tile), "z}");
3188    }
3189
3190    #[test]
3191    fn build_tile_url_substitution_is_not_re_scanned() {
3192        // `{z}` expands to a number, so no expansion can create a new
3193        // placeholder — but a template that already spells one out must not be
3194        // touched twice either.
3195        let tile = MapTileId { z: 1, x: 2, y: 3 };
3196        assert_eq!(build_tile_url("{z}{x}{y}{z}", tile), "1231");
3197    }
3198
3199    #[test]
3200    fn build_tile_url_extremely_long_template_does_not_hang() {
3201        let template = "{z}/".repeat(100_000);
3202        let url = build_tile_url(&template, MapTileId { z: 14, x: 0, y: 0 });
3203        assert_eq!(url.len(), 100_000 * 3);
3204        assert!(url.starts_with("14/14/"));
3205    }
3206
3207    // ==================================================================
3208    // svg_string_to_dom  (parser)
3209    // ==================================================================
3210
3211    // --- xml + cpurender: the SVG is rasterised into an image node ---
3212
3213    #[cfg(all(feature = "xml", feature = "cpurender"))]
3214    const MINIMAL_TILE_SVG: &str =
3215        r#"<svg viewBox="0 0 16 16"><rect x="0" y="0" width="16" height="16" fill="red"/></svg>"#;
3216
3217    #[cfg(all(feature = "xml", feature = "cpurender"))]
3218    #[test]
3219    fn svg_raster_valid_minimal_is_the_positive_control() {
3220        assert!(
3221            svg_string_to_dom(MINIMAL_TILE_SVG).is_some(),
3222            "a well-formed <svg> must rasterise into a Dom"
3223        );
3224    }
3225
3226    #[cfg(all(feature = "xml", feature = "cpurender"))]
3227    #[test]
3228    fn svg_raster_empty_whitespace_and_garbage_return_none() {
3229        for bad in [
3230            "",
3231            " ",
3232            "   \t\n\r ",
3233            "garbage",
3234            "<<<>>>",
3235            "<svg",
3236            "</svg>",
3237            "<html><body/></html>",
3238            "\u{0}\u{1}\u{2}",
3239        ] {
3240            assert!(
3241                svg_string_to_dom(bad).is_none(),
3242                "{bad:?} must be rejected, not rendered"
3243            );
3244        }
3245    }
3246
3247    #[cfg(all(feature = "xml", feature = "cpurender"))]
3248    #[test]
3249    fn svg_raster_boundary_numeric_attributes_do_not_panic() {
3250        for svg in [
3251            r#"<svg viewBox="0 0 16 16"><rect width="0" height="-0" fill="red"/></svg>"#,
3252            r#"<svg viewBox="0 0 16 16"><rect width="NaN" height="inf" fill="red"/></svg>"#,
3253            r#"<svg viewBox="0 0 16 16"><rect width="1e400" height="1e-400" fill="red"/></svg>"#,
3254            r#"<svg viewBox="0 0 16 16"><rect width="9223372036854775807" height="8"/></svg>"#,
3255            r#"<svg viewBox="0 0 0 0"><rect width="8" height="8" fill="red"/></svg>"#,
3256            r#"<svg viewBox="NaN NaN NaN NaN"><rect width="8" height="8" fill="red"/></svg>"#,
3257        ] {
3258            // Reaching the next iteration means the rasteriser did not panic.
3259            let _ = svg_string_to_dom(svg).is_some();
3260        }
3261    }
3262
3263    #[cfg(all(feature = "xml", feature = "cpurender"))]
3264    #[test]
3265    fn svg_raster_unicode_content_does_not_panic() {
3266        let svg = "<svg viewBox=\"0 0 16 16\"><title>\u{1F600} \u{4F60}\u{597D} \
3267                   e\u{0301} \u{202E}</title><rect width=\"16\" height=\"16\" \
3268                   fill=\"red\"/></svg>";
3269        assert!(svg_string_to_dom(svg).is_some());
3270    }
3271
3272    #[cfg(all(feature = "xml", feature = "cpurender"))]
3273    #[test]
3274    fn svg_raster_leading_and_trailing_junk_is_deterministic() {
3275        for svg in [
3276            "  <svg viewBox=\"0 0 8 8\"><rect width=\"8\" height=\"8\"/></svg>  ",
3277            "<svg viewBox=\"0 0 8 8\"><rect width=\"8\" height=\"8\"/></svg>;garbage",
3278            "junk<svg viewBox=\"0 0 8 8\"><rect width=\"8\" height=\"8\"/></svg>",
3279        ] {
3280            // Whatever the verdict, it must be stable across calls.
3281            assert_eq!(
3282                svg_string_to_dom(svg).is_some(),
3283                svg_string_to_dom(svg).is_some(),
3284                "{svg:?} parsed non-deterministically"
3285            );
3286        }
3287    }
3288
3289    #[cfg(all(feature = "xml", feature = "cpurender"))]
3290    #[test]
3291    fn svg_raster_extremely_long_input_does_not_hang() {
3292        let svg = alloc::format!(
3293            "<svg viewBox=\"0 0 8 8\"><desc>{}</desc><rect width=\"8\" height=\"8\" \
3294             fill=\"red\"/></svg>",
3295            "a".repeat(1_000_000)
3296        );
3297        assert!(svg_string_to_dom(&svg).is_some());
3298    }
3299
3300    #[cfg(all(feature = "xml", feature = "cpurender"))]
3301    #[test]
3302    fn svg_raster_deeply_nested_groups_do_not_stack_overflow() {
3303        // The rasteriser recurses per group; give it a generous stack so a real
3304        // 2000-deep document is a clean test rather than a crash.
3305        let ok = std::thread::Builder::new()
3306            .stack_size(128 * 1024 * 1024)
3307            .spawn(|| {
3308                const DEPTH: usize = 2_000;
3309                let svg = alloc::format!(
3310                    "<svg viewBox=\"0 0 8 8\">{}<rect width=\"8\" height=\"8\" \
3311                     fill=\"red\"/>{}</svg>",
3312                    "<g>".repeat(DEPTH),
3313                    "</g>".repeat(DEPTH)
3314                );
3315                svg_string_to_dom(&svg).is_some()
3316            })
3317            .expect("spawn")
3318            .join()
3319            .expect("2000-deep nesting must not overflow the stack");
3320        assert!(ok);
3321    }
3322
3323    // --- xml without cpurender: the SVG goes through the XML→DOM path ---
3324
3325    #[cfg(all(feature = "xml", not(feature = "cpurender")))]
3326    #[test]
3327    fn svg_dom_valid_minimal_is_the_positive_control() {
3328        assert!(svg_string_to_dom("<svg><g/></svg>").is_some());
3329    }
3330
3331    #[cfg(all(feature = "xml", not(feature = "cpurender")))]
3332    #[test]
3333    fn svg_dom_malformed_markup_returns_none() {
3334        for bad in ["<<<>>>", "<svg", "</svg>", "<a></b>", "\u{0}\u{1}"] {
3335            assert!(svg_string_to_dom(bad).is_none(), "{bad:?} must be rejected");
3336        }
3337    }
3338
3339    #[cfg(all(feature = "xml", not(feature = "cpurender")))]
3340    #[test]
3341    fn svg_dom_empty_whitespace_and_unicode_do_not_panic() {
3342        for input in ["", " ", "   \t\n\r ", "\u{1F600}", "e\u{0301}"] {
3343            assert_eq!(
3344                svg_string_to_dom(input).is_some(),
3345                svg_string_to_dom(input).is_some()
3346            );
3347        }
3348    }
3349
3350    #[cfg(all(feature = "xml", not(feature = "cpurender")))]
3351    #[test]
3352    fn svg_dom_extremely_long_input_does_not_hang() {
3353        let svg = alloc::format!("<svg><desc>{}</desc></svg>", "a".repeat(1_000_000));
3354        let _ = svg_string_to_dom(&svg).is_some();
3355    }
3356
3357    // --- no xml feature: the stub always declines ---
3358
3359    #[cfg(not(feature = "xml"))]
3360    #[test]
3361    fn svg_stub_returns_none_for_every_input() {
3362        for input in ["", "   ", "<svg/>", "<svg><g/></svg>", "\u{1F600}", "<<<>>>"] {
3363            assert!(svg_string_to_dom(input).is_none());
3364        }
3365        let long = "a".repeat(1_000_000);
3366        assert!(svg_string_to_dom(&long).is_none());
3367    }
3368
3369    // ==================================================================
3370    // User-hook invocation  (invoke_viewport_changed / invoke_pin_tap)
3371    // ==================================================================
3372
3373    #[test]
3374    fn invoke_viewport_changed_without_a_hook_is_do_nothing() {
3375        let (update, changes) = with_callback_info(|info| {
3376            invoke_viewport_changed(&OptionMapViewportChanged::None, &info, view(0.0, 0.0, 2.0))
3377        });
3378        assert_eq!(update, Update::DoNothing);
3379        assert!(changes.is_empty());
3380    }
3381
3382    #[test]
3383    fn invoke_viewport_changed_forwards_even_a_nan_viewport() {
3384        let mut log = RefAny::new(HookLog::default());
3385        let hook = OptionMapViewportChanged::Some(MapViewportChanged {
3386            refany: log.clone(),
3387            callback: (record_viewport as MapViewportChangedCallbackType).into(),
3388        });
3389        let viewport = view(f64::NAN, f64::INFINITY, f32::NAN);
3390        let (update, _) = with_callback_info(|info| invoke_viewport_changed(&hook, &info, viewport));
3391        assert_eq!(update, Update::DoNothing);
3392        assert_eq!(hook_log(&mut log), (1, 0));
3393    }
3394
3395    #[test]
3396    fn invoke_pin_tap_without_a_hook_is_do_nothing() {
3397        let (update, _) = with_callback_info(|info| {
3398            invoke_pin_tap(
3399                &OptionMapPinTap::None,
3400                &info,
3401                MapLatLon {
3402                    lat_deg: 0.0,
3403                    lon_deg: 0.0,
3404                },
3405            )
3406        });
3407        assert_eq!(update, Update::DoNothing);
3408    }
3409
3410    #[test]
3411    fn invoke_pin_tap_returns_the_users_update_verbatim() {
3412        let mut log = RefAny::new(HookLog::default());
3413        let hook = OptionMapPinTap::Some(MapPinTap {
3414            refany: log.clone(),
3415            callback: (record_pin as MapPinTapCallbackType).into(),
3416        });
3417        let (update, _) = with_callback_info(|info| {
3418            invoke_pin_tap(
3419                &hook,
3420                &info,
3421                MapLatLon {
3422                    lat_deg: f64::NAN,
3423                    lon_deg: -1.0e300,
3424                },
3425            )
3426        });
3427        assert_eq!(update, Update::RefreshDom);
3428        assert_eq!(hook_log(&mut log), (0, 1));
3429    }
3430
3431    // ==================================================================
3432    // Pointer / scroll callbacks
3433    // ==================================================================
3434
3435    #[test]
3436    fn pointer_down_without_a_cursor_is_a_no_op() {
3437        let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
3438        let (update, _) =
3439            with_callback_info(|info| map_on_pointer_down(dataset.clone(), info));
3440        assert_eq!(update, Update::DoNothing);
3441        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3442        assert!(cache.drag_anchor.is_none());
3443        assert!(cache.press_origin.is_none());
3444    }
3445
3446    #[test]
3447    fn pointer_down_records_both_anchors() {
3448        let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
3449        let (update, _) = with_callback_info_at(cursor_at(120.0, 80.0), |info| {
3450            map_on_pointer_down(dataset.clone(), info)
3451        });
3452        assert_eq!(update, Update::DoNothing);
3453        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3454        let anchor = cache.drag_anchor.expect("drag anchor");
3455        let press = cache.press_origin.expect("press origin");
3456        assert_eq!((anchor.x, anchor.y), (120.0, 80.0));
3457        assert_eq!((press.x, press.y), (120.0, 80.0));
3458    }
3459
3460    #[test]
3461    fn pointer_down_on_a_wrong_typed_dataset_is_a_no_op() {
3462        let dataset = RefAny::new(0u16);
3463        let (update, _) = with_callback_info_at(cursor_at(1.0, 1.0), |info| {
3464            map_on_pointer_down(dataset.clone(), info)
3465        });
3466        assert_eq!(update, Update::DoNothing);
3467    }
3468
3469    #[test]
3470    fn pointer_move_without_an_anchor_does_not_pan() {
3471        let mut dataset = RefAny::new(cache_at(37.0, -122.0, 4.0));
3472        let (update, _) = with_callback_info_at(cursor_at(500.0, 500.0), |info| {
3473            map_on_pointer_move(dataset.clone(), info)
3474        });
3475        assert_eq!(update, Update::DoNothing);
3476        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3477        assert_eq!(cache.viewport.centre_lon_deg, -122.0);
3478        assert_eq!(cache.viewport.centre_lat_deg, 37.0);
3479    }
3480
3481    #[test]
3482    fn pointer_move_pans_by_the_exact_mercator_delta_and_re_anchors() {
3483        let mut cache = cache_at(0.0, 0.0, 2.0);
3484        cache.drag_anchor = Some(LogicalPosition::new(100.0, 100.0));
3485        let mut dataset = RefAny::new(cache);
3486        let (_, changes) = with_callback_info_at(cursor_at(150.0, 100.0), |info| {
3487            map_on_pointer_move(dataset.clone(), info)
3488        });
3489        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3490        // world_px = 256 * 2^2 = 1024 → d_lon = -50 * 360 / 1024.
3491        close(cache.viewport.centre_lon_deg, -50.0 * 360.0 / 1024.0, 1e-9);
3492        close(cache.viewport.centre_lat_deg, 0.0, 1e-9);
3493        let anchor = cache.drag_anchor.expect("anchor must follow the cursor");
3494        assert_eq!((anchor.x, anchor.y), (150.0, 100.0));
3495        drop(cache);
3496        assert!(
3497            changes
3498                .iter()
3499                .any(|c| matches!(c, CallbackChange::UpdateAllVirtualViews)),
3500            "a pan must request a virtual-view re-render"
3501        );
3502    }
3503
3504    #[test]
3505    fn pointer_move_ignores_sub_half_pixel_jitter() {
3506        let mut cache = cache_at(10.0, 20.0, 4.0);
3507        cache.drag_anchor = Some(LogicalPosition::new(100.0, 100.0));
3508        let mut dataset = RefAny::new(cache);
3509        let (update, _) = with_callback_info_at(cursor_at(100.4, 99.7), |info| {
3510            map_on_pointer_move(dataset.clone(), info)
3511        });
3512        assert_eq!(update, Update::DoNothing);
3513        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3514        assert_eq!(cache.viewport.centre_lon_deg, 20.0, "jitter must not pan");
3515        assert_eq!(cache.viewport.centre_lat_deg, 10.0);
3516    }
3517
3518    #[test]
3519    fn pointer_move_with_a_nan_viewport_does_not_panic() {
3520        let mut cache = MapTileCache::new(layer_zoom(0, 19), view(f64::NAN, f64::NAN, f32::NAN));
3521        cache.drag_anchor = Some(LogicalPosition::new(0.0, 0.0));
3522        let mut dataset = RefAny::new(cache);
3523        let (update, _) = with_callback_info_at(cursor_at(400.0, 400.0), |info| {
3524            map_on_pointer_move(dataset.clone(), info)
3525        });
3526        assert_eq!(update, Update::DoNothing);
3527        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3528        assert!(cache.viewport.centre_lon_deg.is_nan());
3529    }
3530
3531    #[test]
3532    fn pointer_move_fires_the_viewport_hook_once_per_pan() {
3533        let mut log = RefAny::new(HookLog::default());
3534        let mut cache = cache_at(0.0, 0.0, 4.0);
3535        cache.drag_anchor = Some(LogicalPosition::new(0.0, 0.0));
3536        cache.on_viewport_changed = OptionMapViewportChanged::Some(MapViewportChanged {
3537            refany: log.clone(),
3538            callback: (record_viewport as MapViewportChangedCallbackType).into(),
3539        });
3540        let dataset = RefAny::new(cache);
3541        let _ = with_callback_info_at(cursor_at(60.0, 60.0), |info| {
3542            map_on_pointer_move(dataset.clone(), info)
3543        });
3544        assert_eq!(hook_log(&mut log), (1, 0));
3545    }
3546
3547    #[test]
3548    fn pointer_up_clears_every_gesture_anchor() {
3549        let mut cache = cache_at(0.0, 0.0, 4.0);
3550        cache.drag_anchor = Some(LogicalPosition::new(5.0, 5.0));
3551        cache.pinch_anchor = Some(120.0);
3552        cache.press_origin = Some(LogicalPosition::new(5.0, 5.0));
3553        let mut dataset = RefAny::new(cache);
3554        let (update, _) = with_callback_info_at(cursor_at(5.0, 5.0), |info| {
3555            map_on_pointer_up(dataset.clone(), info)
3556        });
3557        assert_eq!(update, Update::DoNothing);
3558        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3559        assert!(cache.drag_anchor.is_none());
3560        assert!(cache.pinch_anchor.is_none());
3561        assert!(cache.press_origin.is_none());
3562    }
3563
3564    #[test]
3565    fn pointer_up_fires_pin_tap_for_a_tap_but_not_for_a_drag() {
3566        // A release within 6px of the press point is a tap.
3567        let mut log = RefAny::new(HookLog::default());
3568        let mut cache = cache_at(0.0, 0.0, 4.0);
3569        cache.press_origin = Some(LogicalPosition::new(10.0, 10.0));
3570        cache.on_pin_tap = OptionMapPinTap::Some(MapPinTap {
3571            refany: log.clone(),
3572            callback: (record_pin as MapPinTapCallbackType).into(),
3573        });
3574        let dataset = RefAny::new(cache);
3575        let _ = with_callback_info_at(cursor_at(12.0, 12.0), |info| {
3576            map_on_pointer_up(dataset.clone(), info)
3577        });
3578        assert_eq!(hook_log(&mut log), (0, 1), "a 2px release is a tap");
3579
3580        // A release 90px away is a drag, not a tap.
3581        let mut log = RefAny::new(HookLog::default());
3582        let mut cache = cache_at(0.0, 0.0, 4.0);
3583        cache.press_origin = Some(LogicalPosition::new(10.0, 10.0));
3584        cache.on_pin_tap = OptionMapPinTap::Some(MapPinTap {
3585            refany: log.clone(),
3586            callback: (record_pin as MapPinTapCallbackType).into(),
3587        });
3588        let dataset = RefAny::new(cache);
3589        let _ = with_callback_info_at(cursor_at(100.0, 100.0), |info| {
3590            map_on_pointer_up(dataset.clone(), info)
3591        });
3592        assert_eq!(hook_log(&mut log), (0, 0), "a 90px release is a drag");
3593    }
3594
3595    #[test]
3596    fn pointer_up_without_a_press_origin_never_taps() {
3597        let mut log = RefAny::new(HookLog::default());
3598        let mut cache = cache_at(0.0, 0.0, 4.0);
3599        cache.on_pin_tap = OptionMapPinTap::Some(MapPinTap {
3600            refany: log.clone(),
3601            callback: (record_pin as MapPinTapCallbackType).into(),
3602        });
3603        let dataset = RefAny::new(cache);
3604        let _ = with_callback_info_at(cursor_at(10.0, 10.0), |info| {
3605            map_on_pointer_up(dataset.clone(), info)
3606        });
3607        assert_eq!(hook_log(&mut log), (0, 0));
3608    }
3609
3610    #[test]
3611    fn pointer_up_on_a_wrong_typed_dataset_is_a_no_op() {
3612        // A `MapViewport` is the most plausible mix-up: same widget, wrong payload.
3613        let dataset = RefAny::new(MapViewport::default());
3614        let (update, _) = with_callback_info_at(cursor_at(1.0, 1.0), |info| {
3615            map_on_pointer_up(dataset.clone(), info)
3616        });
3617        assert_eq!(update, Update::DoNothing);
3618    }
3619
3620    #[test]
3621    fn scroll_without_a_wheel_delta_is_a_no_op() {
3622        // The harness has no hit node, so `get_scroll_delta` yields 0 - the
3623        // handler must bail before touching the viewport.
3624        let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
3625        let (update, changes) = with_callback_info(|info| map_on_scroll(dataset.clone(), info));
3626        assert_eq!(update, Update::DoNothing);
3627        assert!(changes.is_empty(), "a zero-delta scroll must queue nothing");
3628        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3629        assert_eq!(cache.viewport.zoom, 4.0);
3630        assert!(cache.tiles.is_empty());
3631    }
3632
3633    #[test]
3634    fn scroll_on_a_wrong_typed_dataset_is_a_no_op() {
3635        let dataset = RefAny::new(0u8);
3636        let (update, _) = with_callback_info(|info| map_on_scroll(dataset.clone(), info));
3637        assert_eq!(update, Update::DoNothing);
3638    }
3639
3640    // ==================================================================
3641    // Fetch spawning + writeback
3642    // ==================================================================
3643
3644    #[test]
3645    fn spawn_pending_tile_fetches_is_a_no_op_without_a_worker() {
3646        let mut cache = cache_at(0.0, 0.0, 4.0);
3647        for x in 0..4u32 {
3648            cache.tiles.insert(MapTileId { z: 4, x, y: 8 }, TileEntry::Pending);
3649        }
3650        let mut dataset = RefAny::new(cache);
3651        let (_, changes) = with_callback_info(|info| {
3652            let mut info = info;
3653            spawn_pending_tile_fetches(&mut dataset.clone(), &mut info);
3654        });
3655        assert!(changes.is_empty(), "no worker → no threads queued");
3656        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3657        assert!(
3658            cache.tiles.values().all(|e| matches!(e, TileEntry::Pending)),
3659            "tiles must stay Pending so the placeholder grid renders"
3660        );
3661    }
3662
3663    #[test]
3664    fn spawn_pending_tile_fetches_caps_the_burst_at_sixteen() {
3665        let mut cache = cache_at(0.0, 0.0, 4.0);
3666        cache.fetch_callback = Some(ThreadCallback::new(noop_worker));
3667        for x in 0..20u32 {
3668            cache.tiles.insert(MapTileId { z: 4, x, y: 8 }, TileEntry::Pending);
3669        }
3670        let mut dataset = RefAny::new(cache);
3671
3672        // `(fetching, pending)` counts, as a plain fn so the two call sites
3673        // don't share one inferred closure borrow.
3674        fn count_states(ds: &mut RefAny) -> (usize, usize) {
3675            let cache = ds.downcast_ref::<MapTileCache>().expect("cache");
3676            let fetching = cache
3677                .tiles
3678                .values()
3679                .filter(|e| matches!(e, TileEntry::Fetching))
3680                .count();
3681            let pending = cache
3682                .tiles
3683                .values()
3684                .filter(|e| matches!(e, TileEntry::Pending))
3685                .count();
3686            (fetching, pending)
3687        }
3688
3689        let (_, changes) = with_callback_info(|info| {
3690            let mut info = info;
3691            spawn_pending_tile_fetches(&mut dataset.clone(), &mut info);
3692        });
3693        assert_eq!(count_states(&mut dataset), (16, 4), "one call spawns at most 16");
3694        assert_eq!(
3695            changes
3696                .iter()
3697                .filter(|c| matches!(c, CallbackChange::AddThread { .. }))
3698                .count(),
3699            16
3700        );
3701
3702        // The second call drains the remainder - the cap bounds a burst, it
3703        // does not drop work.
3704        let _ = with_callback_info(|info| {
3705            let mut info = info;
3706            spawn_pending_tile_fetches(&mut dataset.clone(), &mut info);
3707        });
3708        assert_eq!(count_states(&mut dataset), (20, 0));
3709    }
3710
3711    #[test]
3712    fn spawn_pending_tile_fetches_on_a_wrong_typed_dataset_is_a_no_op() {
3713        let mut dataset = RefAny::new(1234u32);
3714        let (_, changes) = with_callback_info(|info| {
3715            let mut info = info;
3716            spawn_pending_tile_fetches(&mut dataset, &mut info);
3717        });
3718        assert!(changes.is_empty());
3719    }
3720
3721    #[test]
3722    fn tile_writeback_marks_ready_on_an_empty_error_and_failed_otherwise() {
3723        let tile = MapTileId { z: 4, x: 1, y: 2 };
3724        let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
3725
3726        let ok = RefAny::new(TileReadyMsg {
3727            tile,
3728            svg: AzString::from("<svg/>"),
3729            error: AzString::from(""),
3730        });
3731        let (update, changes) = with_callback_info(|info| {
3732            map_tile_writeback(dataset.clone(), ok.clone(), info)
3733        });
3734        assert_eq!(update, Update::DoNothing);
3735        assert!(changes
3736            .iter()
3737            .any(|c| matches!(c, CallbackChange::UpdateAllVirtualViews)));
3738        {
3739            let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3740            assert!(matches!(cache.tiles.get(&tile), Some(TileEntry::Ready { .. })));
3741        }
3742
3743        let failed = RefAny::new(TileReadyMsg {
3744            tile,
3745            svg: AzString::from(""),
3746            error: AzString::from("404"),
3747        });
3748        let (update, _) = with_callback_info(|info| {
3749            map_tile_writeback(dataset.clone(), failed.clone(), info)
3750        });
3751        assert_eq!(update, Update::DoNothing);
3752        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3753        assert!(matches!(cache.tiles.get(&tile), Some(TileEntry::Failed { .. })));
3754    }
3755
3756    #[test]
3757    fn tile_writeback_accepts_a_huge_payload_and_an_out_of_range_id() {
3758        let tile = MapTileId {
3759            z: 31,
3760            x: u32::MAX,
3761            y: u32::MAX,
3762        };
3763        let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
3764        let msg = RefAny::new(TileReadyMsg {
3765            tile,
3766            svg: AzString::from("<svg/>".repeat(50_000)),
3767            error: AzString::from(""),
3768        });
3769        let (update, _) =
3770            with_callback_info(|info| map_tile_writeback(dataset.clone(), msg.clone(), info));
3771        assert_eq!(update, Update::DoNothing);
3772        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3773        assert!(cache.tiles.contains_key(&tile));
3774    }
3775
3776    #[test]
3777    fn tile_writeback_with_a_wrong_typed_message_is_a_no_op() {
3778        let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
3779        let (update, changes) = with_callback_info(|info| {
3780            map_tile_writeback(dataset.clone(), RefAny::new(0u32), info)
3781        });
3782        assert_eq!(update, Update::DoNothing);
3783        assert!(changes.is_empty(), "a bogus message must not force a re-render");
3784        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3785        assert!(cache.tiles.is_empty());
3786    }
3787
3788    #[test]
3789    fn tile_writeback_with_a_wrong_typed_cache_is_a_no_op() {
3790        let msg = RefAny::new(TileReadyMsg {
3791            tile: MapTileId { z: 1, x: 0, y: 0 },
3792            svg: AzString::from("<svg/>"),
3793            error: AzString::from(""),
3794        });
3795        let (update, changes) = with_callback_info(|info| {
3796            map_tile_writeback(RefAny::new(9u64), msg.clone(), info)
3797        });
3798        assert_eq!(update, Update::DoNothing);
3799        assert!(changes.is_empty());
3800    }
3801
3802    #[test]
3803    fn after_mount_installs_the_sweep_timer_and_asks_for_a_re_render() {
3804        let dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
3805        let (update, changes) =
3806            with_callback_info(|info| map_on_after_mount(dataset.clone(), info));
3807        assert_eq!(update, Update::DoNothing);
3808        assert_eq!(
3809            changes
3810                .iter()
3811                .filter(|c| matches!(c, CallbackChange::AddTimer { .. }))
3812                .count(),
3813            1,
3814            "exactly one sweep timer per mount"
3815        );
3816        assert!(changes
3817            .iter()
3818            .any(|c| matches!(c, CallbackChange::UpdateAllVirtualViews)));
3819    }
3820
3821    #[test]
3822    fn after_mount_on_a_wrong_typed_dataset_still_installs_the_timer() {
3823        // The timer is unconditional; only the fetch spawn depends on the cache.
3824        let dataset = RefAny::new(0u8);
3825        let (update, changes) =
3826            with_callback_info(|info| map_on_after_mount(dataset.clone(), info));
3827        assert_eq!(update, Update::DoNothing);
3828        assert!(changes
3829            .iter()
3830            .any(|c| matches!(c, CallbackChange::AddTimer { .. })));
3831    }
3832
3833    // ==================================================================
3834    // map_widget_render  (VirtualView callback)
3835    // ==================================================================
3836
3837    #[test]
3838    fn render_with_non_finite_or_empty_bounds_emits_no_dom() {
3839        let dataset = RefAny::new(cache_at(0.0, 0.0, 3.0));
3840        for (w, h) in [
3841            (0.0_f32, 0.0_f32),
3842            (0.0, 600.0),
3843            (800.0, 0.0),
3844            (-800.0, -600.0),
3845            (f32::NAN, 600.0),
3846            (800.0, f32::NAN),
3847            (f32::INFINITY, 600.0),
3848            (800.0, f32::NEG_INFINITY),
3849        ] {
3850            let ret =
3851                with_virtual_view_info(w, h, |info| map_widget_render(dataset.clone(), info));
3852            assert!(
3853                rendered_child_count(&ret).is_none(),
3854                "bounds {w}x{h} must render nothing until layout settles"
3855            );
3856        }
3857    }
3858
3859    #[test]
3860    fn render_with_a_wrong_typed_dataset_emits_no_dom() {
3861        let dataset = RefAny::new(0u32);
3862        let ret =
3863            with_virtual_view_info(800.0, 600.0, |info| map_widget_render(dataset.clone(), info));
3864        assert!(rendered_child_count(&ret).is_none());
3865    }
3866
3867    #[test]
3868    fn render_marks_every_visible_tile_pending_and_emits_one_div_each() {
3869        let mut dataset = RefAny::new(cache_at(0.0, 0.0, 2.0));
3870        let expected = map_visible_tiles(
3871            &view(0.0, 0.0, 2.0),
3872            LogicalSize::new(800.0, 600.0),
3873            &layer_zoom(0, 19),
3874        );
3875        let ret =
3876            with_virtual_view_info(800.0, 600.0, |info| map_widget_render(dataset.clone(), info));
3877        assert_eq!(
3878            rendered_child_count(&ret),
3879            Some(expected.len()),
3880            "one div per visible tile"
3881        );
3882        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
3883        for tile in &expected {
3884            assert!(
3885                matches!(cache.tiles.get(tile), Some(TileEntry::Pending)),
3886                "tile {tile:?} must be queued by the render pass"
3887            );
3888        }
3889    }
3890
3891    #[test]
3892    fn render_reports_the_bounds_back_as_the_scroll_size() {
3893        let dataset = RefAny::new(cache_at(0.0, 0.0, 2.0));
3894        let ret =
3895            with_virtual_view_info(640.0, 480.0, |info| map_widget_render(dataset.clone(), info));
3896        assert_eq!(ret.scroll_size.width, 640.0);
3897        assert_eq!(ret.scroll_size.height, 480.0);
3898        assert_eq!(ret.virtual_scroll_size.width, 640.0);
3899        assert_eq!(ret.virtual_scroll_size.height, 480.0);
3900        assert_eq!((ret.scroll_offset.x, ret.scroll_offset.y), (0.0, 0.0));
3901        assert_eq!(
3902            (ret.virtual_scroll_offset.x, ret.virtual_scroll_offset.y),
3903            (0.0, 0.0)
3904        );
3905    }
3906
3907    #[test]
3908    fn render_falls_back_to_a_glyph_when_a_ready_tile_holds_garbage() {
3909        // A worker can hand back anything; an unparseable payload must degrade
3910        // to the placeholder text child, not panic the render pass.
3911        let mut cache = cache_at(0.0, 0.0, 2.0);
3912        for tile in map_visible_tiles(
3913            &view(0.0, 0.0, 2.0),
3914            LogicalSize::new(512.0, 512.0),
3915            &layer_zoom(0, 19),
3916        ) {
3917            cache.mark_tile_ready(tile, AzString::from("not xml at all <<<"));
3918        }
3919        let dataset = RefAny::new(cache);
3920        let ret =
3921            with_virtual_view_info(512.0, 512.0, |info| map_widget_render(dataset.clone(), info));
3922        assert!(
3923            rendered_child_count(&ret).is_some_and(|n| n > 0),
3924            "garbage tiles still render their placeholder"
3925        );
3926    }
3927
3928    #[test]
3929    fn render_handles_mixed_tile_states_including_failures() {
3930        let mut cache = cache_at(0.0, 0.0, 2.0);
3931        let tiles = map_visible_tiles(
3932            &view(0.0, 0.0, 2.0),
3933            LogicalSize::new(512.0, 512.0),
3934            &layer_zoom(0, 19),
3935        );
3936        for (i, tile) in tiles.iter().enumerate() {
3937            match i % 4 {
3938                0 => {
3939                    cache.tiles.insert(*tile, TileEntry::Pending);
3940                }
3941                1 => {
3942                    cache.tiles.insert(*tile, TileEntry::Fetching);
3943                }
3944                2 => cache.mark_tile_failed(*tile, AzString::from("\u{1F600} failed")),
3945                _ => cache.mark_tile_ready(*tile, AzString::from("")),
3946            }
3947        }
3948        let dataset = RefAny::new(cache);
3949        let ret =
3950            with_virtual_view_info(512.0, 512.0, |info| map_widget_render(dataset.clone(), info));
3951        assert_eq!(rendered_child_count(&ret), Some(tiles.len()));
3952    }
3953
3954    #[test]
3955    fn render_clamps_an_out_of_band_zoom_and_stays_bounded() {
3956        // A single-zoom layer with a viewport far outside it: the grid must
3957        // collapse onto the one supported zoom, not enumerate a huge range.
3958        //
3959        // NOTE: `min_zoom > max_zoom` is deliberately NOT exercised here - the
3960        // `i32::clamp(min, max)` in `map_widget_render` panics on an inverted
3961        // band (see the report accompanying these tests).
3962        for zoom in [0.0_f32, 1.0, 3.0, 25.0, f32::INFINITY] {
3963            let cache = MapTileCache::new(layer_zoom(3, 3), view(0.0, 0.0, zoom));
3964            let dataset = RefAny::new(cache);
3965            let ret = with_virtual_view_info(800.0, 600.0, |info| {
3966                map_widget_render(dataset.clone(), info)
3967            });
3968            let n = rendered_child_count(&ret).expect("a finite box must render");
3969            assert!(n > 0 && n < 4096, "zoom {zoom} produced {n} tiles");
3970        }
3971    }
3972
3973    #[test]
3974    fn render_is_stable_across_repeated_invocations() {
3975        let dataset = RefAny::new(cache_at(48.1372, 11.5756, 5.0));
3976        let first =
3977            with_virtual_view_info(800.0, 600.0, |info| map_widget_render(dataset.clone(), info));
3978        let second =
3979            with_virtual_view_info(800.0, 600.0, |info| map_widget_render(dataset.clone(), info));
3980        assert_eq!(rendered_child_count(&first), rendered_child_count(&second));
3981    }
3982}