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

            
39
use alloc::collections::btree_map::BTreeMap;
40

            
41
use azul_core::callbacks::{
42
    VirtualViewCallback, VirtualViewCallbackInfo, VirtualViewReturn,
43
};
44
use azul_core::dom::{DatasetMergeCallbackType, Dom, OptionDom};
45
use azul_core::refany::{OptionRefAny, RefAny};
46
use azul_css::dynamic_selector::CssPropertyWithConditionsVec;
47
use azul_css::impl_option_inner; // for impl_widget_callback!'s impl_option!
48
use 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)]
56
pub 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)]
71
pub 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

            
91
impl Default for MapTileLayer {
92
110
    fn default() -> Self {
93
110
        Self {
94
110
            // OpenFreeMap's public planet vector tiles (full-detail OSM, z0–14, no
95
110
            // API key). The tile path is VERSIONED by planet-build date — the
96
110
            // unversioned `/planet/{z}/{x}/{y}.pbf` returns empty tiles. The version
97
110
            // below is the current build from the TileJSON at
98
110
            // `https://tiles.openfreemap.org/planet` (`tiles[0]`); when OpenFreeMap
99
110
            // rebuilds the planet this goes stale, so the proper long-term path is to
100
110
            // resolve it on the background thread by fetching that TileJSON first (a
101
110
            // follow-up to the Leaflet-style layer work). Raster relief is also
102
110
            // available at `…/natural_earth/ne2sr/{z}/{x}/{y}.png` (z0–6).
103
110
            url_template: AzString::from(
104
110
                "https://tiles.openfreemap.org/planet/20260531_080002_pt/{z}/{x}/{y}.pbf",
105
110
            ),
106
110
            min_zoom: 0,
107
110
            max_zoom: 14,
108
110
            attribution: AzString::from(
109
110
                "© OpenFreeMap © OpenMapTiles · Data © OpenStreetMap contributors",
110
110
            ),
111
110
            style_css: AzString::from(""),
112
110
        }
113
110
    }
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)]
121
pub 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

            
129
impl Default for MapViewport {
130
174
    fn default() -> Self {
131
        // A neutral "whole world, slightly zoomed in" default. Apps
132
        // care will replace this immediately.
133
174
        Self {
134
174
            centre_lat_deg: 0.0,
135
174
            centre_lon_deg: 0.0,
136
174
            zoom: 2.0,
137
174
            bearing_deg: 0.0,
138
174
            pitch_deg: 0.0,
139
174
        }
140
174
    }
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)]
147
pub 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)]
161
pub 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

            
173
impl MapWidget {
174
36
    #[must_use] pub fn create(layer: MapTileLayer) -> Self {
175
36
        Self {
176
36
            layer,
177
36
            viewport: MapViewport::default(),
178
36
            container_style: CssPropertyWithConditionsVec::from_const_slice(&[]),
179
36
            on_viewport_changed: OptionMapViewportChanged::None,
180
36
            on_pin_tap: OptionMapPinTap::None,
181
36
        }
182
36
    }
183

            
184
17
    #[must_use] pub const fn with_viewport(mut self, viewport: MapViewport) -> Self {
185
17
        self.viewport = viewport;
186
17
        self
187
17
    }
188

            
189
12
    #[must_use] pub fn with_container_style(mut self, css: CssPropertyWithConditionsVec) -> Self {
190
12
        self.container_style = css;
191
12
        self
192
12
    }
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
3
    pub fn set_on_viewport_changed<C: Into<MapViewportChangedCallback>>(
198
3
        &mut self,
199
3
        data: RefAny,
200
3
        callback: C,
201
3
    ) {
202
3
        self.on_viewport_changed = Some(MapViewportChanged {
203
3
            refany: data,
204
3
            callback: callback.into(),
205
3
        })
206
3
        .into();
207
3
    }
208

            
209
    /// Builder form of [`set_on_viewport_changed`](Self::set_on_viewport_changed).
210
    #[must_use]
211
2
    pub fn with_on_viewport_changed<C: Into<MapViewportChangedCallback>>(
212
2
        mut self,
213
2
        data: RefAny,
214
2
        callback: C,
215
2
    ) -> Self {
216
2
        self.set_on_viewport_changed(data, callback);
217
2
        self
218
2
    }
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
3
    pub fn set_on_pin_tap<C: Into<MapPinTapCallback>>(&mut self, data: RefAny, callback: C) {
224
3
        self.on_pin_tap = Some(MapPinTap {
225
3
            refany: data,
226
3
            callback: callback.into(),
227
3
        })
228
3
        .into();
229
3
    }
230

            
231
    /// Builder form of [`set_on_pin_tap`](Self::set_on_pin_tap).
232
    #[must_use]
233
2
    pub fn with_on_pin_tap<C: Into<MapPinTapCallback>>(
234
2
        mut self,
235
2
        data: RefAny,
236
2
        callback: C,
237
2
    ) -> Self {
238
2
        self.set_on_pin_tap(data, callback);
239
2
        self
240
2
    }
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
32
    #[must_use] pub fn latlon_at_px(
249
32
        viewport: MapViewport,
250
32
        px: azul_core::geom::LogicalPosition,
251
32
        container: azul_core::geom::LogicalSize,
252
32
    ) -> MapLatLon {
253
32
        let world = 256.0_f64 * 2.0_f64.powf(f64::from(viewport.zoom));
254
32
        let dx = f64::from(px.x - container.width * 0.5);
255
32
        let dy = f64::from(px.y - container.height * 0.5);
256
32
        let lon = (viewport.centre_lon_deg + dx * 360.0 / world).clamp(-180.0, 180.0);
257
32
        let cos_lat = viewport.centre_lat_deg.to_radians().cos();
258
32
        let lat = (viewport.centre_lat_deg - dy * 360.0 / world * cos_lat).clamp(-85.0, 85.0);
259
32
        MapLatLon {
260
32
            lat_deg: lat,
261
32
            lon_deg: lon,
262
32
        }
263
32
    }
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
12
    #[must_use] pub fn px_at_latlon(
270
12
        viewport: MapViewport,
271
12
        coord: MapLatLon,
272
12
        container: azul_core::geom::LogicalSize,
273
12
    ) -> azul_core::geom::LogicalPosition {
274
12
        let world = 256.0_f64 * 2.0_f64.powf(f64::from(viewport.zoom));
275
12
        let cos_lat = viewport.centre_lat_deg.to_radians().cos();
276
12
        let px = f64::from(container.width) * 0.5
277
12
            + (coord.lon_deg - viewport.centre_lon_deg) * world / 360.0;
278
12
        let py = f64::from(container.height) * 0.5
279
12
            - (coord.lat_deg - viewport.centre_lat_deg) * world / (360.0 * cos_lat);
280
12
        azul_core::geom::LogicalPosition::new(px as f32, py as f32)
281
12
    }
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
    ///
299
    /// The FFI `MapWidget::dom()` does NOT land here: api.json routes it to
300
    /// `azul_dll::unified::map::map_widget_dom`, which calls `dom_with_fetch`
301
    /// with the built-in worker. It used to route here, which is why the map
302
    /// panned but never painted a tile on every desktop platform.
303
89
    #[must_use] pub fn dom(self) -> Dom {
304
89
        self.build_dom(None)
305
89
    }
306

            
307
    /// Like [`dom`](Self::dom), but wires a tile-fetch worker thread.
308
    /// `cb` runs on a framework `Thread` per visible tile: it reads the
309
    /// `TileFetchInit`, fetches + decodes, then
310
    /// `sender.send(ThreadReceiveMsg::WriteBack(...))` a `TileReadyMsg`
311
    /// targeting `map_tile_writeback`. The standard worker is
312
    /// `azul_dll::desktop::extra::map::tile_fetch_worker`; wrap it in a
313
    /// `ThreadCallback` to pass it here. See the recipe in
314
    /// `MOBILE_SESSION_LOG.md`.
315
1
    #[must_use] pub fn dom_with_fetch(self, cb: crate::thread::ThreadCallback) -> Dom {
316
1
        self.build_dom(Some(cb))
317
1
    }
318

            
319
90
    fn build_dom(self, fetch_cb: Option<crate::thread::ThreadCallback>) -> Dom {
320
        use azul_core::dom::{ComponentEventFilter, EventFilter, HoverEventFilter};
321

            
322
90
        let mut cache = MapTileCache::new(self.layer.clone(), self.viewport);
323
90
        cache.fetch_callback = fetch_cb;
324
90
        cache.on_viewport_changed = self.on_viewport_changed;
325
90
        cache.on_pin_tap = self.on_pin_tap;
326
90
        let dataset = RefAny::new(cache);
327
90
        let virtual_view_data = dataset.clone();
328

            
329
90
        let root = Dom::create_div()
330
            // Fill the container (the Leaflet contract) via absolute inset:0 rather
331
            // than height:100%. A percentage height only resolves against a parent
332
            // with a DEFINITE height; the usual map container is a `flex-grow` item
333
            // whose height is not definite for percentage children, so height:100%
334
            // there resolves to INFINITY → the VirtualView gets infinite bounds and
335
            // positions every tile at y=∞ (off-screen → blank map). Absolute inset:0
336
            // instead sizes against the container's final, finite content box. The
337
            // container MUST be a positioned box (the demo's `position: relative`);
338
            // a non-empty `container_style` (via `with_container_style`) overrides.
339
90
            .with_css("position: absolute; top: 0; left: 0; right: 0; bottom: 0; overflow: hidden;")
340
90
            .with_dataset(OptionRefAny::Some(dataset.clone()))
341
90
            .with_merge_callback(azul_core::dom::DatasetMergeCallback::from_ptr(merge_map_tile_cache))
342
            // AfterMount fires once when the widget first appears (and
343
            // again after a DOM-structure change re-mounts it). It's the
344
            // earliest point with a `CallbackInfo`, so we kick the
345
            // initial tile fetches here — without it the first frame's
346
            // tiles would stay `Pending` until the user panned/tapped.
347
90
            .with_callback(
348
90
                EventFilter::Component(ComponentEventFilter::AfterMount),
349
90
                dataset.clone(),
350
90
                crate::callbacks::Callback::from_ptr(map_on_after_mount),
351
            )
352
90
            .with_callback(
353
90
                EventFilter::Hover(HoverEventFilter::MouseDown),
354
90
                dataset.clone(),
355
90
                crate::callbacks::Callback::from_ptr(map_on_pointer_down),
356
            )
357
90
            .with_callback(
358
90
                EventFilter::Hover(HoverEventFilter::MouseOver),
359
90
                dataset.clone(),
360
90
                crate::callbacks::Callback::from_ptr(map_on_pointer_move),
361
            )
362
90
            .with_callback(
363
90
                EventFilter::Hover(HoverEventFilter::MouseUp),
364
90
                dataset.clone(),
365
90
                crate::callbacks::Callback::from_ptr(map_on_pointer_up),
366
            )
367
90
            .with_callback(
368
90
                EventFilter::Hover(HoverEventFilter::MouseLeave),
369
90
                dataset.clone(),
370
90
                crate::callbacks::Callback::from_ptr(map_on_pointer_up),
371
            )
372
90
            .with_callback(
373
90
                EventFilter::Hover(HoverEventFilter::TouchStart),
374
90
                dataset.clone(),
375
90
                crate::callbacks::Callback::from_ptr(map_on_pointer_down),
376
            )
377
90
            .with_callback(
378
90
                EventFilter::Hover(HoverEventFilter::TouchMove),
379
90
                dataset.clone(),
380
90
                crate::callbacks::Callback::from_ptr(map_on_pointer_move),
381
            )
382
90
            .with_callback(
383
90
                EventFilter::Hover(HoverEventFilter::TouchEnd),
384
90
                dataset.clone(),
385
90
                crate::callbacks::Callback::from_ptr(map_on_pointer_up),
386
            )
387
90
            .with_callback(
388
90
                EventFilter::Hover(HoverEventFilter::TouchCancel),
389
90
                dataset.clone(),
390
90
                crate::callbacks::Callback::from_ptr(map_on_pointer_up),
391
            )
392
            // Native gesture events (UIPinchGestureRecognizer on iOS,
393
            // ScaleGestureDetector on Android, NSMagnificationGestureRecognizer
394
            // on macOS) — fire through the same map_on_pointer_move handler
395
            // which reads `info.get_pinch()` and applies the zoom delta.
396
90
            .with_callback(
397
90
                EventFilter::Hover(HoverEventFilter::PinchIn),
398
90
                dataset.clone(),
399
90
                crate::callbacks::Callback::from_ptr(map_on_pointer_move),
400
            )
401
90
            .with_callback(
402
90
                EventFilter::Hover(HoverEventFilter::PinchOut),
403
90
                dataset,
404
90
                crate::callbacks::Callback::from_ptr(map_on_pointer_move),
405
            )
406
90
            .with_child(
407
90
                Dom::create_virtual_view(
408
90
                    virtual_view_data,
409
90
                    azul_core::callbacks::VirtualViewCallback::create(map_widget_render),
410
                )
411
                // Fill the widget div with a PERCENTAGE box (not absolute). The
412
                // outer div above is absolutely sized, so its height IS definite —
413
                // height:100% here resolves against it (441px), giving the
414
                // VirtualView a finite box. (Absolute-against-absolute collapses to
415
                // 0 in the solver; percentage-against-a-definite-parent does not.)
416
90
                .with_css("width: 100%; height: 100%; overflow: hidden;"),
417
            );
418

            
419
        // A caller-supplied container style replaces the default fill above
420
        // (`with_css_props` replaces the inline style) — the caller then owns sizing.
421
90
        if self.container_style.as_slice().is_empty() {
422
89
            root
423
        } else {
424
1
            root.with_css_props(self.container_style)
425
        }
426
90
    }
427
}
428

            
429
// ────────── Tile cache (dataset RefAny payload) ───────────────────────
430

            
431
#[derive(Debug)]
432
pub struct MapTileCache {
433
    pub layer: MapTileLayer,
434
    pub viewport: MapViewport,
435
    /// `Ready(svg)` once the tile has been fetched + decoded;
436
    /// `Pending` while queued, `Fetching` while a worker thread is
437
    /// in flight; absent otherwise. `BTreeMap` for deterministic
438
    /// iteration so the debug log + e2e snapshots are stable.
439
    pub tiles: BTreeMap<MapTileId, TileEntry>,
440
    /// Worker thread entry point that fetches + decodes one tile.
441
    /// Supplied by `MapWidget::dom_with_fetch` (the caller, usually
442
    /// `azul_dll`'s map-tiles glue, provides this because the MVT
443
    /// decoder lives in `azul-dll`, which `azul-layout` can't depend
444
    /// on). `None` means "no fetch wired": tiles stay `Pending` and
445
    /// the placeholder grid renders. The merge callback carries this
446
    /// across relayout. Held as the `ThreadCallback` wrapper (not the
447
    /// raw fn pointer) so it round-trips through the FFI codegen.
448
    pub fetch_callback: Option<crate::thread::ThreadCallback>,
449
    /// Pixel coordinates of the cursor at the last mouse-down /
450
    /// touch-down on the widget. `Some` while a drag is in flight,
451
    /// `None` between drags. The framework consults this on every
452
    /// mouse-move to derive the pixel delta, which then converts to a
453
    /// lat/lon delta via the Web Mercator inverse.
454
    pub drag_anchor: Option<azul_core::geom::LogicalPosition>,
455
    /// Pinch reference distance (pixels) - the two-finger separation
456
    /// the last time a pinch event was observed for this widget.
457
    /// `Some` while a pinch is in flight, `None` between gestures.
458
    /// On each subsequent pinch update we compute
459
    /// `dz = log2(current_distance / pinch_anchor)` and add it to
460
    /// `viewport.zoom`, then reset the anchor to the current
461
    /// distance - so the gesture stays continuous across many frames.
462
    pub pinch_anchor: Option<f32>,
463
    /// The user's `on_viewport_changed` hook, copied here from the builder
464
    /// so the pan / pinch callbacks can fire it. Carried across relayout.
465
    pub on_viewport_changed: OptionMapViewportChanged,
466
    /// Pixel position of the last pointer-down (the original press point, not
467
    /// overwritten by pan moves). Used to tell a tap from a drag in pointer-up.
468
    pub press_origin: Option<azul_core::geom::LogicalPosition>,
469
    /// The user's `on_pin_tap` hook, copied from the builder so pointer-up can
470
    /// fire it. Carried across relayout.
471
    pub on_pin_tap: OptionMapPinTap,
472
}
473

            
474
impl MapTileCache {
475
147
    #[must_use] pub const fn new(layer: MapTileLayer, viewport: MapViewport) -> Self {
476
147
        Self {
477
147
            layer,
478
147
            viewport,
479
147
            tiles: BTreeMap::new(),
480
147
            fetch_callback: None,
481
147
            drag_anchor: None,
482
147
            pinch_anchor: None,
483
147
            press_origin: None,
484
147
            on_viewport_changed: OptionMapViewportChanged::None,
485
147
            on_pin_tap: OptionMapPinTap::None,
486
147
        }
487
147
    }
488

            
489
    /// Worker-thread → main-thread write path. Set the decoded SVG for
490
    /// a tile (called from `map_tile_writeback`). Stamps `Ready`.
491
35
    pub fn mark_tile_ready(&mut self, tile: MapTileId, svg: AzString) {
492
35
        self.tiles.insert(tile, TileEntry::Ready { svg });
493
35
    }
494

            
495
    /// Mark a tile's fetch as failed so the grid doesn't re-spawn it
496
    /// every frame.
497
10
    pub fn mark_tile_failed(&mut self, tile: MapTileId, error: AzString) {
498
10
        self.tiles.insert(tile, TileEntry::Failed { error });
499
10
    }
500

            
501
    /// Bound the tile cache by evicting tiles far from the current viewport.
502
    ///
503
    /// Without this, `tiles` grows without limit - panning across the world or
504
    /// zooming in and out keeps every tile ever fetched (each decoded SVG is
505
    /// tens-to-hundreds of KB), so a long session leaks memory. Called after a
506
    /// viewport change once the new view's tiles are queued.
507
    ///
508
    /// Eviction is viewport-distance based (the right policy for spatial data,
509
    /// stronger than plain LRU): each tile is scored by zoom mismatch + squared
510
    /// distance from the viewport centre (projected into the current zoom's tile
511
    /// space), and the farthest are dropped first. IN-FLIGHT tiles
512
    /// (`Pending`/`Fetching`) are never evicted (their worker would write into a
513
    /// gone entry), and on-screen tiles score near-zero so they survive.
514
    #[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
515
    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded layout/render numeric cast
516
14
    pub fn prune_distant_tiles(&mut self) {
517
        const MAX_CACHED_TILES: usize = 192;
518
14
        if self.tiles.len() <= MAX_CACHED_TILES {
519
4
            return;
520
10
        }
521

            
522
10
        let z = (self.viewport.zoom.floor() as i32)
523
10
            .clamp(i32::from(self.layer.min_zoom), i32::from(self.layer.max_zoom))
524
10
            as u8;
525
10
        let tile_count = 1u32 << u32::from(z);
526
10
        let cx = lon_to_tile_x(self.viewport.centre_lon_deg, f64::from(tile_count));
527
10
        let cy = lat_to_tile_y(self.viewport.centre_lat_deg, f64::from(tile_count));
528

            
529
        // Higher score = evict sooner.
530
2448
        let score = |id: &MapTileId| -> f64 {
531
2448
            let zt_count = 1u32 << u32::from(id.z);
532
            // Project the tile's centre into the CURRENT zoom's tile space so
533
            // distances across zoom levels are comparable.
534
2448
            let scale = f64::from(tile_count) / f64::from(zt_count);
535
2448
            let tx = (f64::from(id.x) + 0.5) * scale;
536
2448
            let ty = (f64::from(id.y) + 0.5) * scale;
537
2448
            let dz = f64::from((i32::from(id.z) - i32::from(z)).abs());
538
2448
            let dx = tx - cx;
539
2448
            let dy = ty - cy;
540
2448
            dz * 10_000.0 + dx * dx + dy * dy
541
2448
        };
542

            
543
10
        let mut evictable: Vec<(f64, MapTileId)> = self
544
10
            .tiles
545
10
            .iter()
546
2705
            .filter(|(_, e)| !matches!(e, TileEntry::Pending | TileEntry::Fetching))
547
2448
            .map(|(id, _)| (score(id), *id))
548
10
            .collect();
549
        // Farthest first.
550
19550
        evictable.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(core::cmp::Ordering::Equal));
551

            
552
10
        let mut to_remove = self.tiles.len().saturating_sub(MAX_CACHED_TILES);
553
731
        for (_, id) in evictable {
554
730
            if to_remove == 0 {
555
9
                break;
556
721
            }
557
721
            self.tiles.remove(&id);
558
721
            to_remove -= 1;
559
        }
560
14
    }
561
}
562

            
563
#[derive(Debug, Clone)]
564
pub enum TileEntry {
565
    /// Needed by the viewport, fetch not yet spawned.
566
    Pending,
567
    /// A worker thread is fetching / decoding this tile right now.
568
    /// Distinct from `Pending` so the spawn pass doesn't double-fire.
569
    Fetching,
570
    /// Tile decoded into an SVG document. Held as the raw SVG
571
    /// string for now; the `VirtualView` callback will feed it
572
    /// through the framework's svg-to-dom pipeline on the next
573
    /// re-render.
574
    Ready { svg: AzString },
575
    /// Fetch failed. Held so the framework doesn't immediately
576
    /// re-try the same URL - caller can choose to clear failed
577
    /// entries on retry.
578
    Failed { error: AzString },
579
}
580

            
581
/// Worker-thread input: which tile to fetch, the resolved URL, and the
582
/// `MapCSS` stylesheet to apply when converting features to SVG. Boxed
583
/// into the `Thread::create` init `RefAny`.
584
#[derive(Debug, Clone)]
585
pub struct TileFetchInit {
586
    pub tile: MapTileId,
587
    pub url: AzString,
588
    /// Copy of `MapTileLayer::style_css` (empty = default palette).
589
    pub style_css: AzString,
590
}
591

            
592
/// Worker-thread output, sent back via `ThreadWriteBackMsg`. The
593
/// `map_tile_writeback` callback downcasts to this and stamps the
594
/// cache.
595
#[derive(Debug, Clone)]
596
pub struct TileReadyMsg {
597
    pub tile: MapTileId,
598
    /// Decoded SVG document for the tile, or empty on failure (with
599
    /// `error` set).
600
    pub svg: AzString,
601
    /// Empty on success; an error message on failure.
602
    pub error: AzString,
603
}
604

            
605
// ────────── Merge callback — cache survives relayout ─────────────────
606

            
607
/// Copy every entry from the previous frame's cache into the new
608
/// frame's cache. The next layout pass thus sees the same in-flight /
609
/// decoded set without re-fetching anything.
610
8
extern "C" fn merge_map_tile_cache(mut new_data: RefAny, mut old_data: RefAny) -> RefAny {
611
    // SHARE the previous cache across the relayout — do NOT copy its tiles into
612
    // the freshly-built one. The tile-fetch worker threads each hold a clone of
613
    // THIS very `RefAny` (handed to them at spawn time); returning it keeps their
614
    // writebacks landing in the same cache the VirtualView reads. The reconcile
615
    // pass re-points the VirtualView node's `refany` at this returned dataset
616
    // (core::diff::transfer_states), so the pure content callback reads it too.
617
    //
618
    // The old behaviour returned a fresh `new_data` with the old tiles *copied*
619
    // in. That orphaned the workers' clone after the first relayout: every tile
620
    // arriving later was written into the old, no-longer-rendered cache, so the
621
    // map stayed blank. Returning the persistent (old) cache fixes it at the root
622
    // — workers, dataset and VirtualView all reference one underlying allocation.
623
    //
624
    // The freshly-built `new_data` carries the layout-callback-controlled
625
    // CONFIG: the fetch worker the `.dom()` shim wired, and — critically — the
626
    // viewport/layer the app passed to `with_viewport()` / `create()` for THIS
627
    // build. Adopt those into the persistent cache: app callbacks (zoom
628
    // buttons, Recentre, Locate) mutate app state and return RefreshDom, and
629
    // the merge previously discarded that new viewport ("viewport intact"),
630
    // so external viewport changes never took effect — only the widget's
631
    // internal drag/wheel (which mutate the persistent cache directly)
632
    // worked. Widget-internal changes stay consistent because every build's
633
    // `with_viewport()` receives the app state, which the on_viewport_changed
634
    // hook keeps in sync with internal pans/zooms.
635
    {
636
8
        let new_g = new_data.downcast_ref::<MapTileCache>();
637
8
        let old_guard = old_data.downcast_mut::<MapTileCache>();
638
8
        if let (Some(new_g), Some(mut old_g)) = (new_g, old_guard) {
639
5
            if old_g.fetch_callback.is_none() {
640
4
                old_g.fetch_callback.clone_from(&new_g.fetch_callback);
641
4
            }
642
5
            old_g.viewport = new_g.viewport;
643
5
            old_g.layer = new_g.layer.clone();
644
5
            old_g.on_viewport_changed = new_g.on_viewport_changed.clone();
645
3
        }
646
    }
647
8
    old_data
648
8
}
649

            
650
// ────────── Pan + zoom callbacks ─────────────────────────────────────
651

            
652
use crate::callbacks::CallbackInfo;
653
use azul_core::callbacks::Update;
654
use azul_core::callbacks::TimerCallbackReturn;
655
use azul_core::task::{Duration, SystemTimeDiff, TerminateTimer, TimerId};
656
use crate::timer::{Timer, TimerCallback, TimerCallbackInfo};
657

            
658
// --- User hook: on_viewport_changed (backreference DI, FFI-exposed) ---
659

            
660
/// User hook fired when the user pans or zooms the map.
661
///
662
/// Lets app code observe
663
/// or persist the widget-driven `MapViewport` (which otherwise lives only in
664
/// the opaque `MapTileCache`). The backreference DI pattern (architecture.md).
665
pub type MapViewportChangedCallbackType =
666
    extern "C" fn(RefAny, CallbackInfo, MapViewport) -> Update;
667
impl_widget_callback!(
668
    MapViewportChanged,
669
    OptionMapViewportChanged,
670
    MapViewportChangedCallback,
671
    MapViewportChangedCallbackType
672
);
673
azul_core::impl_managed_callback! {
674
    wrapper:        MapViewportChangedCallback,
675
    info_ty:        CallbackInfo,
676
    return_ty:      Update,
677
    default_ret:    Update::DoNothing,
678
    invoker_static: MAP_VIEWPORT_CHANGED_INVOKER,
679
    invoker_ty:     AzMapViewportChangedCallbackInvoker,
680
    thunk_fn:       az_map_viewport_changed_callback_thunk,
681
    setter_fn:      AzApp_setMapViewportChangedCallbackInvoker,
682
    from_handle_fn: AzMapViewportChangedCallback_createFromHostHandle,
683
    extra_args:     [ viewport: MapViewport ],
684
}
685

            
686
/// Invoke a map widget's optional `on_viewport_changed` hook with the new
687
/// viewport, returning the user's `Update` (`DoNothing` if no hook is set).
688
5
fn invoke_viewport_changed(
689
5
    hook: &OptionMapViewportChanged,
690
5
    info: &CallbackInfo,
691
5
    viewport: MapViewport,
692
5
) -> Update {
693
5
    match hook {
694
2
        OptionMapViewportChanged::Some(h) => {
695
2
            (h.callback.cb)(h.refany.clone(), *info, viewport)
696
        }
697
3
        OptionMapViewportChanged::None => Update::DoNothing,
698
    }
699
5
}
700

            
701
// --- User hook: on_pin_tap (backreference DI, FFI-exposed) ---
702

            
703
/// User hook fired when the user taps the map (a press + release at ~the same
704
/// point, no pan/pinch).
705
///
706
/// Receives the tapped [`MapLatLon`] (projected via
707
/// [`MapWidget::latlon_at_px`]) so apps can drop a pin without wiring their own
708
/// tap handling + projection. The backreference DI pattern (architecture.md).
709
pub type MapPinTapCallbackType = extern "C" fn(RefAny, CallbackInfo, MapLatLon) -> Update;
710
impl_widget_callback!(
711
    MapPinTap,
712
    OptionMapPinTap,
713
    MapPinTapCallback,
714
    MapPinTapCallbackType
715
);
716
azul_core::impl_managed_callback! {
717
    wrapper:        MapPinTapCallback,
718
    info_ty:        CallbackInfo,
719
    return_ty:      Update,
720
    default_ret:    Update::DoNothing,
721
    invoker_static: MAP_PIN_TAP_INVOKER,
722
    invoker_ty:     AzMapPinTapCallbackInvoker,
723
    thunk_fn:       az_map_pin_tap_callback_thunk,
724
    setter_fn:      AzApp_setMapPinTapCallbackInvoker,
725
    from_handle_fn: AzMapPinTapCallback_createFromHostHandle,
726
    extra_args:     [ coord: MapLatLon ],
727
}
728

            
729
/// Invoke a map widget's optional `on_pin_tap` hook with the tapped coordinate.
730
4
fn invoke_pin_tap(hook: &OptionMapPinTap, info: &CallbackInfo, coord: MapLatLon) -> Update {
731
4
    match hook {
732
2
        OptionMapPinTap::Some(h) => (h.callback.cb)(h.refany.clone(), *info, coord),
733
2
        OptionMapPinTap::None => Update::DoNothing,
734
    }
735
4
}
736

            
737
/// Pointer down → record the drag anchor. The widget knows nothing
738
/// about the user's overall state `RefAny` - only its own dataset -
739
/// so the anchor lives in `MapTileCache::drag_anchor`.
740
3
extern "C" fn map_on_pointer_down(mut data: RefAny, info: CallbackInfo) -> Update {
741
    #[cfg(feature = "std")]
742
3
    if std::env::var("AZ_MAP_DEBUG").is_ok() {
743
        eprintln!("[map] pointer_down fired");
744
3
    }
745
3
    let pos = match info.get_cursor_relative_to_node().into_option() {
746
2
        Some(p) => azul_core::geom::LogicalPosition::new(p.x, p.y),
747
1
        None => return Update::DoNothing,
748
    };
749
2
    if let Some(mut cache) = data.downcast_mut::<MapTileCache>() {
750
1
        cache.drag_anchor = Some(pos);
751
1
        cache.press_origin = Some(pos);
752
1
    }
753
2
    Update::DoNothing
754
3
}
755

            
756
/// Pointer move during an active drag → translate the pixel delta
757
/// into a lat/lon delta via the Web Mercator inverse and update
758
/// `viewport.centre_lat_deg / centre_lon_deg`. Updates the anchor so
759
/// the next move computes a fresh delta.
760
///
761
/// If a pinch gesture is in flight (two fingers on the widget), the
762
/// pan branch is skipped and the move event drives zoom instead -
763
/// `dz = log2(current_distance / pinch_anchor)`. The next move resets
764
/// the anchor to the current distance so the gesture stays
765
/// continuous across many frames.
766
#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
767
5
extern "C" fn map_on_pointer_move(mut data: RefAny, mut info: CallbackInfo) -> Update {
768
    #[cfg(feature = "std")]
769
5
    if std::env::var("AZ_MAP_DEBUG").is_ok() {
770
        let dragging = data
771
            .downcast_ref::<MapTileCache>()
772
            .is_some_and(|c| c.drag_anchor.is_some());
773
        eprintln!("[map] pointer_move fired (dragging={dragging})");
774
5
    }
775
    // Active pinch wins over single-finger pan.
776
5
    if let Some(pinch) = info.get_pinch().into_option() {
777
        let Some(mut cache) = data.downcast_mut::<MapTileCache>() else {
778
            return Update::DoNothing;
779
        };
780
        let anchor = *cache.pinch_anchor.get_or_insert(pinch.current_distance);
781
        if anchor > 1.0 && pinch.current_distance > 1.0 {
782
            let dz = (pinch.current_distance / anchor).log2();
783
            let min = f32::from(cache.layer.min_zoom);
784
            let max = f32::from(cache.layer.max_zoom);
785
            cache.viewport.zoom = (cache.viewport.zoom + dz).clamp(min, max);
786
        }
787
        cache.pinch_anchor = Some(pinch.current_distance);
788
        // Pinch is exclusive with pan — clear the drag anchor so the
789
        // pinch end doesn't accidentally drop into a pan.
790
        cache.drag_anchor = None;
791
        let hook = cache.on_viewport_changed.clone();
792
        let vp = cache.viewport;
793
        drop(cache);
794
        invoke_viewport_changed(&hook, &info, vp);
795
        // Re-render the VirtualView in place so the new zoom's tiles compute
796
        // immediately, without a DOM rebuild. (See map_tile_writeback for why
797
        // RefreshDom is avoided.)
798
        info.trigger_all_virtual_view_rerender();
799
        return Update::DoNothing;
800
5
    }
801

            
802
5
    let pos = match info.get_cursor_relative_to_node().into_option() {
803
5
        Some(p) => azul_core::geom::LogicalPosition::new(p.x, p.y),
804
        None => return Update::DoNothing,
805
    };
806
5
    let Some(mut cache_guard) = data.downcast_mut::<MapTileCache>() else {
807
        return Update::DoNothing;
808
    };
809
5
    let Some(anchor) = cache_guard.drag_anchor else {
810
1
        return Update::DoNothing; // no active drag
811
    };
812

            
813
4
    let dx_px = f64::from(pos.x - anchor.x);
814
4
    let dy_px = f64::from(pos.y - anchor.y);
815
4
    if dx_px.abs() < 0.5 && dy_px.abs() < 0.5 {
816
1
        return Update::DoNothing;
817
3
    }
818

            
819
3
    let (new_lon, new_lat) = pan_viewport(
820
3
        cache_guard.viewport.centre_lat_deg,
821
3
        cache_guard.viewport.centre_lon_deg,
822
3
        f64::from(cache_guard.viewport.zoom),
823
3
        dx_px,
824
3
        dy_px,
825
3
    );
826
3
    cache_guard.viewport.centre_lon_deg = new_lon;
827
3
    cache_guard.viewport.centre_lat_deg = new_lat;
828
3
    cache_guard.drag_anchor = Some(pos);
829

            
830
3
    let hook = cache_guard.on_viewport_changed.clone();
831
3
    let vp = cache_guard.viewport;
832
3
    drop(cache_guard);
833
3
    invoke_viewport_changed(&hook, &info, vp);
834
    // Pan moved the viewport — re-render the VirtualView in place so the newly
835
    // visible tiles are computed (and marked Pending) right away. No RefreshDom.
836
3
    info.trigger_all_virtual_view_rerender();
837
3
    Update::DoNothing
838
5
}
839

            
840
/// Pointer up / pointer leave → end the drag *and* the pinch. Either
841
/// can be in flight (and pinch supersedes pan in the move handler);
842
/// clear both anchors on release.
843
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
844
5
extern "C" fn map_on_pointer_up(mut data: RefAny, mut info: CallbackInfo) -> Update {
845
    // Cursor + container size for tap projection (read before borrowing data).
846
5
    let up_pos = info
847
5
        .get_cursor_relative_to_node()
848
5
        .into_option()
849
5
        .map(|p| azul_core::geom::LogicalPosition::new(p.x, p.y));
850
5
    let container = info
851
5
        .get_hit_node_rect()
852
5
        .map_or(azul_core::geom::LogicalSize::new(0.0, 0.0), |r| r.size);
853
5
    let (press, viewport, hook) = data.downcast_mut::<MapTileCache>().map_or_else(|| (None, MapViewport::default(), OptionMapPinTap::None), |mut cache| {
854
4
            let out = (cache.press_origin, cache.viewport, cache.on_pin_tap.clone());
855
4
            cache.drag_anchor = None;
856
4
            cache.pinch_anchor = None;
857
4
            cache.press_origin = None;
858
4
            out
859
4
        });
860
    // A press + release at ~the same point (no pan/pinch) is a tap: project it
861
    // to lat/lon and fire the user's on_pin_tap hook.
862
5
    if let (Some(origin), Some(up)) = (press, up_pos) {
863
3
        let dx = f64::from(up.x - origin.x);
864
3
        let dy = f64::from(up.y - origin.y);
865
3
        if dx * dx + dy * dy < 36.0 {
866
2
            let coord = MapWidget::latlon_at_px(viewport, up, container);
867
2
            invoke_pin_tap(&hook, &info, coord);
868
2
        }
869
2
    }
870
    // After a pan / pinch settles, kick off fetches for any tiles the new
871
    // viewport needs. (Only a `CallbackInfo`-bearing callback can spawn them.)
872
5
    spawn_pending_tile_fetches(&mut data, &mut info);
873
    // Re-render in place so Fetching/Ready states show as tiles arrive. The
874
    // worker writebacks will trigger further re-renders themselves. No RefreshDom.
875
5
    info.trigger_all_virtual_view_rerender();
876
5
    Update::DoNothing
877
5
}
878

            
879
/// Mouse-wheel / trackpad scroll over the map = ZOOM (Leaflet / Google-Maps
880
/// convention), not content scroll. The map's `VirtualView` has no scroll overflow,
881
/// so the framework's queued wheel deltas would otherwise be wasted - drain them
882
/// and apply as a zoom step, then queue + spawn the tiles the new zoom needs and
883
/// re-render in place.
884
2
extern "C" fn map_on_scroll(mut data: RefAny, mut info: CallbackInfo) -> Update {
885
    // Wheel delta that triggered this Scroll callback (sign = direction). The map
886
    // is not a scroll container, so this comes from the per-pass wheel delta, not
887
    // the scroll-physics input queue (which only feeds scrollable nodes).
888
2
    let dy: f32 = {
889
2
        let hn = info.get_hit_node();
890
2
        hn.node.into_crate_internal().map_or(0.0, |nid| info.get_scroll_delta(hn.dom, nid).map_or(0.0, |d| d.y))
891
    };
892
    #[cfg(feature = "std")]
893
2
    if std::env::var("AZ_MAP_DEBUG").is_ok() {
894
        eprintln!("[map] scroll fired dy={dy}");
895
2
    }
896
2
    if dy == 0.0 {
897
2
        return Update::DoNothing;
898
    }
899
    // The grid's on-screen rect is the widget size (needed to recompute the tiles
900
    // the new zoom needs).
901
    let bounds = info
902
        .get_hit_node_rect()
903
        .map_or(azul_core::geom::LogicalSize::new(0.0, 0.0), |r| r.size);
904
    let (vp, hook) = {
905
        let Some(mut cache) = data.downcast_mut::<MapTileCache>() else {
906
            return Update::DoNothing;
907
        };
908
        let min = f32::from(cache.layer.min_zoom);
909
        let max = f32::from(cache.layer.max_zoom);
910
        // ~0.5 zoom levels per wheel notch. X11 delivers wheel-up as dy > 0;
911
        // wheel-up zooms IN, wheel-down zooms OUT (Leaflet / Google-Maps).
912
        let dz = dy.signum() * 0.5;
913
        cache.viewport.zoom = (cache.viewport.zoom + dz).clamp(min, max);
914
        let vp = cache.viewport;
915
        let layer = cache.layer.clone();
916
        for t in map_visible_tiles(&vp, bounds, &layer) {
917
            cache.tiles.entry(t).or_insert(TileEntry::Pending);
918
        }
919
        (vp, cache.on_viewport_changed.clone())
920
    };
921
    invoke_viewport_changed(&hook, &info, vp);
922
    spawn_pending_tile_fetches(&mut data, &mut info);
923
    info.trigger_all_virtual_view_rerender();
924
    Update::DoNothing
925
2
}
926

            
927
81
fn wrap_lon(lon: f64) -> f64 {
928
    // `rem_euclid` (not `%`) so even large negative deltas normalise:
929
    // `%` follows the dividend's sign and would leak values < -180.
930
81
    (lon + 180.0).rem_euclid(360.0) - 180.0
931
81
}
932

            
933
// ────────── Web-Mercator (WGS-84 ↔ XYZ tile space) ───────────────────
934
//
935
// `tile_count` is `2^zoom`. Tile-space x grows east (0 at lon -180,
936
// `tile_count` at lon +180); y grows south (0 at the north edge
937
// ~85.05°, `tile_count` at the south edge). These four functions are
938
// exact inverses of each other and are the single source of truth for
939
// the widget's projection — `map_widget_render` forward-projects the
940
// viewport centre through them; tap-to-pin will inverse-project taps.
941

            
942
/// Longitude (deg) → fractional tile-x at the given `tile_count`.
943
228
fn lon_to_tile_x(lon_deg: f64, tile_count: f64) -> f64 {
944
228
    (lon_deg + 180.0) / 360.0 * tile_count
945
228
}
946

            
947
/// Latitude (deg) → fractional tile-y at the given `tile_count`.
948
219
fn lat_to_tile_y(lat_deg: f64, tile_count: f64) -> f64 {
949
219
    let lat_rad = lat_deg.to_radians();
950
219
    let mercator =
951
219
        (1.0 - (lat_rad.tan() + 1.0 / lat_rad.cos()).ln() / core::f64::consts::PI) / 2.0;
952
219
    mercator * tile_count
953
219
}
954

            
955
/// Fractional tile-x → longitude (deg). Inverse of [`lon_to_tile_x`].
956
/// Verified against the forward direction in the tests below; the
957
/// upcoming tap-to-pin handler reuses it to turn a tap into a lat/lon.
958
#[allow(dead_code)]
959
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
960
55
fn tile_x_to_lon(x: f64, tile_count: f64) -> f64 {
961
55
    x / tile_count * 360.0 - 180.0
962
55
}
963

            
964
/// Fractional tile-y → latitude (deg). Inverse of [`lat_to_tile_y`].
965
#[allow(dead_code)]
966
56
fn tile_y_to_lat(y: f64, tile_count: f64) -> f64 {
967
56
    let n = core::f64::consts::PI * (1.0 - 2.0 * y / tile_count);
968
56
    n.sinh().atan().to_degrees()
969
56
}
970

            
971
/// Apply a drag of `(dx_px, dy_px)` screen pixels to a viewport centre,
972
/// returning the new `(centre_lon_deg, centre_lat_deg)`. Dragging right
973
/// (+dx) pans the map content right, i.e. recentres on a *lower* longitude
974
/// (hence the minus). Latitude uses the small-angle Mercator approximation
975
/// (`d_lat ≈ dy·cos(lat)·360/world`), accurate to a few metres at city
976
/// zooms; the exact inverse only matters for very long drags near the
977
/// poles. Longitude wraps to [-180, 180); latitude clamps to the
978
/// Web-Mercator ±85.05° limit. The shared, unit-tested core of
979
/// `map_on_pointer_move`.
980
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
981
#[allow(clippy::similar_names)] // domain-standard coordinate/geometry/short-lived names
982
41
fn pan_viewport(
983
41
    centre_lat_deg: f64,
984
41
    centre_lon_deg: f64,
985
41
    zoom: f64,
986
41
    dx_px: f64,
987
41
    dy_px: f64,
988
41
) -> (f64, f64) {
989
    // World pixels at the current fractional zoom (256 px / tile).
990
41
    let world_px = 256.0 * (2.0_f64).powf(zoom);
991
41
    let d_lon = -dx_px * 360.0 / world_px;
992
41
    let d_lat = dy_px * 360.0 / world_px * centre_lat_deg.to_radians().cos();
993
41
    let new_lon = wrap_lon(centre_lon_deg + d_lon);
994
41
    let new_lat = (centre_lat_deg + d_lat).clamp(-85.0, 85.0);
995
41
    (new_lon, new_lat)
996
41
}
997

            
998
/// Parse a standalone `<svg>…</svg>` string into a `Dom` subtree via
999
/// the framework's existing XML→DOM path.
///
/// The SVG is wrapped in a
/// minimal `<html><body>` envelope because `str_to_dom_unstyled`
/// expects a document root; the wrapper divs are zero-impact in
/// layout. Returns `None` if the `xml` feature is off or parsing
/// fails - the caller then falls back to the placeholder glyph.
// Render the decoded tile SVG to a COLOUR image node, reusing the framework's
// `render_svg_group` rasteriser (the one that renders the tiger), which honours
// the SVG `fill`/`stroke` attrs that `features_to_svg` emits. The DOM SVG path
// (`str_to_dom_unstyled` → `SvgNodeData::Path`) only produces a clip mask, so it
// cannot paint the feature colours — hence the tiles rendered grey.
#[cfg(all(feature = "xml", feature = "cpurender"))]
50
#[must_use] pub fn svg_string_to_dom(svg: &str) -> Option<Dom> {
50
    let img = crate::cpurender::render_svg_to_imageref(svg.as_bytes(), 256, 256).ok()?;
16
    Some(
16
        Dom::create_image(img)
16
            .with_css("position: absolute; left: 0; top: 0; width: 100%; height: 100%;"),
16
    )
50
}
#[cfg(all(feature = "xml", not(feature = "cpurender")))]
pub fn svg_string_to_dom(svg: &str) -> Option<Dom> {
    use azul_core::xml::{str_to_dom_unstyled, ComponentMap};
    let wrapped = alloc::format!("<html><body>{}</body></html>", svg);
    let nodes = crate::xml::parse_xml_string(&wrapped).ok()?;
    let component_map = ComponentMap::default();
    str_to_dom_unstyled(nodes.as_ref(), &component_map).ok()
}
#[cfg(not(feature = "xml"))]
fn svg_string_to_dom(_svg: &str) -> Option<Dom> {
    // A tile/page SVG arrived and this build cannot turn it into a DOM — the
    // caller sees a permanent None, indistinguishable from a bad SVG.
    static ANNOUNCE: std::sync::Once = std::sync::Once::new();
    ANNOUNCE.call_once(|| {
        eprintln!(
            "[azul][svg] svg_string_to_dom called, but this build has no `xml` \
             feature — SVG-to-DOM always returns None. Rebuild azul-layout with \
             the `xml` feature"
        );
    });
    None
}
/// Fires once when the widget first mounts. Kicks the initial tile
/// fetches so the map populates without waiting for a user gesture.
/// (The `VirtualView` marks the viewport's tiles `Pending` during the
/// layout pass that precedes mount-event dispatch; this handler then
/// spawns the workers for them.) Returns `RefreshDom` so the
/// `Fetching` state shows immediately.
2
extern "C" fn map_on_after_mount(mut data: RefAny, mut info: CallbackInfo) -> Update {
    #[cfg(feature = "std")]
2
    if std::env::var("AZ_MAP_DEBUG").is_ok() {
        eprintln!("[map] after_mount fired");
2
    }
2
    spawn_pending_tile_fetches(&mut data, &mut info);
    // Install a low-frequency sweep timer. Pointer/scroll/after_mount spawn
    // fetches directly, but a viewport change that originates from a *rebuild*
    // (an app's zoom/recentre button → with_viewport) marks new tiles `Pending`
    // in the VirtualView render, which has no `add_thread` — so without this
    // sweep the map would sit grey after a button-zoom until the next
    // drag/wheel. The timer's cache clone tracks the persistent dataset
    // `transfer_states` keeps across rebuilds, so it stays unified.
2
    let sweep = Timer::create(
2
        data.clone(),
2
        TimerCallback::create(map_fetch_sweep_tick),
2
        info.get_system_time_fn(),
    )
2
    .with_interval(Duration::System(SystemTimeDiff::from_millis(250)));
2
    info.add_timer(TimerId::unique(), sweep);
    // Re-render the VirtualView IN PLACE (not RefreshDom). RefreshDom would
    // rebuild the DOM, allocate a fresh MapTileCache, and orphan the clone of
    // the cache we just handed the worker threads — their tiles would then write
    // to a cache nobody renders. The dataset is shared via the construction-time
    // RefAny::clone(), so re-invoking in place lets the workers' writes land in
    // the same cache the VirtualView reads.
2
    info.trigger_all_virtual_view_rerender();
2
    Update::DoNothing
2
}
/// Scan the cache for `Pending` tiles and spawn one framework `Thread`
/// per tile (capped per call so a big viewport jump doesn't spawn
/// hundreds at once). Each thread gets:
/// - init `RefAny` = `TileFetchInit { tile, url }`
/// - writeback `RefAny` = a clone of the cache dataset, so
///   `map_tile_writeback` mutates the same cache the `VirtualView` reads.
///
/// Tiles transition `Pending → Fetching` here so they aren't
/// re-spawned next frame. No-op when the cache has no `fetch_callback`.
11
fn spawn_pending_tile_fetches(data: &mut RefAny, info: &mut CallbackInfo) {
    use crate::thread::Thread;
    use azul_core::task::ThreadId;
    // Per-call spawn cap — bounds the burst on a big viewport jump.
    const MAX_SPAWN_PER_CALL: usize = 16;
    // Collect the work first (URL build + state flip) under one borrow,
    // then spawn outside it so we don't hold the cache lock across
    // `info.add_thread`.
11
    let mut to_spawn: Vec<TileFetchInit> = Vec::new();
    {
11
        let Some(mut cache) = data.downcast_mut::<MapTileCache>() else {
            // The dataset handed to this callback is not a MapTileCache. Every
            // tile stays Pending forever and the map shows placeholders — the
            // exact symptom of "panning works, tiles never paint".
            #[cfg(feature = "std")]
3
            if std::env::var("AZ_MAP_DEBUG").is_ok() {
                std::eprintln!("[map] spawn_pending: ABORT — dataset is not a MapTileCache");
3
            }
3
            return;
        };
8
        if cache.fetch_callback.is_none() {
            // No worker wired. Either the build has no `map-tiles` feature, or
            // the callback was lost when the cache RefAny was rebuilt (the
            // merge callback is what preserves it across relayout).
            #[cfg(feature = "std")]
6
            if std::env::var("AZ_MAP_DEBUG").is_ok() {
                std::eprintln!(
                    "[map] spawn_pending: ABORT — no fetch_callback on the cache \
                     ({} tiles held)",
                    cache.tiles.len()
                );
6
            }
6
            return; // no worker wired — leave tiles Pending (placeholder grid)
2
        }
2
        let template = cache.layer.url_template.as_str().to_string();
2
        let style_css = cache.layer.style_css.clone();
2
        let pending: Vec<MapTileId> = cache
2
            .tiles
2
            .iter()
36
            .filter(|(_, e)| matches!(e, TileEntry::Pending))
2
            .map(|(id, _)| *id)
2
            .take(MAX_SPAWN_PER_CALL)
2
            .collect();
22
        for tile in pending {
20
            let url = build_tile_url(&template, tile);
20
            cache.tiles.insert(tile, TileEntry::Fetching);
20
            to_spawn.push(TileFetchInit {
20
                tile,
20
                url: AzString::from(url),
20
                style_css: style_css.clone(),
20
            });
20
        }
        // Now that the current view's tiles are queued (Fetching, so eviction
        // protects them), bound the cache by dropping tiles far from the
        // viewport — otherwise panning/zooming grows it without limit.
2
        cache.prune_distant_tiles();
    }
2
    let cb = {
2
        let Some(cache) = data.downcast_ref::<MapTileCache>() else {
            #[cfg(feature = "std")]
            if std::env::var("AZ_MAP_DEBUG").is_ok() {
                std::eprintln!("[map] spawn_pending: ABORT — dataset vanished before spawn");
            }
            return;
        };
2
        let Some(cb) = cache.fetch_callback.as_ref() else {
            #[cfg(feature = "std")]
            if std::env::var("AZ_MAP_DEBUG").is_ok() {
                std::eprintln!("[map] spawn_pending: ABORT — fetch_callback gone at spawn");
            }
            return;
        };
2
        cb.clone()
    };
    #[cfg(feature = "std")]
2
    let spawn_count = to_spawn.len();
    // Distinguish "nothing to do" from "never got here" — a quiet log and an
    // aborted one look identical otherwise.
    #[cfg(feature = "std")]
2
    if spawn_count == 0 && std::env::var("AZ_MAP_DEBUG").is_ok() {
        std::eprintln!("[map] spawn_pending: 0 tiles were Pending (nothing to spawn)");
2
    }
22
    for init in to_spawn {
20
        let init_data = RefAny::new(init);
20
        let writeback_data = data.clone(); // same cache dataset
20
        let thread = Thread::create(init_data, writeback_data, cb.clone());
20
        info.add_thread(ThreadId::unique(), thread);
20
    }
    #[cfg(feature = "std")]
2
    if std::env::var("AZ_MAP_DEBUG").is_ok() {
        eprintln!("[map] spawn_pending: {spawn_count} thread(s) spawned");
2
    }
11
}
/// Low-frequency timer that spawns fetches for any `Pending` tiles the
/// `VirtualView` marked since the last spawn - the path that the
/// `pointer/scroll/after_mount` handlers can't cover (a rebuild-driven viewport
/// change marks tiles `Pending` in the `VirtualView` render, which has no
/// `add_thread`). Installed once in `map_on_after_mount`. The `data` clone
/// tracks the persistent dataset, so writebacks land in the rendered cache.
/// Cheap no-op when nothing is `Pending`; never `RefreshDom`s (that would
/// orphan the cache the workers write to - tile writebacks drive re-render).
extern "C" fn map_fetch_sweep_tick(
    mut data: RefAny,
    mut info: TimerCallbackInfo,
) -> TimerCallbackReturn {
    spawn_pending_tile_fetches(&mut data, &mut info.callback_info);
    TimerCallbackReturn {
        should_update: Update::DoNothing,
        should_terminate: TerminateTimer::Continue,
    }
}
/// `{z}/{x}/{y}` substitution. Mirrors `azul_dll`'s `build_tile_url`
/// (the widget can't reach the dll, so it's duplicated here - trivial).
32
fn build_tile_url(template: &str, tile: MapTileId) -> String {
    use alloc::string::ToString;
32
    template
32
        .replace("{z}", &tile.z.to_string())
32
        .replace("{x}", &tile.x.to_string())
32
        .replace("{y}", &tile.y.to_string())
32
}
/// Worker-thread → main-thread writeback.
///
/// `cache_dataset` is the
/// `writeback_data` handed to `Thread::create` (the same
/// `MapTileCache` the widget reads); `incoming` is the `TileReadyMsg`
/// the worker sent. Stamps the tile `Ready` (or `Failed`) and asks for
/// a relayout so the `VirtualView` renders the new content.
5
#[must_use] pub extern "C" fn map_tile_writeback(
5
    mut cache_dataset: RefAny,
5
    mut incoming: RefAny,
5
    mut info: CallbackInfo,
5
) -> Update {
    // The worker sent something that is not a TileReadyMsg: the tile arrived
    // and is dropped on the floor here.
5
    let Some(m) = incoming.downcast_ref::<TileReadyMsg>() else {
        #[cfg(feature = "std")]
1
        if std::env::var("AZ_MAP_DEBUG").is_ok() {
            std::eprintln!("[map] writeback: DROPPED — payload is not a TileReadyMsg");
1
        }
1
        return Update::DoNothing;
    };
4
    let msg = (m.tile, m.svg.clone(), m.error.clone());
4
    drop(m);
    {
4
        let Some(mut cache) = cache_dataset.downcast_mut::<MapTileCache>() else {
            // The tile came back but the dataset it targets is no longer a
            // MapTileCache — typically a rebuild replaced it. The fetch
            // succeeded and the pixels are still discarded.
            #[cfg(feature = "std")]
1
            if std::env::var("AZ_MAP_DEBUG").is_ok() {
                std::eprintln!(
                    "[map] writeback: DROPPED tile=({},{},{}) — target dataset is not a MapTileCache",
                    msg.0.z, msg.0.x, msg.0.y
                );
1
            }
1
            return Update::DoNothing;
        };
        #[cfg(feature = "std")]
3
        if std::env::var("AZ_MAP_DEBUG").is_ok() {
            eprintln!(
                "[map] writeback tile=({},{},{}) ok={} svg_len={} err={:?}",
                msg.0.z, msg.0.x, msg.0.y,
                msg.2.as_str().is_empty(), msg.1.as_str().len(), msg.2.as_str()
            );
3
        }
3
        if msg.2.as_str().is_empty() {
2
            cache.mark_tile_ready(msg.0, msg.1);
2
        } else {
1
            cache.mark_tile_failed(msg.0, msg.2);
1
        }
    } // drop the cache borrow before touching `info`
    // Re-render the VirtualView(s) IN PLACE so the pure content callback re-reads
    // the shared cache we just mutated. NOT `RefreshDom`: a DOM rebuild would
    // allocate a fresh `MapTileCache` and orphan THIS worker's clone of it (the
    // VirtualView's `refany`, the node dataset and the worker's writeback handle
    // are all clones of one `RefAny` — same underlying data — only while the DOM
    // is not rebuilt). Re-invoking in place keeps that share intact, so this tile
    // and every later one reach the rendered view.
3
    info.trigger_all_virtual_view_rerender();
3
    Update::DoNothing
5
}
/// Inclusive `(x_min, x_max, y_min, y_max)` tile range covering a
/// `width_px x height_px` viewport centred at tile-space `(centre_x,
/// centre_y)`, at fractional `zoom_scale` and integer `tile_count` (2^z).
/// A one-tile margin (`+ 1.0`) is added each side so a tile scrolling into
/// view is already requested; the result is clamped to the valid
/// `0..=tile_count-1` grid. The pure core of `map_widget_render`'s grid
/// loop - what decides which tiles get fetched.
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)] // bounded layout/render numeric cast
161
fn visible_tile_range(
161
    centre_x: f32,
161
    centre_y: f32,
161
    width_px: f32,
161
    height_px: f32,
161
    zoom_scale: f32,
161
    tile_count: u32,
161
) -> (i32, i32, i32, i32) {
161
    let tile_px = 256.0 * zoom_scale;
161
    let half_w = (width_px / tile_px).abs() * 0.5 + 1.0;
161
    let half_h = (height_px / tile_px).abs() * 0.5 + 1.0;
161
    let max_idx = tile_count as i32 - 1;
    // x is NOT clamped: the map wraps horizontally. Callers take the tile id mod
    // `tile_count` (so a column past the antimeridian shows the far side of the
    // world) while positioning the div at the un-wrapped column — seamless pan
    // across ±180° with no empty gutter. y IS clamped: there is no data beyond
    // the Web-Mercator poles, so vertical over-scan must not request bogus rows.
161
    let x_min = (centre_x - half_w).floor() as i32;
161
    let x_max = (centre_x + half_w).ceil() as i32;
161
    let y_min = ((centre_y - half_h).floor() as i32).max(0);
161
    let y_max = ((centre_y + half_h).ceil() as i32).min(max_idx);
161
    (x_min, x_max, y_min, y_max)
161
}
/// Wrap a (possibly negative or over-range) tile column into the valid
/// `0..tile_count` band - the horizontal world-wrap. `rem_euclid` (not `%`)
/// so columns west of the antimeridian map to the east side: at `tile_count`
/// = 4, column `-1` → `3`, column `4` → `0`.
#[allow(clippy::cast_possible_wrap)] // bounded layout/render numeric cast
7683
fn wrap_tile_x(x: i32, tile_count: u32) -> u32 {
7683
    x.rem_euclid(tile_count.max(1) as i32) as u32
7683
}
/// `f(view)` - the tile ids a `viewport` needs to fill a `bounds`-sized widget.
/// Shared by the `VirtualView` render and the pan/zoom handlers so a handler can
/// mark + spawn the NEW viewport's tiles immediately, rather than waiting for the
/// next render pass to discover them. Mirrors `map_widget_render`'s grid math.
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] // bounded layout/render numeric cast
70
fn map_visible_tiles(
70
    viewport: &MapViewport,
70
    bounds: azul_core::geom::LogicalSize,
70
    layer: &MapTileLayer,
70
) -> Vec<MapTileId> {
70
    let z_int =
70
        (viewport.zoom.floor() as i32).clamp(i32::from(layer.min_zoom), i32::from(layer.max_zoom)) as u8;
70
    let tile_count = 1u32 << u32::from(z_int);
70
    let frac_zoom = viewport.zoom - f32::from(z_int);
70
    let zoom_scale = 2.0_f32.powf(frac_zoom);
70
    let centre_x = lon_to_tile_x(viewport.centre_lon_deg, f64::from(tile_count)) as f32;
70
    let centre_y = lat_to_tile_y(viewport.centre_lat_deg, f64::from(tile_count)) as f32;
70
    let (x_min, x_max, y_min, y_max) =
70
        visible_tile_range(centre_x, centre_y, bounds.width, bounds.height, zoom_scale, tile_count);
70
    let mut tiles = Vec::new();
709
    for x in x_min..=x_max {
3617
        for y in y_min..=y_max {
3617
            tiles.push(MapTileId { z: z_int, x: wrap_tile_x(x, tile_count), y: y as u32 });
3617
        }
    }
70
    tiles
70
}
// ────────── VirtualView callback — visible-tile rendering ─────────────
#[allow(clippy::suboptimal_flops)] // mul_add not guaranteed faster/available without target +fma; keep explicit a*b+c
#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss, clippy::cast_sign_loss)] // bounded layout/render numeric cast
#[allow(clippy::too_many_lines)] // large but cohesive: single-purpose layout/render/parse routine (one branch per case)
83
extern "C" fn map_widget_render(
83
    data: RefAny,
83
    info: VirtualViewCallbackInfo,
83
) -> VirtualViewReturn {
    enum TileDisplay {
        Glyph(&'static str),
        Svg(AzString),
    }
83
    let mut data = data;
83
    let bounds = info.get_bounds();
83
    let bounds_logical = bounds.get_logical_size();
83
    let width_px = bounds_logical.width;
83
    let height_px = bounds_logical.height;
    // Defensive: if the widget was placed in a container that gives it no definite
    // size, the bounds come through as 0 or non-finite. Computing a tile grid then
    // positions tiles at NaN/∞ (off-screen → blank) and can allocate unboundedly, so
    // render nothing until the layout settles to a finite box.
83
    if !width_px.is_finite() || !height_px.is_finite() || width_px <= 0.0 || height_px <= 0.0 {
8
        if std::env::var("AZ_MAP_DEBUG").is_ok() {
            eprintln!("[map] non-finite bounds {width_px}x{height_px} — skipping render");
8
        }
8
        return VirtualViewReturn {
8
            dom: OptionDom::None,
8
            materialized: azul_core::geom::LogicalRect::new(azul_core::geom::LogicalPosition::zero(), bounds_logical),
8
            virtual_rect: azul_core::geom::LogicalRect::new(azul_core::geom::LogicalPosition::zero(), bounds_logical),
8
        };
75
    }
75
    let (layer, viewport) = match data.downcast_ref::<MapTileCache>() {
74
        Some(c) => (c.layer.clone(), c.viewport),
        None => {
1
            return VirtualViewReturn {
1
                dom: OptionDom::None,
1
                materialized: azul_core::geom::LogicalRect::new(azul_core::geom::LogicalPosition::zero(), bounds_logical),
1
                virtual_rect: azul_core::geom::LogicalRect::new(azul_core::geom::LogicalPosition::zero(), bounds_logical),
1
            };
        }
    };
    // Round the requested fractional zoom down to the nearest integer
    // tile zoom the layer supports.
74
    let z_int = (viewport.zoom.floor() as i32)
74
        .clamp(i32::from(layer.min_zoom), i32::from(layer.max_zoom))
74
        as u8;
74
    let tile_count = 1u32 << u32::from(z_int);
74
    let frac_zoom = viewport.zoom - f32::from(z_int);
74
    let zoom_scale = 2.0_f32.powf(frac_zoom);
    // Convert WGS-84 → Web-Mercator-XYZ tile-space via the shared
    // projection helpers (the single source of truth, unit-tested below).
74
    let centre_x = lon_to_tile_x(viewport.centre_lon_deg, f64::from(tile_count)) as f32;
74
    let centre_y = lat_to_tile_y(viewport.centre_lat_deg, f64::from(tile_count)) as f32;
    // 256 is the Mercator tile pixel size at integer zoom; tile_px is also
    // used below to position each tile div.
74
    let tile_px = 256.0 * zoom_scale;
74
    let (x_min, x_max, y_min, y_max) =
74
        visible_tile_range(centre_x, centre_y, width_px, height_px, zoom_scale, tile_count);
    // Opt-in render trace (`AZ_MAP_DEBUG=1`): the VirtualView callback fires only
    // when the framework finds this node with real bounds — so seeing this line at
    // all confirms invocation, and the values reveal a zero / infinite / off-screen
    // grid (the usual causes of a blank map).
74
    if std::env::var("AZ_MAP_DEBUG").is_ok() {
        eprintln!(
            "[map] render bounds={:.0}x{:.0} z={} centre_tile=({:.2},{:.2}) tiles x{}..{} y{}..{} = {}",
            width_px, height_px, z_int, centre_x, centre_y, x_min, x_max, y_min, y_max,
            (x_max - x_min + 1).max(0) * (y_max - y_min + 1).max(0)
        );
74
    }
    // Patch in any missing tiles as `Pending`. Real fetch dispatch
    // lands in the follow-up tick that adds the HTTP client; for now
    // we just track which tiles the viewport needs.
74
    if let Some(mut cache) = data.downcast_mut::<MapTileCache>() {
414
        for x in x_min..=x_max {
1900
            for y in y_min..=y_max {
1900
                let id = MapTileId {
1900
                    z: z_int,
1900
                    x: wrap_tile_x(x, tile_count),
1900
                    y: y as u32,
1900
                };
1900
                cache.tiles.entry(id).or_insert(TileEntry::Pending);
1900
            }
        }
    }
    // Snapshot the per-tile state under a short borrow, then drop it
    // before building DOM. `Ready` tiles carry their decoded SVG so the
    // render loop can parse it into a DOM child; the rest carry a glyph
    // (`…` Pending / `⟳` Fetching / `✗` Failed) so the fetch path stays
    // observable.
74
    let states: BTreeMap<MapTileId, TileDisplay> = data
74
        .downcast_ref::<MapTileCache>()
74
        .map_or_else(BTreeMap::new, |c| {
74
            c.tiles
74
                .iter()
1376
                .map(|(id, e)| {
1376
                    let disp = match e {
1348
                        TileEntry::Pending => TileDisplay::Glyph("…"),
4
                        TileEntry::Fetching => TileDisplay::Glyph("⟳"),
20
                        TileEntry::Ready { svg } => TileDisplay::Svg(svg.clone()),
4
                        TileEntry::Failed { .. } => TileDisplay::Glyph("✗"),
                    };
1376
                    (*id, disp)
1376
                })
74
                .collect()
74
        });
    // Build the visible-tile grid. Each tile div is GPU-translated
    // into its screen position; the (CSS-driven) `transform` keeps
    // pan / zoom O(1) — no relayout per frame.
74
    let mut grid = Dom::create_div().with_css(
74
        "position: absolute; left: 0; top: 0; width: 100%; height: 100%; overflow: hidden;",
    );
    // Pan / zoom handlers live HERE, on the VirtualView content — NOT on the
    // outer widget div. The VirtualView renders as a separate DomId painted on
    // top of the outer div, so pointer events hit-test to these tiles and never
    // bubble to the outer div's handlers (which is why mouse-drag panning did
    // nothing). `data` is the shared cache the handlers mutate; the in-place
    // re-render they trigger re-reads it.
    {
        use crate::callbacks::{Callback, CallbackType};
        use azul_core::dom::{EventFilter, HoverEventFilter};
74
        grid = grid
74
            .with_callback(
74
                EventFilter::Hover(HoverEventFilter::MouseDown),
74
                data.clone(),
74
                Callback::from_ptr(map_on_pointer_down),
            )
74
            .with_callback(
74
                EventFilter::Hover(HoverEventFilter::MouseOver),
74
                data.clone(),
74
                Callback::from_ptr(map_on_pointer_move),
            )
74
            .with_callback(
74
                EventFilter::Hover(HoverEventFilter::MouseUp),
74
                data.clone(),
74
                Callback::from_ptr(map_on_pointer_up),
            )
74
            .with_callback(
74
                EventFilter::Hover(HoverEventFilter::MouseLeave),
74
                data.clone(),
74
                Callback::from_ptr(map_on_pointer_up),
            )
74
            .with_callback(
74
                EventFilter::Hover(HoverEventFilter::Scroll),
74
                data.clone(),
74
                Callback::from_ptr(map_on_scroll),
            );
    }
414
    for x in x_min..=x_max {
1900
        for y in y_min..=y_max {
            // Tile id wraps horizontally (the column past ±180° shows the far
            // side of the world); the *screen* position uses the raw un-wrapped
            // column so the wrapped tile lands seamlessly in the gutter.
1900
            let id = MapTileId {
1900
                z: z_int,
1900
                x: wrap_tile_x(x, tile_count),
1900
                y: y as u32,
1900
            };
            // Derive each tile's on-screen box from the ROUNDED origins of THIS
            // tile and the NEXT one along each axis, so neighbours always share an
            // exact edge — no gaps, no overlaps — at fractional zoom too. A fixed
            // `tile_px.round()` size drifts out of step with the per-tile rounded
            // origin the moment `tile_px` isn't a whole number (any non-integer
            // zoom, e.g. a scroll-wheel notch), scattering the tiles into a
            // disconnected grid. At integer zoom `tile_px` is exactly 256, so each
            // span is exactly 256 and this is identical to the previous behaviour.
7600
            let proj = |coord: f32, centre: f32, span_px: f32| {
7600
                ((coord - centre) * tile_px + span_px * 0.5).round() as i32
7600
            };
1900
            let screen_x = proj(x as f32, centre_x, width_px);
1900
            let screen_y = proj(y as f32, centre_y, height_px);
            // `saturating_sub`: `proj` ends in `as i32`, which SATURATES a
            // non-finite or out-of-range float to i32::MIN / i32::MAX. A viewport
            // whose zoom is `f32::INFINITY` therefore puts the two projected
            // origins at opposite ends of i32, and a plain `-` overflows — an
            // abort in an overflow-checked build (these run inside an `extern "C"`
            // render callback, so the panic does not unwind) and a wrapped,
            // nonsensical tile size in release.
1900
            let size_w = proj(x as f32 + 1.0, centre_x, width_px)
1900
                .saturating_sub(screen_x)
1900
                .max(1);
1900
            let size_h = proj(y as f32 + 1.0, centre_y, height_px)
1900
                .saturating_sub(screen_y)
1900
                .max(1);
            // Placeholder (still-loading) tiles show the loading grid — a grey
            // background + 1px border — so fetch state is visible. A LOADED tile
            // drops that chrome entirely: the decoded SVG covers the tile, and
            // keeping the per-tile border would draw a grey seam-grid over the
            // whole map (user-reported "small grey borders around the tiles").
1900
            let is_ready = matches!(states.get(&id), Some(TileDisplay::Svg(_)));
1900
            let chrome = if is_ready {
25
                ""
            } else {
1875
                "background: #e7e9ec; border: 1px solid #d0d4d9;"
            };
1900
            let style = alloc::format!(
1900
                "position: absolute; left: {screen_x}px; top: {screen_y}px; \
1900
                 width: {size_w}px; height: {size_h}px; {chrome}"
            );
1900
            let mut tile_div = Dom::create_div().with_css(style.as_str());
            // `Ready` tiles render their decoded SVG as a child DOM
            // tree (parsed via the framework's existing XML→DOM path);
            // everything else shows a state glyph + tile id so the grid
            // math + fetch state stay observable.
1900
            match states.get(&id) {
25
                Some(TileDisplay::Svg(svg)) => match svg_string_to_dom(svg.as_str()) {
                    Some(svg_dom) => {
                        tile_div = tile_div.with_child(svg_dom);
                    }
25
                    None => {
25
                        tile_div = tile_div.with_child(
25
                            Dom::create_p_with_text(alloc::format!("✓? z{z_int}/{x}/{y}"))
25
                                .with_css("position: absolute; left: 4px; top: 4px; font-size: 11px; color: #888;"),
25
                        );
25
                    }
                },
1875
                other => {
1875
                    let state_tag = match other {
1875
                        Some(TileDisplay::Glyph(g)) => *g,
                        _ => "",
                    };
1875
                    tile_div = tile_div.with_child(
1875
                        Dom::create_p_with_text(alloc::format!("{state_tag} z{z_int}/{x}/{y}"))
1875
                            .with_css("position: absolute; left: 4px; top: 4px; font-size: 11px; color: #888;"),
                    );
                }
            }
1900
            grid = grid.with_child(tile_div);
        }
    }
74
    VirtualViewReturn {
74
        dom: OptionDom::Some(grid),
74
        materialized: azul_core::geom::LogicalRect::new(azul_core::geom::LogicalPosition::zero(), bounds_logical),
74
        virtual_rect: azul_core::geom::LogicalRect::new(azul_core::geom::LogicalPosition::zero(), bounds_logical),
74
    }
83
}
#[cfg(test)]
mod tests {
    use super::*;
49
    fn approx(a: f64, b: f64, eps: f64) {
49
        assert!((a - b).abs() < eps, "expected {a} ≈ {b} (within {eps})");
49
    }
    #[test]
1
    fn wrap_lon_keeps_in_range() {
1
        approx(wrap_lon(0.0), 0.0, 1e-9);
1
        approx(wrap_lon(179.0), 179.0, 1e-9);
1
        approx(wrap_lon(-179.0), -179.0, 1e-9);
        // Past the antimeridian wraps to the other side.
1
        approx(wrap_lon(181.0), -179.0, 1e-9);
1
        approx(wrap_lon(-181.0), 179.0, 1e-9);
        // 540° ≡ 180° ≡ -180° — the antimeridian normalises to -180.
1
        approx(wrap_lon(540.0), -180.0, 1e-9);
        // Anything fed in must come out within [-180, 180].
5
        for raw in [-1234.5, -360.0, 360.0, 999.9] {
4
            let w = wrap_lon(raw);
4
            assert!((-180.0..=180.0).contains(&w), "{raw} → {w} out of range");
        }
1
    }
    #[test]
1
    fn build_tile_url_substitutes_zxy() {
1
        let tile = MapTileId { z: 11, x: 327, y: 791 };
1
        assert_eq!(
1
            build_tile_url("https://t.example/{z}/{x}/{y}.pbf", tile),
            "https://t.example/11/327/791.pbf"
        );
        // Repeated and out-of-order placeholders both resolve.
1
        assert_eq!(
1
            build_tile_url("{y}-{x}-{z}-{z}", MapTileId { z: 3, x: 4, y: 5 }),
            "5-4-3-3"
        );
1
    }
    #[test]
1
    fn lon_tile_endpoints() {
        // At zoom 0 the world is one tile: -180° → 0, +180° → 1.
1
        approx(lon_to_tile_x(-180.0, 1.0), 0.0, 1e-9);
1
        approx(lon_to_tile_x(180.0, 1.0), 1.0, 1e-9);
1
        approx(lon_to_tile_x(0.0, 1.0), 0.5, 1e-9);
        // Greenwich at zoom 1 (2 tiles wide) sits on the seam.
1
        approx(lon_to_tile_x(0.0, 2.0), 1.0, 1e-9);
1
    }
    #[test]
1
    fn lat_tile_equator_and_symmetry() {
        // Equator maps to the vertical centre of the map.
1
        approx(lat_to_tile_y(0.0, 1.0), 0.5, 1e-9);
        // North is above (smaller y) and is mirror-symmetric to south.
1
        let north = lat_to_tile_y(45.0, 1.0);
1
        let south = lat_to_tile_y(-45.0, 1.0);
1
        assert!(north < 0.5 && south > 0.5);
1
        approx(north + south, 1.0, 1e-9);
1
    }
    #[test]
    #[allow(clippy::cast_precision_loss)] // bounded layout/render numeric cast
1
    fn projection_round_trips() {
        // Forward then inverse must return the original coordinate, for
        // a handful of real-world points across several zooms.
1
        let points = [
1
            (37.7749, -122.4194), // San Francisco
1
            (51.5074, -0.1278),   // London
1
            (-33.8688, 151.2093), // Sydney
1
            (0.0, 0.0),           // null island
1
        ];
5
        for z in [0u32, 5, 11, 18] {
4
            let tc = (1u64 << z) as f64;
20
            for (lat, lon) in points {
16
                let x = lon_to_tile_x(lon, tc);
16
                let y = lat_to_tile_y(lat, tc);
16
                approx(tile_x_to_lon(x, tc), lon, 1e-6);
16
                approx(tile_y_to_lat(y, tc), lat, 1e-6);
16
            }
        }
1
    }
    #[test]
1
    fn pan_zero_drag_is_identity() {
        // No movement → centre unchanged (lon/lat already in range).
1
        let (lon, lat) = pan_viewport(37.0, -122.0, 11.0, 0.0, 0.0);
1
        approx(lon, -122.0, 1e-9);
1
        approx(lat, 37.0, 1e-9);
1
    }
    #[test]
1
    fn pan_right_decreases_longitude() {
        // Dragging content right (+dx) recentres on a lower longitude.
1
        let (lon, _) = pan_viewport(0.0, 0.0, 0.0, 100.0, 0.0);
1
        assert!(lon < 0.0, "drag right should lower longitude, got {lon}");
        // Dragging left (-dx) is the mirror.
1
        let (lon_left, _) = pan_viewport(0.0, 0.0, 0.0, -100.0, 0.0);
1
        approx(lon_left, -lon, 1e-9);
1
    }
    #[test]
1
    fn pan_step_scales_inversely_with_zoom() {
        // Each extra zoom level doubles the world size, so the same pixel
        // drag should move the centre half as far in degrees.
1
        let (lon_z0, _) = pan_viewport(0.0, 0.0, 0.0, 50.0, 0.0);
1
        let (lon_z1, _) = pan_viewport(0.0, 0.0, 1.0, 50.0, 0.0);
1
        approx(lon_z1, lon_z0 / 2.0, 1e-9);
1
    }
    #[test]
1
    fn pan_clamps_latitude_to_mercator_limit() {
        // A huge vertical drag can't push the centre past ±85°.
1
        let (_, lat_north) = pan_viewport(84.0, 0.0, 0.0, 0.0, 1.0e6);
1
        assert!((-85.0..=85.0).contains(&lat_north));
1
        let (_, lat_south) = pan_viewport(-84.0, 0.0, 0.0, 0.0, -1.0e6);
1
        assert!((-85.0..=85.0).contains(&lat_south));
1
    }
    #[test]
1
    fn pan_wraps_longitude_across_antimeridian() {
        // Starting near +180 and panning further east wraps into negatives
        // rather than producing an out-of-range longitude.
1
        let (lon, _) = pan_viewport(0.0, 179.0, 0.0, -100.0, 0.0);
1
        assert!((-180.0..180.0).contains(&lon), "lon {lon} out of range");
1
    }
5
    fn viewport_at(zoom: f32) -> MapViewport {
5
        MapViewport {
5
            centre_lat_deg: 0.0,
5
            centre_lon_deg: 0.0,
5
            zoom,
5
            bearing_deg: 0.0,
5
            pitch_deg: 0.0,
5
        }
5
    }
    #[test]
1
    fn merge_shares_old_cache_so_worker_writebacks_survive_relayout() {
        // THE regression behind the blank map: the merge must SHARE the previous
        // cache (the very `RefAny` the fetch-worker threads cloned at spawn), not
        // copy its tiles into a freshly-built one. With a copy, a tile that writes
        // back AFTER a relayout lands in the orphaned old cache and never renders.
        // Here we prove a post-merge writeback through a retained handle is
        // visible in the merged cache — i.e. they are one shared allocation.
1
        let tile = MapTileId { z: 5, x: 1, y: 2 };
1
        let old_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(5.0));
1
        let old_ref = RefAny::new(old_cache);
        // A worker thread keeps THIS clone and writes into it after the relayout.
1
        let mut worker_handle = old_ref.clone();
        // dom() rebuilds a fresh, empty cache (default viewport) each relayout.
1
        let new_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(9.0));
1
        let mut merged = merge_map_tile_cache(RefAny::new(new_cache), old_ref);
        // Worker finishes a fetch AFTER the merge and stamps the tile Ready on its
        // retained handle...
1
        worker_handle
1
            .downcast_mut::<MapTileCache>()
1
            .unwrap()
1
            .mark_tile_ready(tile, AzString::from("<svg/>"));
        // ...and it IS visible through the merged cache (shared storage). With the
        // old copy-merge this assertion failed — the tile was stranded.
1
        let g = merged.downcast_ref::<MapTileCache>().unwrap();
1
        assert!(
1
            g.tiles.contains_key(&tile),
            "a worker writeback after relayout must reach the rendered cache"
        );
1
    }
    #[test]
1
    fn merge_adopts_build_viewport_but_keeps_tiles() {
        // CONTRACT (changed 2026-06-10): `with_viewport()` is authoritative on
        // every rebuild. App callbacks (zoom buttons / Recentre / Locate)
        // mutate app state and RefreshDom; the old merge kept the persistent
        // cache's viewport "intact", silently discarding those changes — the
        // demo's +/− buttons fired but did nothing. Widget-internal drags stay
        // consistent because the on_viewport_changed hook mirrors them into
        // app state, which the next build passes back via with_viewport().
        // Tiles and the fetch worker stay with the persistent cache: workers
        // hold clones of that very RefAny, so writebacks keep landing in it.
1
        let mut old_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(5.0));
1
        old_cache.viewport.zoom = 7.0; // internal state from previous frames
1
        let tile = MapTileId { z: 2, x: 1, y: 1 };
1
        old_cache.tiles.insert(tile, TileEntry::Ready { svg: "<svg/>".into() });
1
        let new_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(2.0));
1
        let mut merged =
1
            merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(old_cache));
1
        let g = merged.downcast_ref::<MapTileCache>().unwrap();
        // The build's viewport wins…
1
        approx(f64::from(g.viewport.zoom), f64::from(viewport_at(2.0).zoom), 1e-6);
        // …while the fetched tiles survive in the same allocation.
1
        assert!(
1
            g.tiles.contains_key(&tile),
            "fetched tiles must survive the merge (workers write into this cache)"
        );
1
    }
    #[test]
1
    fn tile_range_covers_centre_with_margin() {
        // 512x512 viewport at zoom-scale 1 (256 px tiles) = 2 tiles across;
        // half-extent 2 (incl. the +1 margin) → 5 tiles each axis, centred.
1
        let (x0, x1, y0, y1) = visible_tile_range(8.0, 8.0, 512.0, 512.0, 1.0, 16);
1
        assert_eq!((x0, x1), (6, 10));
1
        assert_eq!((y0, y1), (6, 10));
1
    }
    #[test]
1
    fn wrap_tile_x_wraps_both_directions() {
        // rem_euclid semantics: west of the antimeridian wraps to the east side.
1
        assert_eq!(wrap_tile_x(-1, 4), 3);
1
        assert_eq!(wrap_tile_x(0, 4), 0);
1
        assert_eq!(wrap_tile_x(3, 4), 3);
1
        assert_eq!(wrap_tile_x(4, 4), 0);
1
        assert_eq!(wrap_tile_x(-5, 4), 3);
        // Single-tile world: every column resolves to the one tile.
1
        assert_eq!(wrap_tile_x(7, 1), 0);
1
        assert_eq!(wrap_tile_x(-3, 1), 0);
1
    }
    #[test]
1
    fn tile_range_y_clamps_but_x_wraps_at_zoom0() {
        // zoom 0 → tile_count 1. y stays pinned to row 0 (no data past the
        // poles); x is unclamped (the column over-scans to fill the width) but
        // every column wraps to the single tile.
1
        let (x0, x1, y0, y1) = visible_tile_range(0.5, 0.5, 256.0, 256.0, 1.0, 1);
1
        assert_eq!((y0, y1), (0, 0));
4
        for x in x0..=x1 {
4
            assert_eq!(wrap_tile_x(x, 1), 0);
        }
1
    }
    #[test]
1
    fn tile_range_widens_with_viewport() {
1
        let (nx0, nx1, ..) = visible_tile_range(8.0, 8.0, 512.0, 512.0, 1.0, 16);
1
        let (wx0, wx1, ..) = visible_tile_range(8.0, 8.0, 1024.0, 512.0, 1.0, 16);
1
        assert!(
1
            (wx1 - wx0) > (nx1 - nx0),
            "a wider viewport must request more columns"
        );
1
    }
    #[test]
1
    fn tile_range_clamps_y_but_wraps_x_at_edges() {
        // y is clamped to the valid band at both poles (no over-scan past the
        // Web-Mercator edges)…
1
        let (x0, _, y0, _) = visible_tile_range(0.0, 0.0, 512.0, 512.0, 1.0, 16);
1
        assert!(y0 >= 0);
1
        let (_, x1, _, y1) = visible_tile_range(15.0, 15.0, 512.0, 512.0, 1.0, 16);
1
        assert!(y1 <= 15);
        // …but x is unclamped so the world wraps: a west-edge viewport over-scans
        // into negative columns and an east-edge one past tile_count-1; both wrap
        // back into 0..tile_count via wrap_tile_x.
1
        assert!(x0 < 0, "west-edge viewport should over-scan into wrapped columns");
1
        assert!(x1 > 15, "east-edge viewport should over-scan into wrapped columns");
1
        assert_eq!(wrap_tile_x(x0, 16), x0.rem_euclid(16) as u32);
1
        assert_eq!(wrap_tile_x(x1, 16), x1.rem_euclid(16) as u32);
1
    }
2
    fn test_cache() -> MapTileCache {
2
        let layer = MapTileLayer {
2
            url_template: AzString::from("{z}/{x}/{y}"),
2
            min_zoom: 0,
2
            max_zoom: 19,
2
            attribution: AzString::from(""),
2
            style_css: AzString::from(""),
2
        };
2
        let viewport = MapViewport {
2
            centre_lat_deg: 0.0,
2
            centre_lon_deg: 0.0,
2
            zoom: 4.0,
2
            bearing_deg: 0.0,
2
            pitch_deg: 0.0,
2
        };
2
        MapTileCache::new(layer, viewport)
2
    }
    #[test]
1
    fn prune_evicts_distant_tiles_keeps_near_and_inflight() {
1
        let mut cache = test_cache();
        // Centre at z4 is tile (8, 8). Fill a big z4 grid (Ready) — far more than
        // the 192 cap — plus a near Pending tile and a near Ready tile.
21
        for x in 0..20u32 {
420
            for y in 0..20u32 {
400
                cache
400
                    .tiles
400
                    .insert(MapTileId { z: 4, x, y }, TileEntry::Ready { svg: AzString::from("<svg/>") });
400
            }
        }
        // A near, in-flight tile (must NEVER be evicted).
1
        cache.tiles.insert(MapTileId { z: 4, x: 8, y: 8 }, TileEntry::Pending);
        // A near, ready tile (should survive — low distance score).
1
        cache.tiles.insert(MapTileId { z: 4, x: 9, y: 8 }, TileEntry::Ready { svg: AzString::from("<svg/>") });
        // A very far ready tile (should be evicted first).
1
        cache.tiles.insert(MapTileId { z: 4, x: 0, y: 0 }, TileEntry::Ready { svg: AzString::from("<svg/>") });
1
        assert!(cache.tiles.len() > 192, "precondition: over the cap");
1
        cache.prune_distant_tiles();
1
        assert!(cache.tiles.len() <= 192, "cache must be bounded after prune");
        // In-flight tile survives.
1
        assert!(matches!(
1
            cache.tiles.get(&MapTileId { z: 4, x: 8, y: 8 }),
            Some(TileEntry::Pending)
        ));
        // Near tile survives; the corner tile is gone.
1
        assert!(cache.tiles.contains_key(&MapTileId { z: 4, x: 9, y: 8 }));
1
        assert!(!cache.tiles.contains_key(&MapTileId { z: 4, x: 0, y: 0 }));
1
    }
    #[test]
1
    fn prune_is_noop_under_cap() {
1
        let mut cache = test_cache();
5
        for x in 0..4u32 {
4
            cache
4
                .tiles
4
                .insert(MapTileId { z: 4, x, y: 8 }, TileEntry::Ready { svg: AzString::from("<svg/>") });
4
        }
1
        cache.prune_distant_tiles();
1
        assert_eq!(cache.tiles.len(), 4, "under the cap → nothing evicted");
1
    }
}
// ────────── Adversarial autotest coverage ────────────────────────────
//
// Boundary / malformed / overflow probes for the widget's pure numeric core,
// its builders, and its callback surface. Everything here is deliberately fed
// values a real app can produce (a zero-size container, a NaN viewport, a
// tile id past the antimeridian, a garbage tile payload) and asserts the
// function *contains* them rather than panicking.
#[cfg(test)]
#[allow(
    clippy::cast_possible_truncation,
    clippy::cast_precision_loss,
    clippy::cast_sign_loss,
    clippy::cast_possible_wrap,
    clippy::float_cmp,
    clippy::too_many_lines,
    clippy::unreadable_literal
)]
mod autotest_generated {
    use std::sync::{Arc, Mutex};
    use azul_core::{
        callbacks::{HidpiAdjustedBounds, VirtualViewCallbackReason},
        dom::{DomId, DomNodeId},
        geom::{LogicalPosition, LogicalSize, OptionLogicalPosition},
        gl::OptionGlContextPtr,
        hit_test::ScrollPosition,
        resources::{DpiScaleFactor, ImageCache, RendererResources},
        styled_dom::NodeHierarchyItemId,
        window::{MonitorVec, RawWindowHandle, WindowTheme},
    };
    use azul_css::system::SystemStyle;
    use rust_fontconfig::FcFontCache;
    use super::*;
    #[cfg(feature = "icu")]
    use crate::icu::IcuLocalizerHandle;
    use azul_core::task::ThreadReceiver;
    use crate::{
        callbacks::{CallbackChange, CallbackInfoRefData, ExternalSystemCallbacks},
        thread::{ThreadCallback, ThreadCallbackType, ThreadSender},
        window::LayoutWindow,
        window_state::FullWindowState,
    };
    // ------------------------------------------------------------------
    // Helpers
    // ------------------------------------------------------------------
    fn close(a: f64, b: f64, eps: f64) {
        assert!((a - b).abs() <= eps, "expected {a} ≈ {b} (within {eps})");
    }
    fn layer_zoom(min_zoom: u8, max_zoom: u8) -> MapTileLayer {
        MapTileLayer {
            url_template: AzString::from("https://tiles.invalid/{z}/{x}/{y}.pbf"),
            min_zoom,
            max_zoom,
            attribution: AzString::from("attr"),
            style_css: AzString::from(""),
        }
    }
    fn view(lat: f64, lon: f64, zoom: f32) -> MapViewport {
        MapViewport {
            centre_lat_deg: lat,
            centre_lon_deg: lon,
            zoom,
            bearing_deg: 0.0,
            pitch_deg: 0.0,
        }
    }
    fn cache_at(lat: f64, lon: f64, zoom: f32) -> MapTileCache {
        MapTileCache::new(layer_zoom(0, 19), view(lat, lon, zoom))
    }
    /// Records everything the widget's user hooks are handed.
    #[derive(Default)]
    struct HookLog {
        viewports: Vec<MapViewport>,
        coords: Vec<MapLatLon>,
    }
    extern "C" fn record_viewport(
        mut data: RefAny,
        _: CallbackInfo,
        viewport: MapViewport,
    ) -> Update {
        if let Some(mut log) = data.downcast_mut::<HookLog>() {
            log.viewports.push(viewport);
        }
        Update::DoNothing
    }
    extern "C" fn record_pin(mut data: RefAny, _: CallbackInfo, coord: MapLatLon) -> Update {
        if let Some(mut log) = data.downcast_mut::<HookLog>() {
            log.coords.push(coord);
        }
        Update::RefreshDom
    }
    /// A worker that returns immediately - enough to exercise the spawn path
    /// without any I/O.
    extern "C" fn noop_worker(_: RefAny, _: ThreadSender, _: ThreadReceiver) {}
    extern "C" fn other_noop_worker(_: RefAny, _: ThreadSender, _: ThreadReceiver) {}
    fn hook_log(data: &mut RefAny) -> (usize, usize) {
        let log = data
            .downcast_ref::<HookLog>()
            .expect("payload must still be a HookLog");
        (log.viewports.len(), log.coords.len())
    }
    /// Runs `f` against a real `CallbackInfo` over an empty `LayoutWindow`,
    /// with `cursor` reported as the cursor position relative to the hit node.
    /// Returns `f`'s value plus every `CallbackChange` the callback recorded.
    fn with_callback_info_at<R>(
        cursor: OptionLogicalPosition,
        f: impl FnOnce(CallbackInfo) -> R,
    ) -> (R, Vec<CallbackChange>) {
        let layout_window =
            LayoutWindow::new(FcFontCache::default()).expect("LayoutWindow::new failed");
        let renderer_resources = RendererResources::default();
        let previous_window_state: Option<FullWindowState> = None;
        let current_window_state = FullWindowState::default();
        let gl_context = OptionGlContextPtr::None;
        let scroll_states: BTreeMap<
            DomId,
            BTreeMap<NodeHierarchyItemId, ScrollPosition>,
        > = BTreeMap::new();
        let window_handle = RawWindowHandle::Unsupported;
        let system_callbacks = ExternalSystemCallbacks::rust_internal();
        let ref_data = CallbackInfoRefData {
            layout_window: &layout_window,
            renderer_resources: &renderer_resources,
            previous_window_state: &previous_window_state,
            current_window_state: &current_window_state,
            gl_context: &gl_context,
            current_scroll_manager: &scroll_states,
            current_window_handle: &window_handle,
            system_callbacks: &system_callbacks,
            system_style: Arc::new(SystemStyle::default()),
            monitors: Arc::new(Mutex::new(MonitorVec::from_const_slice(&[]))),
            #[cfg(feature = "icu")]
            icu_localizer: IcuLocalizerHandle::default(),
            ctx: OptionRefAny::None,
        };
        let changes: Arc<Mutex<Vec<CallbackChange>>> = Arc::new(Mutex::new(Vec::new()));
        let info = CallbackInfo::new(
            &ref_data,
            &changes,
            DomNodeId {
                dom: DomId::ROOT_ID,
                node: NodeHierarchyItemId::NONE,
            },
            cursor,
            OptionLogicalPosition::None,
        );
        let out = f(info);
        let recorded = core::mem::take(&mut *changes.lock().expect("change log poisoned"));
        (out, recorded)
    }
    fn with_callback_info<R>(f: impl FnOnce(CallbackInfo) -> R) -> (R, Vec<CallbackChange>) {
        with_callback_info_at(OptionLogicalPosition::None, f)
    }
    fn cursor_at(x: f32, y: f32) -> OptionLogicalPosition {
        OptionLogicalPosition::Some(LogicalPosition::new(x, y))
    }
    /// Runs `f` against a `VirtualViewCallbackInfo` reporting `w x h` bounds.
    fn with_virtual_view_info<R>(
        w: f32,
        h: f32,
        f: impl FnOnce(VirtualViewCallbackInfo) -> R,
    ) -> R {
        let fonts = FcFontCache::default();
        let images = ImageCache::default();
        let size = LogicalSize::new(w, h);
        let info = VirtualViewCallbackInfo::new(
            VirtualViewCallbackReason::InitialRender,
            &fonts,
            &images,
            WindowTheme::LightMode,
            HidpiAdjustedBounds {
                logical_size: size,
                hidpi_factor: DpiScaleFactor::new(1.0),
            },
            azul_core::geom::LogicalRect::new(LogicalPosition::zero(), size),
            azul_core::geom::LogicalRect::new(LogicalPosition::zero(), size),
            LogicalPosition::zero(),
        );
        f(info)
    }
    fn rendered_child_count(ret: &VirtualViewReturn) -> Option<usize> {
        match &ret.dom {
            OptionDom::Some(d) => Some(d.children.as_slice().len()),
            OptionDom::None => None,
        }
    }
    // ==================================================================
    // wrap_lon  (numeric)
    // ==================================================================
    #[test]
    fn wrap_lon_zero_and_negative_zero_are_zero() {
        assert_eq!(wrap_lon(0.0), 0.0);
        assert_eq!(wrap_lon(-0.0), 0.0);
    }
    #[test]
    fn wrap_lon_nan_and_infinities_are_nan_not_panic() {
        // `rem_euclid` on a non-finite dividend is NaN (fmod(inf, x) == NaN);
        // the documented, non-panicking outcome.
        assert!(wrap_lon(f64::NAN).is_nan());
        assert!(wrap_lon(f64::INFINITY).is_nan());
        assert!(wrap_lon(f64::NEG_INFINITY).is_nan());
    }
    #[test]
    fn wrap_lon_extreme_finite_inputs_stay_bounded_and_finite() {
        for raw in [
            f64::MAX,
            f64::MIN,
            f64::MIN_POSITIVE,
            -f64::MIN_POSITIVE,
            1.0e300,
            -1.0e300,
            1.0e18,
            -1.0e18,
            360.0 * 1.0e9,
        ] {
            let w = wrap_lon(raw);
            assert!(w.is_finite(), "{raw} → {w} is not finite");
            assert!((-180.0..=180.0).contains(&w), "{raw} → {w} out of range");
        }
    }
    #[test]
    fn wrap_lon_is_idempotent_on_representative_inputs() {
        for (raw, expected) in [
            (0.0_f64, 0.0_f64),
            (45.0, 45.0),
            (-45.0, -45.0),
            (181.0, -179.0),
            (-181.0, 179.0),
            (720.0, 0.0),
            (-720.0, 0.0),
            (1.0e6, -80.0),
        ] {
            let once = wrap_lon(raw);
            close(once, expected, 1e-9);
            close(wrap_lon(once), once, 1e-9);
        }
    }
    // ==================================================================
    // lon_to_tile_x / tile_x_to_lon  (numeric)
    // ==================================================================
    #[test]
    fn lon_to_tile_x_zero_tile_count_collapses_to_zero() {
        for lon in [-180.0, -1.0, 0.0, 1.0, 180.0] {
            assert_eq!(lon_to_tile_x(lon, 0.0), 0.0, "lon {lon} at tile_count 0");
        }
    }
    #[test]
    fn lon_to_tile_x_nan_inf_are_defined_not_panics() {
        assert!(lon_to_tile_x(f64::NAN, 4.0).is_nan());
        assert!(lon_to_tile_x(0.0, f64::NAN).is_nan());
        assert_eq!(lon_to_tile_x(f64::INFINITY, 4.0), f64::INFINITY);
        assert_eq!(lon_to_tile_x(f64::NEG_INFINITY, 4.0), f64::NEG_INFINITY);
        // inf * 0 is the one genuinely undefined product → NaN, not a panic.
        assert!(lon_to_tile_x(f64::INFINITY, 0.0).is_nan());
    }
    #[test]
    fn lon_to_tile_x_is_monotonic_and_saturates_on_huge_counts() {
        let mut prev = f64::NEG_INFINITY;
        for lon in [-180.0, -90.0, -0.5, 0.0, 0.5, 90.0, 180.0] {
            let x = lon_to_tile_x(lon, 256.0);
            assert!(x > prev, "lon_to_tile_x must increase with longitude");
            prev = x;
        }
        assert_eq!(lon_to_tile_x(180.0, f64::MAX), f64::MAX);
        assert!(lon_to_tile_x(180.0, f64::INFINITY).is_infinite());
    }
    #[test]
    fn tile_x_to_lon_degenerate_tile_counts_do_not_panic() {
        // 0/0 is the only NaN; a non-zero column over a zero-wide world is +inf.
        assert!(tile_x_to_lon(0.0, 0.0).is_nan());
        assert!(tile_x_to_lon(1.0, 0.0).is_infinite());
        assert!(tile_x_to_lon(f64::NAN, 4.0).is_nan());
        assert!(tile_x_to_lon(f64::MAX, f64::MIN_POSITIVE).is_infinite());
    }
    #[test]
    fn lon_tile_x_round_trips_across_zooms_and_edges() {
        for z in [0u32, 1, 5, 14, 22] {
            let tc = f64::from(1u32 << z);
            for lon in [-180.0, -179.999, -122.4194, 0.0, 0.1, 151.2093, 180.0] {
                let x = lon_to_tile_x(lon, tc);
                close(tile_x_to_lon(x, tc), lon, 1e-9);
            }
        }
    }
    // ==================================================================
    // lat_to_tile_y / tile_y_to_lat  (numeric)
    // ==================================================================
    #[test]
    fn lat_to_tile_y_inside_the_mercator_band_is_finite_and_ordered() {
        let tc = 256.0;
        let mut prev = f64::NEG_INFINITY;
        // y grows southward, so iterate north → south and expect a rise.
        for lat in [85.0, 60.0, 30.0, 0.0, -30.0, -60.0, -85.0] {
            let y = lat_to_tile_y(lat, tc);
            assert!(y.is_finite(), "lat {lat} → {y}");
            assert!((0.0..=tc).contains(&y), "lat {lat} → {y} outside the grid");
            assert!(y > prev, "tile-y must increase as latitude decreases");
            prev = y;
        }
    }
    #[test]
    fn lat_to_tile_y_nan_and_infinite_latitudes_are_nan() {
        assert!(lat_to_tile_y(f64::NAN, 4.0).is_nan());
        // tan(±inf) is NaN, so the whole Mercator term degrades to NaN.
        assert!(lat_to_tile_y(f64::INFINITY, 4.0).is_nan());
        assert!(lat_to_tile_y(f64::NEG_INFINITY, 4.0).is_nan());
        assert!(lat_to_tile_y(0.0, f64::NAN).is_nan());
    }
    #[test]
    fn lat_to_tile_y_past_the_poles_does_not_panic() {
        // Beyond ±85.05° the projection is undefined; assert only that every
        // one of these returns (reaching the length check means no panic).
        let outs: Vec<f64> = [90.0, -90.0, 89.9999, -89.9999, 180.0, -180.0, 1.0e9]
            .iter()
            .map(|lat| lat_to_tile_y(*lat, 4.0))
            .collect();
        assert_eq!(outs.len(), 7);
    }
    #[test]
    fn tile_y_to_lat_saturates_at_the_poles_for_out_of_range_rows() {
        for (y, expected) in [(-1.0e9_f64, 90.0_f64), (1.0e9, -90.0)] {
            let lat = tile_y_to_lat(y, 4.0);
            close(lat, expected, 1e-9);
        }
        // No finite row can ever escape ±90°.
        for y in [-1.0e300, -1000.0, -1.0, 0.0, 2.0, 1000.0, 1.0e300] {
            let lat = tile_y_to_lat(y, 4.0);
            assert!(
                (-90.0 - 1e-9..=90.0 + 1e-9).contains(&lat),
                "row {y} → {lat} outside ±90"
            );
        }
    }
    #[test]
    fn tile_y_to_lat_degenerate_tile_counts_do_not_panic() {
        assert!(tile_y_to_lat(0.0, 0.0).is_nan()); // 0/0
        close(tile_y_to_lat(1.0, 0.0), -90.0, 1e-9); // +inf rows south
        assert!(tile_y_to_lat(f64::NAN, 4.0).is_nan());
    }
    #[test]
    fn lat_tile_y_round_trips_at_the_mercator_edges() {
        for z in [0u32, 3, 14, 22] {
            let tc = f64::from(1u32 << z);
            for lat in [-85.05, -45.0, -0.0001, 0.0, 0.0001, 45.0, 85.05] {
                let y = lat_to_tile_y(lat, tc);
                close(tile_y_to_lat(y, tc), lat, 1e-6);
            }
        }
    }
    // ==================================================================
    // pan_viewport  (numeric)
    // ==================================================================
    #[test]
    fn pan_viewport_nan_inputs_propagate_without_panicking() {
        let (lon, lat) = pan_viewport(f64::NAN, 0.0, 2.0, 10.0, 10.0);
        assert!(lat.is_nan(), "NaN centre latitude must stay NaN, got {lat}");
        assert!(lon.is_nan() || (-180.0..=180.0).contains(&lon));
        let (lon, _) = pan_viewport(0.0, f64::NAN, 2.0, 10.0, 10.0);
        assert!(lon.is_nan());
        let (lon, lat) = pan_viewport(0.0, 0.0, 2.0, f64::NAN, f64::NAN);
        assert!(lon.is_nan() && lat.is_nan());
    }
    #[test]
    fn pan_viewport_infinite_zoom_is_a_no_op() {
        // world_px = inf → every pixel delta maps to a zero angular delta.
        let (lon, lat) = pan_viewport(37.0, -122.0, f64::INFINITY, 1.0e6, -1.0e6);
        close(lon, -122.0, 1e-9);
        close(lat, 37.0, 1e-9);
    }
    #[test]
    fn pan_viewport_negative_infinite_zoom_saturates_latitude_not_panics() {
        // world_px underflows to 0 → the longitude delta is ±inf (→ NaN through
        // wrap_lon) and the latitude delta saturates against the ±85 clamp.
        let (lon, lat) = pan_viewport(0.0, 0.0, f64::NEG_INFINITY, 100.0, 100.0);
        assert!(lon.is_nan(), "expected NaN longitude, got {lon}");
        close(lat, 85.0, 1e-9);
        let (_, lat_south) = pan_viewport(0.0, 0.0, f64::NEG_INFINITY, 0.0, -100.0);
        close(lat_south, -85.0, 1e-9);
    }
    #[test]
    fn pan_viewport_extreme_pixel_deltas_stay_inside_the_world() {
        for dx in [-1.0e18_f64, -1.0e9, -1.0, 0.0, 1.0, 1.0e9, 1.0e18] {
            for dy in [-1.0e18_f64, 0.0, 1.0e18] {
                let (lon, lat) = pan_viewport(37.0, -122.0, 0.0, dx, dy);
                assert!(lon.is_finite(), "dx {dx} dy {dy} → lon {lon}");
                assert!((-180.0..=180.0).contains(&lon), "lon {lon} out of range");
                assert!((-85.0..=85.0).contains(&lat), "lat {lat} out of range");
            }
        }
    }
    #[test]
    fn pan_viewport_zero_zoom_and_zero_delta_is_the_identity() {
        let (lon, lat) = pan_viewport(0.0, 0.0, 0.0, 0.0, 0.0);
        assert_eq!((lon, lat), (0.0, 0.0));
    }
    #[test]
    fn pan_viewport_latitude_step_shrinks_towards_the_poles() {
        // d_lat carries a cos(lat) factor, so the same drag moves less near a pole.
        let (_, at_equator) = pan_viewport(0.0, 0.0, 2.0, 0.0, 100.0);
        let (_, at_80) = pan_viewport(80.0, 0.0, 2.0, 0.0, 100.0);
        assert!(
            (at_80 - 80.0).abs() < at_equator.abs(),
            "pole-adjacent pan {at_80} must move less than equatorial {at_equator}"
        );
    }
    // ==================================================================
    // MapWidget::latlon_at_px / px_at_latlon  (numeric)
    // ==================================================================
    #[test]
    fn latlon_at_px_centre_pixel_is_the_viewport_centre() {
        let viewport = view(51.5074, -0.1278, 11.0);
        let container = LogicalSize::new(800.0, 600.0);
        let coord = MapWidget::latlon_at_px(
            viewport,
            LogicalPosition::new(400.0, 300.0),
            container,
        );
        close(coord.lat_deg, 51.5074, 1e-9);
        close(coord.lon_deg, -0.1278, 1e-9);
    }
    #[test]
    fn latlon_at_px_result_is_always_clamped_or_nan() {
        let container = LogicalSize::new(800.0, 600.0);
        for zoom in [0.0_f32, 2.0, 11.0, 22.0] {
            for px in [
                LogicalPosition::new(0.0, 0.0),
                LogicalPosition::new(-1.0e9, -1.0e9),
                LogicalPosition::new(1.0e9, 1.0e9),
                LogicalPosition::new(f32::MAX, f32::MIN),
            ] {
                let c = MapWidget::latlon_at_px(view(0.0, 0.0, zoom), px, container);
                assert!(
                    (-180.0..=180.0).contains(&c.lon_deg),
                    "zoom {zoom} px {px:?} → lon {}",
                    c.lon_deg
                );
                assert!(
                    (-85.0..=85.0).contains(&c.lat_deg),
                    "zoom {zoom} px {px:?} → lat {}",
                    c.lat_deg
                );
            }
        }
    }
    #[test]
    fn latlon_at_px_non_finite_zoom_does_not_panic() {
        let container = LogicalSize::new(800.0, 600.0);
        let px = LogicalPosition::new(10.0, 10.0);
        // +inf zoom → infinitely large world → the centre pixel wins.
        let c = MapWidget::latlon_at_px(view(0.0, 0.0, f32::INFINITY), px, container);
        close(c.lon_deg, 0.0, 1e-9);
        close(c.lat_deg, 0.0, 1e-9);
        // -inf zoom → zero-size world → the clamp saturates instead of overflowing.
        let c = MapWidget::latlon_at_px(view(0.0, 0.0, f32::NEG_INFINITY), px, container);
        assert!((-180.0..=180.0).contains(&c.lon_deg));
        assert!((-85.0..=85.0).contains(&c.lat_deg));
        // NaN zoom → NaN out, never a panic.
        let c = MapWidget::latlon_at_px(view(0.0, 0.0, f32::NAN), px, container);
        assert!(c.lon_deg.is_nan() && c.lat_deg.is_nan());
    }
    #[test]
    fn latlon_at_px_zero_sized_container_is_still_defined() {
        let c = MapWidget::latlon_at_px(
            view(10.0, 20.0, 4.0),
            LogicalPosition::new(0.0, 0.0),
            LogicalSize::new(0.0, 0.0),
        );
        close(c.lat_deg, 10.0, 1e-9);
        close(c.lon_deg, 20.0, 1e-9);
    }
    #[test]
    fn px_at_latlon_centre_coord_is_the_container_centre() {
        let viewport = view(37.7749, -122.4194, 12.0);
        let container = LogicalSize::new(1024.0, 768.0);
        let p = MapWidget::px_at_latlon(
            viewport,
            MapLatLon {
                lat_deg: viewport.centre_lat_deg,
                lon_deg: viewport.centre_lon_deg,
            },
            container,
        );
        close(f64::from(p.x), 512.0, 1e-3);
        close(f64::from(p.y), 384.0, 1e-3);
    }
    #[test]
    fn px_at_latlon_saturates_instead_of_overflowing_f32() {
        // world = 256 * 2^f32::MAX overflows to +inf; the f64→f32 cast must
        // saturate (Rust's `as` is saturating) rather than trap.
        let container = LogicalSize::new(800.0, 600.0);
        let p = MapWidget::px_at_latlon(
            view(0.0, 0.0, f32::MAX),
            MapLatLon {
                lat_deg: 10.0,
                lon_deg: 10.0,
            },
            container,
        );
        assert!(!p.x.is_finite(), "expected a saturated x, got {}", p.x);
        assert!(!p.y.is_finite(), "expected a saturated y, got {}", p.y);
    }
    #[test]
    fn px_at_latlon_at_a_pole_centre_does_not_panic() {
        // cos(90°) is ~6e-17, not exactly 0 — the division is huge but finite.
        let container = LogicalSize::new(800.0, 600.0);
        let p = MapWidget::px_at_latlon(
            view(90.0, 0.0, 2.0),
            MapLatLon {
                lat_deg: 0.0,
                lon_deg: 0.0,
            },
            container,
        );
        assert!(p.x.is_finite(), "x should stay finite, got {}", p.x);
        assert!(!p.y.is_nan(), "y must be a number or an infinity, got {}", p.y);
    }
    #[test]
    fn projection_px_round_trips_within_the_clamped_band() {
        let container = LogicalSize::new(800.0, 600.0);
        for zoom in [2.0_f32, 8.0, 14.0] {
            let viewport = view(37.7749, -122.4194, zoom);
            for (dlat, dlon) in [(0.0, 0.0), (0.01, 0.02), (-0.03, 0.04)] {
                let coord = MapLatLon {
                    lat_deg: viewport.centre_lat_deg + dlat,
                    lon_deg: viewport.centre_lon_deg + dlon,
                };
                let px = MapWidget::px_at_latlon(viewport, coord, container);
                let back = MapWidget::latlon_at_px(viewport, px, container);
                close(back.lat_deg, coord.lat_deg, 1e-4);
                close(back.lon_deg, coord.lon_deg, 1e-4);
            }
        }
    }
    // ==================================================================
    // visible_tile_range  (numeric)
    // ==================================================================
    #[test]
    fn visible_tile_range_zero_zoom_scale_saturates_to_the_i32_extremes() {
        // tile_px = 0 → the half-extent is +inf → the floor/ceil casts saturate.
        // x is unclamped (world wrap), so the caller receives the FULL i32 span.
        let (x0, x1, y0, y1) = visible_tile_range(8.0, 8.0, 800.0, 600.0, 0.0, 16);
        assert_eq!((x0, x1), (i32::MIN, i32::MAX));
        assert_eq!((y0, y1), (0, 15));
    }
    #[test]
    fn visible_tile_range_infinite_dimensions_saturate_the_same_way() {
        let (x0, x1, y0, y1) =
            visible_tile_range(8.0, 8.0, f32::INFINITY, f32::INFINITY, 1.0, 16);
        assert_eq!((x0, x1), (i32::MIN, i32::MAX));
        assert_eq!((y0, y1), (0, 15));
    }
    #[test]
    fn visible_tile_range_nan_inputs_collapse_to_a_single_cell() {
        // `NaN as i32` is 0 in Rust (saturating cast), so a non-finite viewport
        // degenerates to the (0,0) cell instead of an unbounded loop.
        assert_eq!(
            visible_tile_range(8.0, 8.0, f32::NAN, f32::NAN, 1.0, 16),
            (0, 0, 0, 0)
        );
        assert_eq!(
            visible_tile_range(f32::NAN, f32::NAN, 512.0, 512.0, 1.0, 16),
            (0, 0, 0, 0)
        );
        assert_eq!(
            visible_tile_range(8.0, 8.0, 512.0, 512.0, f32::NAN, 16),
            (0, 0, 0, 0)
        );
    }
    #[test]
    fn visible_tile_range_negative_dimensions_are_taken_absolutely() {
        let positive = visible_tile_range(8.0, 8.0, 512.0, 384.0, 1.0, 16);
        let negative = visible_tile_range(8.0, 8.0, -512.0, -384.0, 1.0, 16);
        assert_eq!(positive, negative);
    }
    #[test]
    fn visible_tile_range_zero_tile_count_yields_an_empty_row_span() {
        // max_idx = -1, so y_min (>= 0) is above y_max — the caller's
        // `for y in y_min..=y_max` loop body never runs. No tiles, no panic.
        let (_, _, y0, y1) = visible_tile_range(0.0, 0.0, 512.0, 512.0, 1.0, 0);
        assert!(y0 > y1, "expected an empty row span, got {y0}..={y1}");
    }
    #[test]
    fn visible_tile_range_u32_max_tile_count_wraps_to_an_empty_row_span() {
        // `u32::MAX as i32` is -1 → max_idx -2 → again an empty (safe) span.
        let (_, _, y0, y1) = visible_tile_range(0.0, 0.0, 512.0, 512.0, 1.0, u32::MAX);
        assert!(y0 > y1, "expected an empty row span, got {y0}..={y1}");
    }
    #[test]
    fn visible_tile_range_always_keeps_a_one_tile_margin() {
        // Even a 1x1-pixel viewport must over-scan by a whole tile each side.
        let (x0, x1, y0, y1) = visible_tile_range(8.0, 8.0, 1.0, 1.0, 1.0, 16);
        assert!(x0 <= 7 && x1 >= 9, "x span {x0}..={x1} lost its margin");
        assert!(y0 <= 7 && y1 >= 9, "y span {y0}..={y1} lost its margin");
    }
    #[test]
    fn visible_tile_range_extreme_zoom_scale_shrinks_to_the_margin() {
        // A gigantic tile_px makes the viewport sub-tile: only the margin remains.
        let (x0, x1, y0, y1) = visible_tile_range(8.0, 8.0, 800.0, 600.0, f32::MAX, 16);
        assert_eq!((x0, x1), (7, 9));
        assert_eq!((y0, y1), (7, 9));
    }
    // ==================================================================
    // wrap_tile_x  (numeric)
    // ==================================================================
    #[test]
    fn wrap_tile_x_zero_tile_count_is_zero_never_a_division_by_zero() {
        for x in [i32::MIN, -7, -1, 0, 1, 7, i32::MAX] {
            assert_eq!(wrap_tile_x(x, 0), 0, "column {x} at tile_count 0");
        }
    }
    #[test]
    fn wrap_tile_x_extremes_stay_inside_the_band() {
        for tile_count in [1u32, 2, 4, 256, 65_536, 1 << 30, 1 << 31] {
            for x in [i32::MIN, -1_000_000, -1, 0, 1, 1_000_000, i32::MAX] {
                let wrapped = wrap_tile_x(x, tile_count);
                assert!(
                    wrapped < tile_count,
                    "column {x} at tile_count {tile_count} → {wrapped} (out of band)"
                );
            }
        }
    }
    #[test]
    fn wrap_tile_x_matches_rem_euclid_for_realistic_zooms() {
        for z in 0u32..=20 {
            let tile_count = 1u32 << z;
            for x in [i32::MIN, -3, -1, 0, 1, 3, i32::MAX] {
                assert_eq!(
                    wrap_tile_x(x, tile_count),
                    x.rem_euclid(tile_count as i32) as u32
                );
            }
        }
    }
    #[test]
    fn wrap_tile_x_is_periodic_in_tile_count() {
        for tile_count in [1u32, 2, 4, 16, 1024] {
            for x in [-9i32, -1, 0, 1, 9] {
                let shifted = x
                    .checked_add(tile_count as i32)
                    .expect("shift must stay in range");
                assert_eq!(wrap_tile_x(x, tile_count), wrap_tile_x(shifted, tile_count));
            }
        }
    }
    // ==================================================================
    // map_visible_tiles  (numeric)
    // ==================================================================
    #[test]
    fn map_visible_tiles_zero_bounds_still_covers_the_centre() {
        let layer = MapTileLayer::default();
        let tiles = map_visible_tiles(&view(0.0, 0.0, 2.0), LogicalSize::new(0.0, 0.0), &layer);
        assert!(!tiles.is_empty(), "the one-tile margin must survive 0x0 bounds");
        for t in &tiles {
            assert_eq!(t.z, 2);
            assert!(t.x < 4 && t.y < 4, "tile {t:?} escaped the z2 grid");
        }
    }
    #[test]
    fn map_visible_tiles_non_finite_bounds_degenerate_to_one_tile() {
        let layer = MapTileLayer::default();
        let tiles = map_visible_tiles(
            &view(0.0, 0.0, 2.0),
            LogicalSize::new(f32::NAN, f32::NAN),
            &layer,
        );
        assert_eq!(tiles.len(), 1, "NaN bounds must not enumerate a grid");
    }
    #[test]
    fn map_visible_tiles_nan_zoom_degenerates_to_one_tile() {
        let layer = MapTileLayer::default();
        let tiles = map_visible_tiles(
            &view(0.0, 0.0, f32::NAN),
            LogicalSize::new(800.0, 600.0),
            &layer,
        );
        assert_eq!(tiles.len(), 1);
        assert_eq!(tiles[0].z, 0, "a NaN zoom clamps to the layer minimum");
    }
    #[test]
    fn map_visible_tiles_positive_infinite_zoom_stays_bounded() {
        let layer = MapTileLayer::default();
        let tiles = map_visible_tiles(
            &view(0.0, 0.0, f32::INFINITY),
            LogicalSize::new(800.0, 600.0),
            &layer,
        );
        assert!(!tiles.is_empty());
        assert!(tiles.len() < 64, "+inf zoom produced {} tiles", tiles.len());
        for t in &tiles {
            assert_eq!(t.z, layer.max_zoom, "+inf zoom must clamp to max_zoom");
        }
    }
    #[test]
    fn map_visible_tiles_negative_bounds_match_positive_bounds() {
        let layer = MapTileLayer::default();
        let viewport = view(48.0, 11.0, 6.0);
        let positive = map_visible_tiles(&viewport, LogicalSize::new(640.0, 480.0), &layer);
        let negative = map_visible_tiles(&viewport, LogicalSize::new(-640.0, -480.0), &layer);
        assert_eq!(positive, negative);
    }
    #[test]
    fn map_visible_tiles_ids_always_live_inside_the_grid() {
        for (min_zoom, max_zoom) in [(0u8, 14u8), (3, 5), (0, 0)] {
            let layer = layer_zoom(min_zoom, max_zoom);
            for zoom in [-1.0_f32, 0.0, 2.5, 7.0, 99.0] {
                for (lat, lon) in [(0.0, 0.0), (85.0, 180.0), (-85.0, -180.0), (60.0, 179.99)] {
                    let viewport = view(lat, lon, zoom);
                    let tiles =
                        map_visible_tiles(&viewport, LogicalSize::new(800.0, 600.0), &layer);
                    let expected_z = (zoom.floor() as i32)
                        .clamp(i32::from(min_zoom), i32::from(max_zoom))
                        as u8;
                    let tile_count = 1u32 << u32::from(expected_z);
                    for t in &tiles {
                        assert_eq!(t.z, expected_z, "zoom {zoom} produced z {}", t.z);
                        assert!(t.x < tile_count, "column {} >= {tile_count}", t.x);
                        assert!(t.y < tile_count, "row {} >= {tile_count}", t.y);
                    }
                }
            }
        }
    }
    #[test]
    fn map_visible_tiles_wraps_columns_across_the_antimeridian() {
        // A viewport pinned to +180° over-scans past the east edge; every id
        // must still be a legal column (the wrap), never tile_count or above.
        let layer = layer_zoom(0, 14);
        let tiles = map_visible_tiles(
            &view(0.0, 180.0, 3.0),
            LogicalSize::new(1024.0, 256.0),
            &layer,
        );
        assert!(!tiles.is_empty());
        assert!(
            tiles.iter().any(|t| t.x == 0),
            "panning past +180° must surface the west-edge column"
        );
        for t in &tiles {
            assert!(t.x < 8, "column {} escaped the z3 grid", t.x);
        }
    }
    // ==================================================================
    // MapWidget builders  (constructors)
    // ==================================================================
    #[test]
    fn create_uses_the_given_layer_and_neutral_defaults() {
        let layer = layer_zoom(2, 9);
        let widget = MapWidget::create(layer.clone());
        assert_eq!(widget.layer, layer);
        assert_eq!(widget.viewport, MapViewport::default());
        assert!(widget.container_style.as_slice().is_empty());
        assert!(matches!(
            widget.on_viewport_changed,
            OptionMapViewportChanged::None
        ));
        assert!(matches!(widget.on_pin_tap, OptionMapPinTap::None));
    }
    #[test]
    fn create_accepts_degenerate_layers_without_panicking() {
        // An inverted zoom band and an empty template are nonsense but must
        // still build - the widget validates nothing at construction time.
        let widget = MapWidget::create(MapTileLayer {
            url_template: AzString::from(""),
            min_zoom: 30,
            max_zoom: 0,
            attribution: AzString::from(""),
            style_css: AzString::from(""),
        });
        assert_eq!(widget.layer.min_zoom, 30);
        assert_eq!(widget.layer.max_zoom, 0);
    }
    #[test]
    fn with_viewport_stores_extreme_values_verbatim() {
        let widget = MapWidget::create(MapTileLayer::default())
            .with_viewport(view(1.0e300, -1.0e300, f32::MAX));
        assert_eq!(widget.viewport.centre_lat_deg, 1.0e300);
        assert_eq!(widget.viewport.centre_lon_deg, -1.0e300);
        assert_eq!(widget.viewport.zoom, f32::MAX);
        let widget = MapWidget::create(MapTileLayer::default())
            .with_viewport(view(f64::NAN, f64::INFINITY, f32::NEG_INFINITY));
        assert!(widget.viewport.centre_lat_deg.is_nan());
        assert!(widget.viewport.centre_lon_deg.is_infinite());
        assert!(widget.viewport.zoom.is_infinite());
    }
    #[test]
    fn with_viewport_is_last_write_wins() {
        let widget = MapWidget::create(MapTileLayer::default())
            .with_viewport(view(1.0, 2.0, 3.0))
            .with_viewport(view(4.0, 5.0, 6.0));
        assert_eq!(widget.viewport, view(4.0, 5.0, 6.0));
    }
    #[test]
    fn with_container_style_replaces_the_style_vec() {
        let css = CssPropertyWithConditionsVec::parse("width: 100px; height: 50px;");
        let parsed_len = css.as_slice().len();
        assert!(parsed_len > 0, "positive control: the style must parse");
        let widget = MapWidget::create(MapTileLayer::default()).with_container_style(css);
        assert_eq!(widget.container_style.as_slice().len(), parsed_len);
        // An unparseable style yields an empty vec, and the builder stores it.
        let widget = MapWidget::create(MapTileLayer::default())
            .with_container_style(CssPropertyWithConditionsVec::parse(""));
        assert!(widget.container_style.as_slice().is_empty());
    }
    #[test]
    fn with_container_style_tolerates_garbage_and_unicode() {
        for style in [
            "\u{1F600}: \u{1F600};",
            "   ",
            ";;;;",
            "width",
            "width: ;",
            "}{",
            "color: \u{0301}\u{0301};",
        ] {
            let widget = MapWidget::create(MapTileLayer::default())
                .with_container_style(CssPropertyWithConditionsVec::parse(style));
            // Reaching here means neither the parser nor the builder panicked.
            let _ = widget.container_style.as_slice().len();
        }
    }
    #[test]
    fn with_on_viewport_changed_installs_and_replaces_the_hook() {
        let mut widget = MapWidget::create(MapTileLayer::default()).with_on_viewport_changed(
            RefAny::new(HookLog::default()),
            record_viewport as MapViewportChangedCallbackType,
        );
        assert!(matches!(
            widget.on_viewport_changed,
            OptionMapViewportChanged::Some(_)
        ));
        // Re-setting must overwrite, not accumulate.
        widget.set_on_viewport_changed(
            RefAny::new(HookLog::default()),
            record_viewport as MapViewportChangedCallbackType,
        );
        assert!(matches!(
            widget.on_viewport_changed,
            OptionMapViewportChanged::Some(_)
        ));
        assert!(matches!(widget.on_pin_tap, OptionMapPinTap::None));
    }
    #[test]
    fn with_on_pin_tap_installs_and_replaces_the_hook() {
        let mut widget = MapWidget::create(MapTileLayer::default())
            .with_on_pin_tap(RefAny::new(HookLog::default()), record_pin as MapPinTapCallbackType);
        assert!(matches!(widget.on_pin_tap, OptionMapPinTap::Some(_)));
        widget.set_on_pin_tap(
            RefAny::new(HookLog::default()),
            record_pin as MapPinTapCallbackType,
        );
        assert!(matches!(widget.on_pin_tap, OptionMapPinTap::Some(_)));
        assert!(matches!(
            widget.on_viewport_changed,
            OptionMapViewportChanged::None
        ));
    }
    #[test]
    fn builder_chain_order_does_not_matter_for_independent_fields() {
        let layer = layer_zoom(1, 12);
        let viewport = view(10.0, 20.0, 5.0);
        let a = MapWidget::create(layer.clone())
            .with_viewport(viewport)
            .with_container_style(CssPropertyWithConditionsVec::parse("width: 10px;"));
        let b = MapWidget::create(layer)
            .with_container_style(CssPropertyWithConditionsVec::parse("width: 10px;"))
            .with_viewport(viewport);
        assert_eq!(a, b);
    }
    // ==================================================================
    // MapWidget::dom / dom_with_fetch / build_dom
    // ==================================================================
    #[test]
    fn dom_builds_a_single_virtual_view_child_with_a_dataset() {
        let mut dom = MapWidget::create(MapTileLayer::default())
            .with_viewport(view(0.0, 0.0, 3.0))
            .dom();
        assert_eq!(dom.children.as_slice().len(), 1, "one VirtualView child");
        let dataset = dom
            .root
            .get_dataset_mut()
            .expect("the widget div must carry a MapTileCache dataset");
        let cache = dataset
            .downcast_ref::<MapTileCache>()
            .expect("the dataset must be a MapTileCache");
        assert_eq!(cache.viewport.zoom, 3.0);
        assert!(cache.tiles.is_empty());
        assert!(cache.fetch_callback.is_none(), "dom() wires no worker");
    }
    #[test]
    fn dom_survives_degenerate_viewports_and_layers() {
        for viewport in [
            view(f64::NAN, f64::NAN, f32::NAN),
            view(1.0e300, -1.0e300, f32::INFINITY),
            view(0.0, 0.0, f32::NEG_INFINITY),
        ] {
            let dom = MapWidget::create(layer_zoom(200, 1))
                .with_viewport(viewport)
                .dom();
            assert_eq!(dom.children.as_slice().len(), 1);
        }
    }
    #[test]
    fn dom_with_a_container_style_still_builds_the_grid() {
        let dom = MapWidget::create(MapTileLayer::default())
            .with_container_style(CssPropertyWithConditionsVec::parse(
                "position: relative; width: 320px; height: 240px;",
            ))
            .dom();
        assert_eq!(dom.children.as_slice().len(), 1);
    }
    #[test]
    fn dom_with_fetch_records_the_worker_in_the_dataset() {
        let mut dom = MapWidget::create(MapTileLayer::default())
            .dom_with_fetch(ThreadCallback::new(noop_worker));
        let dataset = dom.root.get_dataset_mut().expect("dataset");
        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
        let cb = cache
            .fetch_callback
            .as_ref()
            .expect("dom_with_fetch must record the worker");
        assert_eq!(cb.cb as usize, noop_worker as ThreadCallbackType as usize);
    }
    #[test]
    fn dom_carries_the_user_hooks_into_the_cache() {
        let mut dom = MapWidget::create(MapTileLayer::default())
            .with_on_viewport_changed(
                RefAny::new(HookLog::default()),
                record_viewport as MapViewportChangedCallbackType,
            )
            .with_on_pin_tap(
                RefAny::new(HookLog::default()),
                record_pin as MapPinTapCallbackType,
            )
            .dom();
        let dataset = dom.root.get_dataset_mut().expect("dataset");
        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
        assert!(matches!(
            cache.on_viewport_changed,
            OptionMapViewportChanged::Some(_)
        ));
        assert!(matches!(cache.on_pin_tap, OptionMapPinTap::Some(_)));
    }
    // ==================================================================
    // MapTileCache  (constructor + mutators)
    // ==================================================================
    #[test]
    fn tile_cache_new_starts_empty_and_idle() {
        let cache = MapTileCache::new(layer_zoom(0, 14), view(1.0, 2.0, 3.0));
        assert!(cache.tiles.is_empty());
        assert!(cache.fetch_callback.is_none());
        assert!(cache.drag_anchor.is_none());
        assert!(cache.pinch_anchor.is_none());
        assert!(cache.press_origin.is_none());
        assert_eq!(cache.viewport, view(1.0, 2.0, 3.0));
        assert_eq!(cache.layer.max_zoom, 14);
    }
    #[test]
    fn mark_tile_ready_and_failed_overwrite_each_other() {
        let mut cache = cache_at(0.0, 0.0, 4.0);
        let tile = MapTileId { z: 4, x: 8, y: 8 };
        cache.mark_tile_ready(tile, AzString::from("<svg/>"));
        assert!(matches!(cache.tiles.get(&tile), Some(TileEntry::Ready { .. })));
        cache.mark_tile_failed(tile, AzString::from("boom"));
        assert!(matches!(cache.tiles.get(&tile), Some(TileEntry::Failed { .. })));
        cache.mark_tile_ready(tile, AzString::from(""));
        assert!(matches!(cache.tiles.get(&tile), Some(TileEntry::Ready { .. })));
        assert_eq!(cache.tiles.len(), 1, "the same id must not duplicate");
    }
    #[test]
    fn mark_tile_ready_accepts_empty_unicode_and_huge_payloads() {
        let mut cache = cache_at(0.0, 0.0, 4.0);
        let payloads = [
            AzString::from(""),
            AzString::from("\u{1F600}\u{4F60}\u{597D}e\u{0301}"),
            AzString::from("\0\u{FFFD}\r\n\t"),
            AzString::from("x".repeat(1_000_000)),
        ];
        for (i, svg) in payloads.into_iter().enumerate() {
            cache.mark_tile_ready(
                MapTileId {
                    z: 4,
                    x: i as u32,
                    y: 0,
                },
                svg,
            );
        }
        assert_eq!(cache.tiles.len(), 4);
    }
    #[test]
    fn mark_tile_ready_accepts_out_of_range_tile_ids() {
        // Nothing validates an id at insert time - a bogus id from an FFI
        // caller must land in the map rather than panic.
        let mut cache = cache_at(0.0, 0.0, 4.0);
        for tile in [
            MapTileId { z: 0, x: 0, y: 0 },
            MapTileId {
                z: 31,
                x: u32::MAX,
                y: u32::MAX,
            },
            MapTileId {
                z: 4,
                x: u32::MAX,
                y: 0,
            },
        ] {
            cache.mark_tile_failed(tile, AzString::from("e"));
        }
        assert_eq!(cache.tiles.len(), 3);
    }
    // ==================================================================
    // MapTileCache::prune_distant_tiles
    // ==================================================================
    fn fill_ready_grid(cache: &mut MapTileCache, z: u8, side: u32) {
        for x in 0..side {
            for y in 0..side {
                cache
                    .tiles
                    .insert(MapTileId { z, x, y }, TileEntry::Ready { svg: AzString::from("<svg/>") });
            }
        }
    }
    #[test]
    fn prune_never_evicts_in_flight_tiles_even_far_over_the_cap() {
        let mut cache = cache_at(0.0, 0.0, 4.0);
        for x in 0..16u32 {
            for y in 0..16u32 {
                cache.tiles.insert(
                    MapTileId { z: 4, x, y },
                    if (x + y) % 2 == 0 {
                        TileEntry::Pending
                    } else {
                        TileEntry::Fetching
                    },
                );
            }
        }
        assert_eq!(cache.tiles.len(), 256);
        cache.prune_distant_tiles();
        assert_eq!(
            cache.tiles.len(),
            256,
            "in-flight tiles are unevictable, so the cap can be exceeded"
        );
    }
    #[test]
    fn prune_with_a_nan_viewport_centre_still_bounds_the_cache() {
        let mut cache = MapTileCache::new(layer_zoom(0, 19), view(f64::NAN, f64::NAN, 4.0));
        fill_ready_grid(&mut cache, 4, 16);
        assert_eq!(cache.tiles.len(), 256);
        // Every score is NaN → `partial_cmp` returns None → the sort falls back
        // to Equal. The eviction count must still be honoured.
        cache.prune_distant_tiles();
        assert_eq!(cache.tiles.len(), 192);
    }
    #[test]
    fn prune_with_non_finite_zoom_clamps_to_the_layer_band() {
        for zoom in [f32::INFINITY, f32::NEG_INFINITY, f32::NAN, 1.0e30, -1.0e30] {
            let mut cache = MapTileCache::new(layer_zoom(0, 19), view(0.0, 0.0, zoom));
            fill_ready_grid(&mut cache, 4, 16);
            cache.prune_distant_tiles();
            assert_eq!(cache.tiles.len(), 192, "zoom {zoom} must still bound the cache");
        }
    }
    #[test]
    fn prune_is_idempotent_once_under_the_cap() {
        let mut cache = cache_at(0.0, 0.0, 4.0);
        fill_ready_grid(&mut cache, 4, 16);
        cache.prune_distant_tiles();
        let first = cache.tiles.len();
        let survivors: Vec<MapTileId> = cache.tiles.keys().copied().collect();
        cache.prune_distant_tiles();
        assert_eq!(cache.tiles.len(), first);
        assert_eq!(
            cache.tiles.keys().copied().collect::<Vec<_>>(),
            survivors,
            "a second prune under the cap must change nothing"
        );
    }
    #[test]
    fn prune_drops_the_farthest_tiles_first_across_zoom_levels() {
        // A mixed-zoom cache: same-zoom near tiles must outlive a wrong-zoom
        // tile (the score adds 10_000 per zoom level of mismatch).
        let mut cache = cache_at(0.0, 0.0, 4.0);
        fill_ready_grid(&mut cache, 4, 16);
        let wrong_zoom = MapTileId { z: 9, x: 256, y: 256 };
        cache
            .tiles
            .insert(wrong_zoom, TileEntry::Ready { svg: AzString::from("<svg/>") });
        let near = MapTileId { z: 4, x: 8, y: 8 };
        cache.prune_distant_tiles();
        assert!(cache.tiles.len() <= 192);
        assert!(
            !cache.tiles.contains_key(&wrong_zoom),
            "a zoom-mismatched tile must be evicted before same-zoom neighbours"
        );
        assert!(cache.tiles.contains_key(&near), "the centre tile must survive");
    }
    // ==================================================================
    // merge_map_tile_cache
    // ==================================================================
    #[test]
    fn merge_with_a_wrong_typed_new_dataset_returns_the_old_one_intact() {
        let mut old_cache = cache_at(0.0, 0.0, 5.0);
        let tile = MapTileId { z: 5, x: 1, y: 1 };
        old_cache.mark_tile_ready(tile, AzString::from("<svg/>"));
        let mut merged = merge_map_tile_cache(RefAny::new(0u32), RefAny::new(old_cache));
        let cache = merged.downcast_ref::<MapTileCache>().expect("old cache");
        assert_eq!(cache.viewport.zoom, 5.0, "no adoption from a bogus new dataset");
        assert!(cache.tiles.contains_key(&tile));
    }
    #[test]
    fn merge_with_a_wrong_typed_old_dataset_returns_it_unchanged() {
        let new_cache = cache_at(0.0, 0.0, 9.0);
        let mut merged = merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(7u64));
        assert!(
            merged.downcast_ref::<MapTileCache>().is_none(),
            "the merge must not fabricate a cache out of a wrong-typed dataset"
        );
        assert_eq!(*merged.downcast_ref::<u64>().expect("u64 payload"), 7);
    }
    #[test]
    fn merge_of_two_aliases_of_one_dataset_does_not_panic() {
        // Both handles share one allocation, so the shared borrow taken for
        // `new_data` blocks the exclusive borrow for `old_data`. The merge must
        // degrade to "no adoption" rather than deadlock or panic.
        let dataset = RefAny::new(cache_at(0.0, 0.0, 5.0));
        let mut merged = merge_map_tile_cache(dataset.clone(), dataset);
        let cache = merged.downcast_ref::<MapTileCache>().expect("cache");
        assert_eq!(cache.viewport.zoom, 5.0);
    }
    #[test]
    fn merge_adopts_the_worker_only_when_the_old_cache_has_none() {
        // Old has no worker → adopt the build's.
        let old_cache = cache_at(0.0, 0.0, 5.0);
        let mut new_cache = cache_at(0.0, 0.0, 6.0);
        new_cache.fetch_callback = Some(ThreadCallback::new(noop_worker));
        let mut merged = merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(old_cache));
        {
            let cache = merged.downcast_ref::<MapTileCache>().expect("cache");
            let cb = cache.fetch_callback.as_ref().expect("adopted worker");
            assert_eq!(cb.cb as usize, noop_worker as ThreadCallbackType as usize);
        }
        // Old already has one → keep it (the workers already hold its handle).
        let mut old_cache = cache_at(0.0, 0.0, 5.0);
        old_cache.fetch_callback = Some(ThreadCallback::new(noop_worker));
        let mut new_cache = cache_at(0.0, 0.0, 6.0);
        new_cache.fetch_callback = Some(ThreadCallback::new(other_noop_worker));
        let mut merged = merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(old_cache));
        let cache = merged.downcast_ref::<MapTileCache>().expect("cache");
        let cb = cache.fetch_callback.as_ref().expect("kept worker");
        assert_eq!(cb.cb as usize, noop_worker as ThreadCallbackType as usize);
    }
    #[test]
    fn merge_adopts_the_build_layer_and_viewport_even_when_degenerate() {
        let old_cache = MapTileCache::new(layer_zoom(0, 19), view(10.0, 20.0, 5.0));
        let new_cache =
            MapTileCache::new(layer_zoom(3, 7), view(f64::NAN, f64::INFINITY, f32::NAN));
        let mut merged = merge_map_tile_cache(RefAny::new(new_cache), RefAny::new(old_cache));
        let cache = merged.downcast_ref::<MapTileCache>().expect("cache");
        assert!(cache.viewport.centre_lat_deg.is_nan());
        assert!(cache.viewport.zoom.is_nan());
        assert_eq!(cache.layer.min_zoom, 3);
        assert_eq!(cache.layer.max_zoom, 7);
    }
    // ==================================================================
    // build_tile_url  (parser-ish substitution)
    // ==================================================================
    #[test]
    fn build_tile_url_without_placeholders_is_the_identity() {
        let tile = MapTileId { z: 1, x: 2, y: 3 };
        assert_eq!(build_tile_url("", tile), "");
        assert_eq!(build_tile_url("https://t.example/fixed", tile), "https://t.example/fixed");
        // Unknown placeholders are left verbatim, not eaten.
        assert_eq!(build_tile_url("{q}/{Z}/{ x }", tile), "{q}/{Z}/{ x }");
    }
    #[test]
    fn build_tile_url_substitutes_extreme_tile_ids() {
        let tile = MapTileId {
            z: u8::MAX,
            x: u32::MAX,
            y: 0,
        };
        assert_eq!(build_tile_url("{z}/{x}/{y}", tile), "255/4294967295/0");
    }
    #[test]
    fn build_tile_url_handles_unicode_and_unbalanced_braces() {
        let tile = MapTileId { z: 7, x: 8, y: 9 };
        assert_eq!(
            build_tile_url("\u{1F600}/{z}/\u{4F60}\u{597D}/{y}", tile),
            "\u{1F600}/7/\u{4F60}\u{597D}/9"
        );
        assert_eq!(build_tile_url("{{z}}", tile), "{7}");
        assert_eq!(build_tile_url("{z", tile), "{z");
        assert_eq!(build_tile_url("z}", tile), "z}");
    }
    #[test]
    fn build_tile_url_substitution_is_not_re_scanned() {
        // `{z}` expands to a number, so no expansion can create a new
        // placeholder — but a template that already spells one out must not be
        // touched twice either.
        let tile = MapTileId { z: 1, x: 2, y: 3 };
        assert_eq!(build_tile_url("{z}{x}{y}{z}", tile), "1231");
    }
    #[test]
    fn build_tile_url_extremely_long_template_does_not_hang() {
        let template = "{z}/".repeat(100_000);
        let url = build_tile_url(&template, MapTileId { z: 14, x: 0, y: 0 });
        assert_eq!(url.len(), 100_000 * 3);
        assert!(url.starts_with("14/14/"));
    }
    // ==================================================================
    // svg_string_to_dom  (parser)
    // ==================================================================
    // --- xml + cpurender: the SVG is rasterised into an image node ---
    #[cfg(all(feature = "xml", feature = "cpurender"))]
    const MINIMAL_TILE_SVG: &str =
        r#"<svg viewBox="0 0 16 16"><rect x="0" y="0" width="16" height="16" fill="red"/></svg>"#;
    #[cfg(all(feature = "xml", feature = "cpurender"))]
    #[test]
    fn svg_raster_valid_minimal_is_the_positive_control() {
        assert!(
            svg_string_to_dom(MINIMAL_TILE_SVG).is_some(),
            "a well-formed <svg> must rasterise into a Dom"
        );
    }
    #[cfg(all(feature = "xml", feature = "cpurender"))]
    #[test]
    fn svg_raster_empty_whitespace_and_garbage_return_none() {
        for bad in [
            "",
            " ",
            "   \t\n\r ",
            "garbage",
            "<<<>>>",
            "<svg",
            "</svg>",
            "<html><body/></html>",
            "\u{0}\u{1}\u{2}",
        ] {
            assert!(
                svg_string_to_dom(bad).is_none(),
                "{bad:?} must be rejected, not rendered"
            );
        }
    }
    #[cfg(all(feature = "xml", feature = "cpurender"))]
    #[test]
    fn svg_raster_boundary_numeric_attributes_do_not_panic() {
        for svg in [
            r#"<svg viewBox="0 0 16 16"><rect width="0" height="-0" fill="red"/></svg>"#,
            r#"<svg viewBox="0 0 16 16"><rect width="NaN" height="inf" fill="red"/></svg>"#,
            r#"<svg viewBox="0 0 16 16"><rect width="1e400" height="1e-400" fill="red"/></svg>"#,
            r#"<svg viewBox="0 0 16 16"><rect width="9223372036854775807" height="8"/></svg>"#,
            r#"<svg viewBox="0 0 0 0"><rect width="8" height="8" fill="red"/></svg>"#,
            r#"<svg viewBox="NaN NaN NaN NaN"><rect width="8" height="8" fill="red"/></svg>"#,
        ] {
            // Reaching the next iteration means the rasteriser did not panic.
            let _ = svg_string_to_dom(svg).is_some();
        }
    }
    #[cfg(all(feature = "xml", feature = "cpurender"))]
    #[test]
    fn svg_raster_unicode_content_does_not_panic() {
        let svg = "<svg viewBox=\"0 0 16 16\"><title>\u{1F600} \u{4F60}\u{597D} \
                   e\u{0301} \u{202E}</title><rect width=\"16\" height=\"16\" \
                   fill=\"red\"/></svg>";
        assert!(svg_string_to_dom(svg).is_some());
    }
    #[cfg(all(feature = "xml", feature = "cpurender"))]
    #[test]
    fn svg_raster_leading_and_trailing_junk_is_deterministic() {
        for svg in [
            "  <svg viewBox=\"0 0 8 8\"><rect width=\"8\" height=\"8\"/></svg>  ",
            "<svg viewBox=\"0 0 8 8\"><rect width=\"8\" height=\"8\"/></svg>;garbage",
            "junk<svg viewBox=\"0 0 8 8\"><rect width=\"8\" height=\"8\"/></svg>",
        ] {
            // Whatever the verdict, it must be stable across calls.
            assert_eq!(
                svg_string_to_dom(svg).is_some(),
                svg_string_to_dom(svg).is_some(),
                "{svg:?} parsed non-deterministically"
            );
        }
    }
    #[cfg(all(feature = "xml", feature = "cpurender"))]
    #[test]
    fn svg_raster_extremely_long_input_does_not_hang() {
        let svg = alloc::format!(
            "<svg viewBox=\"0 0 8 8\"><desc>{}</desc><rect width=\"8\" height=\"8\" \
             fill=\"red\"/></svg>",
            "a".repeat(1_000_000)
        );
        assert!(svg_string_to_dom(&svg).is_some());
    }
    #[cfg(all(feature = "xml", feature = "cpurender"))]
    #[test]
    fn svg_raster_deeply_nested_groups_do_not_stack_overflow() {
        // The rasteriser recurses per group; give it a generous stack so a real
        // 2000-deep document is a clean test rather than a crash.
        let ok = std::thread::Builder::new()
            .stack_size(128 * 1024 * 1024)
            .spawn(|| {
                const DEPTH: usize = 2_000;
                let svg = alloc::format!(
                    "<svg viewBox=\"0 0 8 8\">{}<rect width=\"8\" height=\"8\" \
                     fill=\"red\"/>{}</svg>",
                    "<g>".repeat(DEPTH),
                    "</g>".repeat(DEPTH)
                );
                svg_string_to_dom(&svg).is_some()
            })
            .expect("spawn")
            .join()
            .expect("2000-deep nesting must not overflow the stack");
        assert!(ok);
    }
    // --- xml without cpurender: the SVG goes through the XML→DOM path ---
    #[cfg(all(feature = "xml", not(feature = "cpurender")))]
    #[test]
    fn svg_dom_valid_minimal_is_the_positive_control() {
        assert!(svg_string_to_dom("<svg><g/></svg>").is_some());
    }
    #[cfg(all(feature = "xml", not(feature = "cpurender")))]
    #[test]
    fn svg_dom_malformed_markup_returns_none() {
        for bad in ["<<<>>>", "<svg", "</svg>", "<a></b>", "\u{0}\u{1}"] {
            assert!(svg_string_to_dom(bad).is_none(), "{bad:?} must be rejected");
        }
    }
    #[cfg(all(feature = "xml", not(feature = "cpurender")))]
    #[test]
    fn svg_dom_empty_whitespace_and_unicode_do_not_panic() {
        for input in ["", " ", "   \t\n\r ", "\u{1F600}", "e\u{0301}"] {
            assert_eq!(
                svg_string_to_dom(input).is_some(),
                svg_string_to_dom(input).is_some()
            );
        }
    }
    #[cfg(all(feature = "xml", not(feature = "cpurender")))]
    #[test]
    fn svg_dom_extremely_long_input_does_not_hang() {
        let svg = alloc::format!("<svg><desc>{}</desc></svg>", "a".repeat(1_000_000));
        let _ = svg_string_to_dom(&svg).is_some();
    }
    // --- no xml feature: the stub always declines ---
    #[cfg(not(feature = "xml"))]
    #[test]
    fn svg_stub_returns_none_for_every_input() {
        for input in ["", "   ", "<svg/>", "<svg><g/></svg>", "\u{1F600}", "<<<>>>"] {
            assert!(svg_string_to_dom(input).is_none());
        }
        let long = "a".repeat(1_000_000);
        assert!(svg_string_to_dom(&long).is_none());
    }
    // ==================================================================
    // User-hook invocation  (invoke_viewport_changed / invoke_pin_tap)
    // ==================================================================
    #[test]
    fn invoke_viewport_changed_without_a_hook_is_do_nothing() {
        let (update, changes) = with_callback_info(|info| {
            invoke_viewport_changed(&OptionMapViewportChanged::None, &info, view(0.0, 0.0, 2.0))
        });
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
    }
    #[test]
    fn invoke_viewport_changed_forwards_even_a_nan_viewport() {
        let mut log = RefAny::new(HookLog::default());
        let hook = OptionMapViewportChanged::Some(MapViewportChanged {
            refany: log.clone(),
            callback: (record_viewport as MapViewportChangedCallbackType).into(),
        });
        let viewport = view(f64::NAN, f64::INFINITY, f32::NAN);
        let (update, _) = with_callback_info(|info| invoke_viewport_changed(&hook, &info, viewport));
        assert_eq!(update, Update::DoNothing);
        assert_eq!(hook_log(&mut log), (1, 0));
    }
    #[test]
    fn invoke_pin_tap_without_a_hook_is_do_nothing() {
        let (update, _) = with_callback_info(|info| {
            invoke_pin_tap(
                &OptionMapPinTap::None,
                &info,
                MapLatLon {
                    lat_deg: 0.0,
                    lon_deg: 0.0,
                },
            )
        });
        assert_eq!(update, Update::DoNothing);
    }
    #[test]
    fn invoke_pin_tap_returns_the_users_update_verbatim() {
        let mut log = RefAny::new(HookLog::default());
        let hook = OptionMapPinTap::Some(MapPinTap {
            refany: log.clone(),
            callback: (record_pin as MapPinTapCallbackType).into(),
        });
        let (update, _) = with_callback_info(|info| {
            invoke_pin_tap(
                &hook,
                &info,
                MapLatLon {
                    lat_deg: f64::NAN,
                    lon_deg: -1.0e300,
                },
            )
        });
        assert_eq!(update, Update::RefreshDom);
        assert_eq!(hook_log(&mut log), (0, 1));
    }
    // ==================================================================
    // Pointer / scroll callbacks
    // ==================================================================
    #[test]
    fn pointer_down_without_a_cursor_is_a_no_op() {
        let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
        let (update, _) =
            with_callback_info(|info| map_on_pointer_down(dataset.clone(), info));
        assert_eq!(update, Update::DoNothing);
        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
        assert!(cache.drag_anchor.is_none());
        assert!(cache.press_origin.is_none());
    }
    #[test]
    fn pointer_down_records_both_anchors() {
        let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
        let (update, _) = with_callback_info_at(cursor_at(120.0, 80.0), |info| {
            map_on_pointer_down(dataset.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
        let anchor = cache.drag_anchor.expect("drag anchor");
        let press = cache.press_origin.expect("press origin");
        assert_eq!((anchor.x, anchor.y), (120.0, 80.0));
        assert_eq!((press.x, press.y), (120.0, 80.0));
    }
    #[test]
    fn pointer_down_on_a_wrong_typed_dataset_is_a_no_op() {
        let dataset = RefAny::new(0u16);
        let (update, _) = with_callback_info_at(cursor_at(1.0, 1.0), |info| {
            map_on_pointer_down(dataset.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
    }
    #[test]
    fn pointer_move_without_an_anchor_does_not_pan() {
        let mut dataset = RefAny::new(cache_at(37.0, -122.0, 4.0));
        let (update, _) = with_callback_info_at(cursor_at(500.0, 500.0), |info| {
            map_on_pointer_move(dataset.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
        assert_eq!(cache.viewport.centre_lon_deg, -122.0);
        assert_eq!(cache.viewport.centre_lat_deg, 37.0);
    }
    #[test]
    fn pointer_move_pans_by_the_exact_mercator_delta_and_re_anchors() {
        let mut cache = cache_at(0.0, 0.0, 2.0);
        cache.drag_anchor = Some(LogicalPosition::new(100.0, 100.0));
        let mut dataset = RefAny::new(cache);
        let (_, changes) = with_callback_info_at(cursor_at(150.0, 100.0), |info| {
            map_on_pointer_move(dataset.clone(), info)
        });
        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
        // world_px = 256 * 2^2 = 1024 → d_lon = -50 * 360 / 1024.
        close(cache.viewport.centre_lon_deg, -50.0 * 360.0 / 1024.0, 1e-9);
        close(cache.viewport.centre_lat_deg, 0.0, 1e-9);
        let anchor = cache.drag_anchor.expect("anchor must follow the cursor");
        assert_eq!((anchor.x, anchor.y), (150.0, 100.0));
        drop(cache);
        assert!(
            changes
                .iter()
                .any(|c| matches!(c, CallbackChange::UpdateAllVirtualViews)),
            "a pan must request a virtual-view re-render"
        );
    }
    #[test]
    fn pointer_move_ignores_sub_half_pixel_jitter() {
        let mut cache = cache_at(10.0, 20.0, 4.0);
        cache.drag_anchor = Some(LogicalPosition::new(100.0, 100.0));
        let mut dataset = RefAny::new(cache);
        let (update, _) = with_callback_info_at(cursor_at(100.4, 99.7), |info| {
            map_on_pointer_move(dataset.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
        assert_eq!(cache.viewport.centre_lon_deg, 20.0, "jitter must not pan");
        assert_eq!(cache.viewport.centre_lat_deg, 10.0);
    }
    #[test]
    fn pointer_move_with_a_nan_viewport_does_not_panic() {
        let mut cache = MapTileCache::new(layer_zoom(0, 19), view(f64::NAN, f64::NAN, f32::NAN));
        cache.drag_anchor = Some(LogicalPosition::new(0.0, 0.0));
        let mut dataset = RefAny::new(cache);
        let (update, _) = with_callback_info_at(cursor_at(400.0, 400.0), |info| {
            map_on_pointer_move(dataset.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
        assert!(cache.viewport.centre_lon_deg.is_nan());
    }
    #[test]
    fn pointer_move_fires_the_viewport_hook_once_per_pan() {
        let mut log = RefAny::new(HookLog::default());
        let mut cache = cache_at(0.0, 0.0, 4.0);
        cache.drag_anchor = Some(LogicalPosition::new(0.0, 0.0));
        cache.on_viewport_changed = OptionMapViewportChanged::Some(MapViewportChanged {
            refany: log.clone(),
            callback: (record_viewport as MapViewportChangedCallbackType).into(),
        });
        let dataset = RefAny::new(cache);
        let _ = with_callback_info_at(cursor_at(60.0, 60.0), |info| {
            map_on_pointer_move(dataset.clone(), info)
        });
        assert_eq!(hook_log(&mut log), (1, 0));
    }
    #[test]
    fn pointer_up_clears_every_gesture_anchor() {
        let mut cache = cache_at(0.0, 0.0, 4.0);
        cache.drag_anchor = Some(LogicalPosition::new(5.0, 5.0));
        cache.pinch_anchor = Some(120.0);
        cache.press_origin = Some(LogicalPosition::new(5.0, 5.0));
        let mut dataset = RefAny::new(cache);
        let (update, _) = with_callback_info_at(cursor_at(5.0, 5.0), |info| {
            map_on_pointer_up(dataset.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
        assert!(cache.drag_anchor.is_none());
        assert!(cache.pinch_anchor.is_none());
        assert!(cache.press_origin.is_none());
    }
    #[test]
    fn pointer_up_fires_pin_tap_for_a_tap_but_not_for_a_drag() {
        // A release within 6px of the press point is a tap.
        let mut log = RefAny::new(HookLog::default());
        let mut cache = cache_at(0.0, 0.0, 4.0);
        cache.press_origin = Some(LogicalPosition::new(10.0, 10.0));
        cache.on_pin_tap = OptionMapPinTap::Some(MapPinTap {
            refany: log.clone(),
            callback: (record_pin as MapPinTapCallbackType).into(),
        });
        let dataset = RefAny::new(cache);
        let _ = with_callback_info_at(cursor_at(12.0, 12.0), |info| {
            map_on_pointer_up(dataset.clone(), info)
        });
        assert_eq!(hook_log(&mut log), (0, 1), "a 2px release is a tap");
        // A release 90px away is a drag, not a tap.
        let mut log = RefAny::new(HookLog::default());
        let mut cache = cache_at(0.0, 0.0, 4.0);
        cache.press_origin = Some(LogicalPosition::new(10.0, 10.0));
        cache.on_pin_tap = OptionMapPinTap::Some(MapPinTap {
            refany: log.clone(),
            callback: (record_pin as MapPinTapCallbackType).into(),
        });
        let dataset = RefAny::new(cache);
        let _ = with_callback_info_at(cursor_at(100.0, 100.0), |info| {
            map_on_pointer_up(dataset.clone(), info)
        });
        assert_eq!(hook_log(&mut log), (0, 0), "a 90px release is a drag");
    }
    #[test]
    fn pointer_up_without_a_press_origin_never_taps() {
        let mut log = RefAny::new(HookLog::default());
        let mut cache = cache_at(0.0, 0.0, 4.0);
        cache.on_pin_tap = OptionMapPinTap::Some(MapPinTap {
            refany: log.clone(),
            callback: (record_pin as MapPinTapCallbackType).into(),
        });
        let dataset = RefAny::new(cache);
        let _ = with_callback_info_at(cursor_at(10.0, 10.0), |info| {
            map_on_pointer_up(dataset.clone(), info)
        });
        assert_eq!(hook_log(&mut log), (0, 0));
    }
    #[test]
    fn pointer_up_on_a_wrong_typed_dataset_is_a_no_op() {
        // A `MapViewport` is the most plausible mix-up: same widget, wrong payload.
        let dataset = RefAny::new(MapViewport::default());
        let (update, _) = with_callback_info_at(cursor_at(1.0, 1.0), |info| {
            map_on_pointer_up(dataset.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
    }
    #[test]
    fn scroll_without_a_wheel_delta_is_a_no_op() {
        // The harness has no hit node, so `get_scroll_delta` yields 0 - the
        // handler must bail before touching the viewport.
        let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
        let (update, changes) = with_callback_info(|info| map_on_scroll(dataset.clone(), info));
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty(), "a zero-delta scroll must queue nothing");
        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
        assert_eq!(cache.viewport.zoom, 4.0);
        assert!(cache.tiles.is_empty());
    }
    #[test]
    fn scroll_on_a_wrong_typed_dataset_is_a_no_op() {
        let dataset = RefAny::new(0u8);
        let (update, _) = with_callback_info(|info| map_on_scroll(dataset.clone(), info));
        assert_eq!(update, Update::DoNothing);
    }
    // ==================================================================
    // Fetch spawning + writeback
    // ==================================================================
    #[test]
    fn spawn_pending_tile_fetches_is_a_no_op_without_a_worker() {
        let mut cache = cache_at(0.0, 0.0, 4.0);
        for x in 0..4u32 {
            cache.tiles.insert(MapTileId { z: 4, x, y: 8 }, TileEntry::Pending);
        }
        let mut dataset = RefAny::new(cache);
        let (_, changes) = with_callback_info(|info| {
            let mut info = info;
            spawn_pending_tile_fetches(&mut dataset.clone(), &mut info);
        });
        assert!(changes.is_empty(), "no worker → no threads queued");
        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
        assert!(
            cache.tiles.values().all(|e| matches!(e, TileEntry::Pending)),
            "tiles must stay Pending so the placeholder grid renders"
        );
    }
    #[test]
    fn spawn_pending_tile_fetches_caps_the_burst_at_sixteen() {
        let mut cache = cache_at(0.0, 0.0, 4.0);
        cache.fetch_callback = Some(ThreadCallback::new(noop_worker));
        for x in 0..20u32 {
            cache.tiles.insert(MapTileId { z: 4, x, y: 8 }, TileEntry::Pending);
        }
        let mut dataset = RefAny::new(cache);
        // `(fetching, pending)` counts, as a plain fn so the two call sites
        // don't share one inferred closure borrow.
        fn count_states(ds: &mut RefAny) -> (usize, usize) {
            let cache = ds.downcast_ref::<MapTileCache>().expect("cache");
            let fetching = cache
                .tiles
                .values()
                .filter(|e| matches!(e, TileEntry::Fetching))
                .count();
            let pending = cache
                .tiles
                .values()
                .filter(|e| matches!(e, TileEntry::Pending))
                .count();
            (fetching, pending)
        }
        let (_, changes) = with_callback_info(|info| {
            let mut info = info;
            spawn_pending_tile_fetches(&mut dataset.clone(), &mut info);
        });
        assert_eq!(count_states(&mut dataset), (16, 4), "one call spawns at most 16");
        assert_eq!(
            changes
                .iter()
                .filter(|c| matches!(c, CallbackChange::AddThread { .. }))
                .count(),
            16
        );
        // The second call drains the remainder - the cap bounds a burst, it
        // does not drop work.
        let _ = with_callback_info(|info| {
            let mut info = info;
            spawn_pending_tile_fetches(&mut dataset.clone(), &mut info);
        });
        assert_eq!(count_states(&mut dataset), (20, 0));
    }
    #[test]
    fn spawn_pending_tile_fetches_on_a_wrong_typed_dataset_is_a_no_op() {
        let mut dataset = RefAny::new(1234u32);
        let (_, changes) = with_callback_info(|info| {
            let mut info = info;
            spawn_pending_tile_fetches(&mut dataset, &mut info);
        });
        assert!(changes.is_empty());
    }
    #[test]
    fn tile_writeback_marks_ready_on_an_empty_error_and_failed_otherwise() {
        let tile = MapTileId { z: 4, x: 1, y: 2 };
        let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
        let ok = RefAny::new(TileReadyMsg {
            tile,
            svg: AzString::from("<svg/>"),
            error: AzString::from(""),
        });
        let (update, changes) = with_callback_info(|info| {
            map_tile_writeback(dataset.clone(), ok.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
        assert!(changes
            .iter()
            .any(|c| matches!(c, CallbackChange::UpdateAllVirtualViews)));
        {
            let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
            assert!(matches!(cache.tiles.get(&tile), Some(TileEntry::Ready { .. })));
        }
        let failed = RefAny::new(TileReadyMsg {
            tile,
            svg: AzString::from(""),
            error: AzString::from("404"),
        });
        let (update, _) = with_callback_info(|info| {
            map_tile_writeback(dataset.clone(), failed.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
        assert!(matches!(cache.tiles.get(&tile), Some(TileEntry::Failed { .. })));
    }
    #[test]
    fn tile_writeback_accepts_a_huge_payload_and_an_out_of_range_id() {
        let tile = MapTileId {
            z: 31,
            x: u32::MAX,
            y: u32::MAX,
        };
        let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
        let msg = RefAny::new(TileReadyMsg {
            tile,
            svg: AzString::from("<svg/>".repeat(50_000)),
            error: AzString::from(""),
        });
        let (update, _) =
            with_callback_info(|info| map_tile_writeback(dataset.clone(), msg.clone(), info));
        assert_eq!(update, Update::DoNothing);
        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
        assert!(cache.tiles.contains_key(&tile));
    }
    #[test]
    fn tile_writeback_with_a_wrong_typed_message_is_a_no_op() {
        let mut dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
        let (update, changes) = with_callback_info(|info| {
            map_tile_writeback(dataset.clone(), RefAny::new(0u32), info)
        });
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty(), "a bogus message must not force a re-render");
        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
        assert!(cache.tiles.is_empty());
    }
    #[test]
    fn tile_writeback_with_a_wrong_typed_cache_is_a_no_op() {
        let msg = RefAny::new(TileReadyMsg {
            tile: MapTileId { z: 1, x: 0, y: 0 },
            svg: AzString::from("<svg/>"),
            error: AzString::from(""),
        });
        let (update, changes) = with_callback_info(|info| {
            map_tile_writeback(RefAny::new(9u64), msg.clone(), info)
        });
        assert_eq!(update, Update::DoNothing);
        assert!(changes.is_empty());
    }
    #[test]
    fn after_mount_installs_the_sweep_timer_and_asks_for_a_re_render() {
        let dataset = RefAny::new(cache_at(0.0, 0.0, 4.0));
        let (update, changes) =
            with_callback_info(|info| map_on_after_mount(dataset.clone(), info));
        assert_eq!(update, Update::DoNothing);
        assert_eq!(
            changes
                .iter()
                .filter(|c| matches!(c, CallbackChange::AddTimer { .. }))
                .count(),
            1,
            "exactly one sweep timer per mount"
        );
        assert!(changes
            .iter()
            .any(|c| matches!(c, CallbackChange::UpdateAllVirtualViews)));
    }
    #[test]
    fn after_mount_on_a_wrong_typed_dataset_still_installs_the_timer() {
        // The timer is unconditional; only the fetch spawn depends on the cache.
        let dataset = RefAny::new(0u8);
        let (update, changes) =
            with_callback_info(|info| map_on_after_mount(dataset.clone(), info));
        assert_eq!(update, Update::DoNothing);
        assert!(changes
            .iter()
            .any(|c| matches!(c, CallbackChange::AddTimer { .. })));
    }
    // ==================================================================
    // map_widget_render  (VirtualView callback)
    // ==================================================================
    #[test]
    fn render_with_non_finite_or_empty_bounds_emits_no_dom() {
        let dataset = RefAny::new(cache_at(0.0, 0.0, 3.0));
        for (w, h) in [
            (0.0_f32, 0.0_f32),
            (0.0, 600.0),
            (800.0, 0.0),
            (-800.0, -600.0),
            (f32::NAN, 600.0),
            (800.0, f32::NAN),
            (f32::INFINITY, 600.0),
            (800.0, f32::NEG_INFINITY),
        ] {
            let ret =
                with_virtual_view_info(w, h, |info| map_widget_render(dataset.clone(), info));
            assert!(
                rendered_child_count(&ret).is_none(),
                "bounds {w}x{h} must render nothing until layout settles"
            );
        }
    }
    #[test]
    fn render_with_a_wrong_typed_dataset_emits_no_dom() {
        let dataset = RefAny::new(0u32);
        let ret =
            with_virtual_view_info(800.0, 600.0, |info| map_widget_render(dataset.clone(), info));
        assert!(rendered_child_count(&ret).is_none());
    }
    #[test]
    fn render_marks_every_visible_tile_pending_and_emits_one_div_each() {
        let mut dataset = RefAny::new(cache_at(0.0, 0.0, 2.0));
        let expected = map_visible_tiles(
            &view(0.0, 0.0, 2.0),
            LogicalSize::new(800.0, 600.0),
            &layer_zoom(0, 19),
        );
        let ret =
            with_virtual_view_info(800.0, 600.0, |info| map_widget_render(dataset.clone(), info));
        assert_eq!(
            rendered_child_count(&ret),
            Some(expected.len()),
            "one div per visible tile"
        );
        let cache = dataset.downcast_ref::<MapTileCache>().expect("cache");
        for tile in &expected {
            assert!(
                matches!(cache.tiles.get(tile), Some(TileEntry::Pending)),
                "tile {tile:?} must be queued by the render pass"
            );
        }
    }
    #[test]
    fn render_reports_the_bounds_back_as_the_scroll_size() {
        let dataset = RefAny::new(cache_at(0.0, 0.0, 2.0));
        let ret =
            with_virtual_view_info(640.0, 480.0, |info| map_widget_render(dataset.clone(), info));
        assert_eq!(ret.materialized.size.width, 640.0);
        assert_eq!(ret.materialized.size.height, 480.0);
        assert_eq!(ret.virtual_rect.size.width, 640.0);
        assert_eq!(ret.virtual_rect.size.height, 480.0);
        assert_eq!((ret.materialized.origin.x, ret.materialized.origin.y), (0.0, 0.0));
        assert_eq!(
            (ret.virtual_rect.origin.x, ret.virtual_rect.origin.y),
            (0.0, 0.0)
        );
    }
    #[test]
    fn render_falls_back_to_a_glyph_when_a_ready_tile_holds_garbage() {
        // A worker can hand back anything; an unparseable payload must degrade
        // to the placeholder text child, not panic the render pass.
        let mut cache = cache_at(0.0, 0.0, 2.0);
        for tile in map_visible_tiles(
            &view(0.0, 0.0, 2.0),
            LogicalSize::new(512.0, 512.0),
            &layer_zoom(0, 19),
        ) {
            cache.mark_tile_ready(tile, AzString::from("not xml at all <<<"));
        }
        let dataset = RefAny::new(cache);
        let ret =
            with_virtual_view_info(512.0, 512.0, |info| map_widget_render(dataset.clone(), info));
        assert!(
            rendered_child_count(&ret).is_some_and(|n| n > 0),
            "garbage tiles still render their placeholder"
        );
    }
    #[test]
    fn render_handles_mixed_tile_states_including_failures() {
        let mut cache = cache_at(0.0, 0.0, 2.0);
        let tiles = map_visible_tiles(
            &view(0.0, 0.0, 2.0),
            LogicalSize::new(512.0, 512.0),
            &layer_zoom(0, 19),
        );
        for (i, tile) in tiles.iter().enumerate() {
            match i % 4 {
                0 => {
                    cache.tiles.insert(*tile, TileEntry::Pending);
                }
                1 => {
                    cache.tiles.insert(*tile, TileEntry::Fetching);
                }
                2 => cache.mark_tile_failed(*tile, AzString::from("\u{1F600} failed")),
                _ => cache.mark_tile_ready(*tile, AzString::from("")),
            }
        }
        let dataset = RefAny::new(cache);
        let ret =
            with_virtual_view_info(512.0, 512.0, |info| map_widget_render(dataset.clone(), info));
        assert_eq!(rendered_child_count(&ret), Some(tiles.len()));
    }
    #[test]
    fn render_clamps_an_out_of_band_zoom_and_stays_bounded() {
        // A single-zoom layer with a viewport far outside it: the grid must
        // collapse onto the one supported zoom, not enumerate a huge range.
        //
        // NOTE: `min_zoom > max_zoom` is deliberately NOT exercised here - the
        // `i32::clamp(min, max)` in `map_widget_render` panics on an inverted
        // band (see the report accompanying these tests).
        for zoom in [0.0_f32, 1.0, 3.0, 25.0, f32::INFINITY] {
            let cache = MapTileCache::new(layer_zoom(3, 3), view(0.0, 0.0, zoom));
            let dataset = RefAny::new(cache);
            let ret = with_virtual_view_info(800.0, 600.0, |info| {
                map_widget_render(dataset.clone(), info)
            });
            let n = rendered_child_count(&ret).expect("a finite box must render");
            assert!(n > 0 && n < 4096, "zoom {zoom} produced {n} tiles");
        }
    }
    #[test]
    fn render_is_stable_across_repeated_invocations() {
        let dataset = RefAny::new(cache_at(48.1372, 11.5756, 5.0));
        let first =
            with_virtual_view_info(800.0, 600.0, |info| map_widget_render(dataset.clone(), info));
        let second =
            with_virtual_view_info(800.0, 600.0, |info| map_widget_render(dataset.clone(), info));
        assert_eq!(rendered_child_count(&first), rendered_child_count(&second));
    }
}