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)]
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
400
    fn default() -> Self {
93
400
        Self {
94
400
            // OpenFreeMap's public planet vector tiles (full-detail OSM, z0–14, no
95
400
            // API key). The tile path is VERSIONED by planet-build date — the
96
400
            // unversioned `/planet/{z}/{x}/{y}.pbf` returns empty tiles. The version
97
400
            // below is the current build from the TileJSON at
98
400
            // `https://tiles.openfreemap.org/planet` (`tiles[0]`); when OpenFreeMap
99
400
            // rebuilds the planet this goes stale, so the proper long-term path is to
100
400
            // resolve it on the background thread by fetching that TileJSON first (a
101
400
            // follow-up to the Leaflet-style layer work). Raster relief is also
102
400
            // available at `…/natural_earth/ne2sr/{z}/{x}/{y}.png` (z0–6).
103
400
            url_template: AzString::from(
104
400
                "https://tiles.openfreemap.org/planet/20260531_080002_pt/{z}/{x}/{y}.pbf",
105
400
            ),
106
400
            min_zoom: 0,
107
400
            max_zoom: 14,
108
400
            attribution: AzString::from(
109
400
                "© OpenFreeMap © OpenMapTiles · Data © OpenStreetMap contributors",
110
400
            ),
111
400
            style_css: AzString::from(""),
112
400
        }
113
400
    }
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
704
    fn default() -> Self {
131
        // A neutral "whole world, slightly zoomed in" default. Apps
132
        // care will replace this immediately.
133
704
        Self {
134
704
            centre_lat_deg: 0.0,
135
704
            centre_lon_deg: 0.0,
136
704
            zoom: 2.0,
137
704
            bearing_deg: 0.0,
138
704
            pitch_deg: 0.0,
139
704
        }
140
704
    }
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
18
    pub fn create(layer: MapTileLayer) -> Self {
175
18
        Self {
176
18
            layer,
177
18
            viewport: MapViewport::default(),
178
18
            container_style: CssPropertyWithConditionsVec::from_const_slice(&[]),
179
18
            on_viewport_changed: OptionMapViewportChanged::None,
180
18
            on_pin_tap: OptionMapPinTap::None,
181
18
        }
182
18
    }
183

            
184
7
    pub fn with_viewport(mut self, viewport: MapViewport) -> Self {
185
7
        self.viewport = viewport;
186
7
        self
187
7
    }
188

            
189
    pub fn with_container_style(mut self, css: CssPropertyWithConditionsVec) -> Self {
190
        self.container_style = css;
191
        self
192
    }
193

            
194
    /// Set a hook fired when the user pans / zooms the map. The map owns its
195
    /// own pan/pinch state; this lets your app observe or persist the
196
    /// resulting `MapViewport`. The backreference DI pattern (architecture.md).
197
    pub fn set_on_viewport_changed<C: Into<MapViewportChangedCallback>>(
198
        &mut self,
199
        data: RefAny,
200
        callback: C,
201
    ) {
202
        self.on_viewport_changed = Some(MapViewportChanged {
203
            refany: data,
204
            callback: callback.into(),
205
        })
206
        .into();
207
    }
208

            
209
    /// Builder form of [`set_on_viewport_changed`](Self::set_on_viewport_changed).
210
    pub fn with_on_viewport_changed<C: Into<MapViewportChangedCallback>>(
211
        mut self,
212
        data: RefAny,
213
        callback: C,
214
    ) -> Self {
215
        self.set_on_viewport_changed(data, callback);
216
        self
217
    }
218

            
219
    /// Set a hook fired when the user taps the map (a press + release at ~the
220
    /// same point, no drag), with the tapped lat/lon. The backreference DI
221
    /// pattern (architecture.md).
222
    pub fn set_on_pin_tap<C: Into<MapPinTapCallback>>(&mut self, data: RefAny, callback: C) {
223
        self.on_pin_tap = Some(MapPinTap {
224
            refany: data,
225
            callback: callback.into(),
226
        })
227
        .into();
228
    }
229

            
230
    /// Builder form of [`set_on_pin_tap`](Self::set_on_pin_tap).
231
    pub fn with_on_pin_tap<C: Into<MapPinTapCallback>>(
232
        mut self,
233
        data: RefAny,
234
        callback: C,
235
    ) -> Self {
236
        self.set_on_pin_tap(data, callback);
237
        self
238
    }
239

            
240
    /// Project a screen pixel `px` (relative to the map node's top-left, in a
241
    /// node of size `container`) to a lat/lon on the map at `viewport`. Small-
242
    /// angle Mercator (accurate at city zooms). Inverse of
243
    /// [`px_at_latlon`](Self::px_at_latlon). Exposed so apps don't reimplement
244
    /// the projection (e.g. to drop a pin where the user tapped).
245
    pub fn latlon_at_px(
246
        viewport: MapViewport,
247
        px: azul_core::geom::LogicalPosition,
248
        container: azul_core::geom::LogicalSize,
249
    ) -> MapLatLon {
250
        let world = 256.0_f64 * 2.0_f64.powf(viewport.zoom as f64);
251
        let dx = (px.x - container.width * 0.5) as f64;
252
        let dy = (px.y - container.height * 0.5) as f64;
253
        let lon = (viewport.centre_lon_deg + dx * 360.0 / world).clamp(-180.0, 180.0);
254
        let cos_lat = viewport.centre_lat_deg.to_radians().cos();
255
        let lat = (viewport.centre_lat_deg - dy * 360.0 / world * cos_lat).clamp(-85.0, 85.0);
256
        MapLatLon {
257
            lat_deg: lat,
258
            lon_deg: lon,
259
        }
260
    }
261

            
262
    /// Inverse of [`latlon_at_px`](Self::latlon_at_px): where `coord` lands in
263
    /// container pixels at `viewport`.
264
    pub fn px_at_latlon(
265
        viewport: MapViewport,
266
        coord: MapLatLon,
267
        container: azul_core::geom::LogicalSize,
268
    ) -> azul_core::geom::LogicalPosition {
269
        let world = 256.0_f64 * 2.0_f64.powf(viewport.zoom as f64);
270
        let cos_lat = viewport.centre_lat_deg.to_radians().cos();
271
        let px = container.width as f64 * 0.5
272
            + (coord.lon_deg - viewport.centre_lon_deg) * world / 360.0;
273
        let py = container.height as f64 * 0.5
274
            - (coord.lat_deg - viewport.centre_lat_deg) * world / (360.0 * cos_lat);
275
        azul_core::geom::LogicalPosition::new(px as f32, py as f32)
276
    }
277

            
278
    /// Construct the rendered `Dom`. The returned `Dom` is a single
279
    /// `<div>` with:
280
    /// - A `MapTileCache` `RefAny` dataset (initialised from this
281
    ///   widget's `viewport` + `layer`).
282
    /// - A `DatasetMergeCallback` so the cache survives relayout.
283
    /// - A `VirtualView` child that re-renders the visible-tile grid
284
    ///   on bounds change.
285
    /// - Mouse-down / mouse-move / mouse-up callbacks that pan the
286
    ///   viewport while a drag is active (the widget owns the
287
    ///   pan state via `MapTileCache::drag_anchor`, so user code
288
    ///   doesn't have to wire anything).
289
    /// - Pinch callbacks that zoom in / out.
290
    ///
291
    /// No tile-fetch worker is wired — tiles render as placeholders.
292
    /// Use [`dom_with_fetch`](Self::dom_with_fetch) to supply one.
293
396
    pub fn dom(self) -> Dom {
294
396
        self.build_dom(None)
295
396
    }
296

            
297
    /// Like [`dom`](Self::dom), but wires a tile-fetch worker thread.
298
    /// `cb` runs on a framework `Thread` per visible tile: it reads the
299
    /// `TileFetchInit`, fetches + decodes, then
300
    /// `sender.send(ThreadReceiveMsg::WriteBack(...))` a `TileReadyMsg`
301
    /// targeting `map_tile_writeback`. The standard worker is
302
    /// `azul_dll::desktop::extra::map::tile_fetch_worker`; wrap it in a
303
    /// `ThreadCallback` to pass it here. See the recipe in
304
    /// `MOBILE_SESSION_LOG.md`.
305
    pub fn dom_with_fetch(self, cb: crate::thread::ThreadCallback) -> Dom {
306
        self.build_dom(Some(cb))
307
    }
308

            
309
396
    fn build_dom(self, fetch_cb: Option<crate::thread::ThreadCallback>) -> Dom {
310
        use azul_core::dom::{ComponentEventFilter, EventFilter, HoverEventFilter};
311

            
312
396
        let mut cache = MapTileCache::new(self.layer.clone(), self.viewport);
313
396
        cache.fetch_callback = fetch_cb;
314
396
        cache.on_viewport_changed = self.on_viewport_changed;
315
396
        cache.on_pin_tap = self.on_pin_tap;
316
396
        let dataset = RefAny::new(cache);
317
396
        let virtual_view_data = dataset.clone();
318

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

            
409
        // A caller-supplied container style replaces the default fill above
410
        // (`with_css_props` replaces the inline style) — the caller then owns sizing.
411
396
        if self.container_style.as_slice().is_empty() {
412
396
            root
413
        } else {
414
            root.with_css_props(self.container_style.clone())
415
        }
416
396
    }
417
}
418

            
419
// ────────── Tile cache (dataset RefAny payload) ───────────────────────
420

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

            
464
impl MapTileCache {
465
402
    pub fn new(layer: MapTileLayer, viewport: MapViewport) -> Self {
466
402
        Self {
467
402
            layer,
468
402
            viewport,
469
402
            tiles: BTreeMap::new(),
470
402
            fetch_callback: None,
471
402
            drag_anchor: None,
472
402
            pinch_anchor: None,
473
402
            press_origin: None,
474
402
            on_viewport_changed: OptionMapViewportChanged::None,
475
402
            on_pin_tap: OptionMapPinTap::None,
476
402
        }
477
402
    }
478

            
479
    /// Worker-thread → main-thread write path. Set the decoded SVG for
480
    /// a tile (called from `map_tile_writeback`). Stamps `Ready`.
481
1
    pub fn mark_tile_ready(&mut self, tile: MapTileId, svg: AzString) {
482
1
        self.tiles.insert(tile, TileEntry::Ready { svg });
483
1
    }
484

            
485
    /// Mark a tile's fetch as failed so the grid doesn't re-spawn it
486
    /// every frame.
487
    pub fn mark_tile_failed(&mut self, tile: MapTileId, error: AzString) {
488
        self.tiles.insert(tile, TileEntry::Failed { error });
489
    }
490

            
491
    /// Bound the tile cache by evicting tiles far from the current viewport.
492
    ///
493
    /// Without this, `tiles` grows without limit — panning across the world or
494
    /// zooming in and out keeps every tile ever fetched (each decoded SVG is
495
    /// tens-to-hundreds of KB), so a long session leaks memory. Called after a
496
    /// viewport change once the new view's tiles are queued.
497
    ///
498
    /// Eviction is viewport-distance based (the right policy for spatial data,
499
    /// stronger than plain LRU): each tile is scored by zoom mismatch + squared
500
    /// distance from the viewport centre (projected into the current zoom's tile
501
    /// space), and the farthest are dropped first. IN-FLIGHT tiles
502
    /// (`Pending`/`Fetching`) are never evicted (their worker would write into a
503
    /// gone entry), and on-screen tiles score near-zero so they survive.
504
2
    pub fn prune_distant_tiles(&mut self) {
505
        const MAX_CACHED_TILES: usize = 192;
506
2
        if self.tiles.len() <= MAX_CACHED_TILES {
507
1
            return;
508
1
        }
509

            
510
1
        let z = (self.viewport.zoom.floor() as i32)
511
1
            .clamp(self.layer.min_zoom as i32, self.layer.max_zoom as i32)
512
1
            as u8;
513
1
        let tile_count = 1u32 << z as u32;
514
1
        let cx = lon_to_tile_x(self.viewport.centre_lon_deg, tile_count as f64);
515
1
        let cy = lat_to_tile_y(self.viewport.centre_lat_deg, tile_count as f64);
516

            
517
        // Higher score = evict sooner.
518
399
        let score = |id: &MapTileId| -> f64 {
519
399
            let zt_count = 1u32 << id.z as u32;
520
            // Project the tile's centre into the CURRENT zoom's tile space so
521
            // distances across zoom levels are comparable.
522
399
            let scale = tile_count as f64 / zt_count as f64;
523
399
            let tx = (id.x as f64 + 0.5) * scale;
524
399
            let ty = (id.y as f64 + 0.5) * scale;
525
399
            let dz = (id.z as i32 - z as i32).abs() as f64;
526
399
            let dx = tx - cx;
527
399
            let dy = ty - cy;
528
399
            dz * 10_000.0 + dx * dx + dy * dy
529
399
        };
530

            
531
1
        let mut evictable: alloc::vec::Vec<(f64, MapTileId)> = self
532
1
            .tiles
533
1
            .iter()
534
400
            .filter(|(_, e)| !matches!(e, TileEntry::Pending | TileEntry::Fetching))
535
399
            .map(|(id, _)| (score(id), *id))
536
1
            .collect();
537
        // Farthest first.
538
3563
        evictable.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(core::cmp::Ordering::Equal));
539

            
540
1
        let mut to_remove = self.tiles.len().saturating_sub(MAX_CACHED_TILES);
541
209
        for (_, id) in evictable {
542
209
            if to_remove == 0 {
543
1
                break;
544
208
            }
545
208
            self.tiles.remove(&id);
546
208
            to_remove -= 1;
547
        }
548
2
    }
549
}
550

            
551
#[derive(Debug, Clone)]
552
pub enum TileEntry {
553
    /// Needed by the viewport, fetch not yet spawned.
554
    Pending,
555
    /// A worker thread is fetching / decoding this tile right now.
556
    /// Distinct from `Pending` so the spawn pass doesn't double-fire.
557
    Fetching,
558
    /// Tile decoded into an SVG document. Held as the raw SVG
559
    /// string for now; the VirtualView callback will feed it
560
    /// through the framework's svg-to-dom pipeline on the next
561
    /// re-render.
562
    Ready { svg: AzString },
563
    /// Fetch failed. Held so the framework doesn't immediately
564
    /// re-try the same URL — caller can choose to clear failed
565
    /// entries on retry.
566
    Failed { error: AzString },
567
}
568

            
569
/// Worker-thread input: which tile to fetch, the resolved URL, and the
570
/// MapCSS stylesheet to apply when converting features to SVG. Boxed
571
/// into the `Thread::create` init `RefAny`.
572
#[derive(Debug, Clone)]
573
pub struct TileFetchInit {
574
    pub tile: MapTileId,
575
    pub url: AzString,
576
    /// Copy of `MapTileLayer::style_css` (empty = default palette).
577
    pub style_css: AzString,
578
}
579

            
580
/// Worker-thread output, sent back via `ThreadWriteBackMsg`. The
581
/// `map_tile_writeback` callback downcasts to this and stamps the
582
/// cache.
583
#[derive(Debug, Clone)]
584
pub struct TileReadyMsg {
585
    pub tile: MapTileId,
586
    /// Decoded SVG document for the tile, or empty on failure (with
587
    /// `error` set).
588
    pub svg: AzString,
589
    /// Empty on success; an error message on failure.
590
    pub error: AzString,
591
}
592

            
593
// ────────── Merge callback — cache survives relayout ─────────────────
594

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

            
638
// ────────── Pan + zoom callbacks ─────────────────────────────────────
639

            
640
use crate::callbacks::CallbackInfo;
641
use azul_core::callbacks::Update;
642
use azul_core::callbacks::TimerCallbackReturn;
643
use azul_core::task::{Duration, SystemTimeDiff, TerminateTimer, TimerId};
644
use crate::timer::{Timer, TimerCallback, TimerCallbackInfo};
645

            
646
// --- User hook: on_viewport_changed (backreference DI, FFI-exposed) ---
647

            
648
/// User hook fired when the user pans or zooms the map. Lets app code observe
649
/// or persist the widget-driven `MapViewport` (which otherwise lives only in
650
/// the opaque `MapTileCache`). The backreference DI pattern (architecture.md).
651
pub type MapViewportChangedCallbackType =
652
    extern "C" fn(RefAny, CallbackInfo, MapViewport) -> Update;
653
impl_widget_callback!(
654
    MapViewportChanged,
655
    OptionMapViewportChanged,
656
    MapViewportChangedCallback,
657
    MapViewportChangedCallbackType
658
);
659
azul_core::impl_managed_callback! {
660
    wrapper:        MapViewportChangedCallback,
661
    info_ty:        CallbackInfo,
662
    return_ty:      Update,
663
    default_ret:    Update::DoNothing,
664
    invoker_static: MAP_VIEWPORT_CHANGED_INVOKER,
665
    invoker_ty:     AzMapViewportChangedCallbackInvoker,
666
    thunk_fn:       az_map_viewport_changed_callback_thunk,
667
    setter_fn:      AzApp_setMapViewportChangedCallbackInvoker,
668
    from_handle_fn: AzMapViewportChangedCallback_createFromHostHandle,
669
    extra_args:     [ viewport: MapViewport ],
670
}
671

            
672
/// Invoke a map widget's optional `on_viewport_changed` hook with the new
673
/// viewport, returning the user's `Update` (`DoNothing` if no hook is set).
674
fn invoke_viewport_changed(
675
    hook: &OptionMapViewportChanged,
676
    info: &CallbackInfo,
677
    viewport: MapViewport,
678
) -> Update {
679
    match hook {
680
        OptionMapViewportChanged::Some(h) => {
681
            (h.callback.cb)(h.refany.clone(), info.clone(), viewport)
682
        }
683
        OptionMapViewportChanged::None => Update::DoNothing,
684
    }
685
}
686

            
687
// --- User hook: on_pin_tap (backreference DI, FFI-exposed) ---
688

            
689
/// User hook fired when the user taps the map (a press + release at ~the same
690
/// point, no pan/pinch). Receives the tapped [`MapLatLon`] (projected via
691
/// [`MapWidget::latlon_at_px`]) so apps can drop a pin without wiring their own
692
/// tap handling + projection. The backreference DI pattern (architecture.md).
693
pub type MapPinTapCallbackType = extern "C" fn(RefAny, CallbackInfo, MapLatLon) -> Update;
694
impl_widget_callback!(
695
    MapPinTap,
696
    OptionMapPinTap,
697
    MapPinTapCallback,
698
    MapPinTapCallbackType
699
);
700
azul_core::impl_managed_callback! {
701
    wrapper:        MapPinTapCallback,
702
    info_ty:        CallbackInfo,
703
    return_ty:      Update,
704
    default_ret:    Update::DoNothing,
705
    invoker_static: MAP_PIN_TAP_INVOKER,
706
    invoker_ty:     AzMapPinTapCallbackInvoker,
707
    thunk_fn:       az_map_pin_tap_callback_thunk,
708
    setter_fn:      AzApp_setMapPinTapCallbackInvoker,
709
    from_handle_fn: AzMapPinTapCallback_createFromHostHandle,
710
    extra_args:     [ coord: MapLatLon ],
711
}
712

            
713
/// Invoke a map widget's optional `on_pin_tap` hook with the tapped coordinate.
714
fn invoke_pin_tap(hook: &OptionMapPinTap, info: &CallbackInfo, coord: MapLatLon) -> Update {
715
    match hook {
716
        OptionMapPinTap::Some(h) => (h.callback.cb)(h.refany.clone(), info.clone(), coord),
717
        OptionMapPinTap::None => Update::DoNothing,
718
    }
719
}
720

            
721
/// Pointer down → record the drag anchor. The widget knows nothing
722
/// about the user's overall state RefAny — only its own dataset —
723
/// so the anchor lives in `MapTileCache::drag_anchor`.
724
extern "C" fn map_on_pointer_down(mut data: RefAny, info: CallbackInfo) -> Update {
725
    #[cfg(feature = "std")]
726
    if std::env::var("AZ_MAP_DEBUG").is_ok() {
727
        eprintln!("[map] pointer_down fired");
728
    }
729
    let pos = match info.get_cursor_relative_to_node().into_option() {
730
        Some(p) => azul_core::geom::LogicalPosition::new(p.x, p.y),
731
        None => return Update::DoNothing,
732
    };
733
    if let Some(mut cache) = data.downcast_mut::<MapTileCache>() {
734
        cache.drag_anchor = Some(pos);
735
        cache.press_origin = Some(pos);
736
    }
737
    Update::DoNothing
738
}
739

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

            
787
    let pos = match info.get_cursor_relative_to_node().into_option() {
788
        Some(p) => azul_core::geom::LogicalPosition::new(p.x, p.y),
789
        None => return Update::DoNothing,
790
    };
791
    let mut cache_guard = match data.downcast_mut::<MapTileCache>() {
792
        Some(c) => c,
793
        None => return Update::DoNothing,
794
    };
795
    let anchor = match cache_guard.drag_anchor {
796
        Some(a) => a,
797
        None => return Update::DoNothing, // no active drag
798
    };
799

            
800
    let dx_px = (pos.x - anchor.x) as f64;
801
    let dy_px = (pos.y - anchor.y) as f64;
802
    if dx_px.abs() < 0.5 && dy_px.abs() < 0.5 {
803
        return Update::DoNothing;
804
    }
805

            
806
    let (new_lon, new_lat) = pan_viewport(
807
        cache_guard.viewport.centre_lat_deg,
808
        cache_guard.viewport.centre_lon_deg,
809
        cache_guard.viewport.zoom as f64,
810
        dx_px,
811
        dy_px,
812
    );
813
    cache_guard.viewport.centre_lon_deg = new_lon;
814
    cache_guard.viewport.centre_lat_deg = new_lat;
815
    cache_guard.drag_anchor = Some(pos);
816

            
817
    let hook = cache_guard.on_viewport_changed.clone();
818
    let vp = cache_guard.viewport;
819
    drop(cache_guard);
820
    invoke_viewport_changed(&hook, &info, vp);
821
    // Pan moved the viewport — re-render the VirtualView in place so the newly
822
    // visible tiles are computed (and marked Pending) right away. No RefreshDom.
823
    info.trigger_all_virtual_view_rerender();
824
    Update::DoNothing
825
}
826

            
827
/// Pointer up / pointer leave → end the drag *and* the pinch. Either
828
/// can be in flight (and pinch supersedes pan in the move handler);
829
/// clear both anchors on release.
830
extern "C" fn map_on_pointer_up(mut data: RefAny, mut info: CallbackInfo) -> Update {
831
    // Cursor + container size for tap projection (read before borrowing data).
832
    let up_pos = info
833
        .get_cursor_relative_to_node()
834
        .into_option()
835
        .map(|p| azul_core::geom::LogicalPosition::new(p.x, p.y));
836
    let container = info
837
        .get_hit_node_rect()
838
        .map(|r| r.size)
839
        .unwrap_or(azul_core::geom::LogicalSize::new(0.0, 0.0));
840
    let (press, viewport, hook) = match data.downcast_mut::<MapTileCache>() {
841
        Some(mut cache) => {
842
            let out = (cache.press_origin, cache.viewport, cache.on_pin_tap.clone());
843
            cache.drag_anchor = None;
844
            cache.pinch_anchor = None;
845
            cache.press_origin = None;
846
            out
847
        }
848
        None => (None, MapViewport::default(), OptionMapPinTap::None),
849
    };
850
    // A press + release at ~the same point (no pan/pinch) is a tap: project it
851
    // to lat/lon and fire the user's on_pin_tap hook.
852
    if let (Some(origin), Some(up)) = (press, up_pos) {
853
        let dx = (up.x - origin.x) as f64;
854
        let dy = (up.y - origin.y) as f64;
855
        if dx * dx + dy * dy < 36.0 {
856
            let coord = MapWidget::latlon_at_px(viewport, up, container);
857
            invoke_pin_tap(&hook, &info, coord);
858
        }
859
    }
860
    // After a pan / pinch settles, kick off fetches for any tiles the new
861
    // viewport needs. (Only a `CallbackInfo`-bearing callback can spawn them.)
862
    spawn_pending_tile_fetches(&mut data, &mut info);
863
    // Re-render in place so Fetching/Ready states show as tiles arrive. The
864
    // worker writebacks will trigger further re-renders themselves. No RefreshDom.
865
    info.trigger_all_virtual_view_rerender();
866
    Update::DoNothing
867
}
868

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

            
922
18
fn wrap_lon(lon: f64) -> f64 {
923
    // `rem_euclid` (not `%`) so even large negative deltas normalise:
924
    // `%` follows the dividend's sign and would leak values < -180.
925
18
    (lon + 180.0).rem_euclid(360.0) - 180.0
926
18
}
927

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

            
937
/// Longitude (deg) → fractional tile-x at the given `tile_count`.
938
329
fn lon_to_tile_x(lon_deg: f64, tile_count: f64) -> f64 {
939
329
    (lon_deg + 180.0) / 360.0 * tile_count
940
329
}
941

            
942
/// Latitude (deg) → fractional tile-y at the given `tile_count`.
943
328
fn lat_to_tile_y(lat_deg: f64, tile_count: f64) -> f64 {
944
328
    let lat_rad = lat_deg.to_radians();
945
328
    let mercator =
946
328
        (1.0 - (lat_rad.tan() + 1.0 / lat_rad.cos()).ln() / core::f64::consts::PI) / 2.0;
947
328
    mercator * tile_count
948
328
}
949

            
950
/// Fractional tile-x → longitude (deg). Inverse of [`lon_to_tile_x`].
951
/// Verified against the forward direction in the tests below; the
952
/// upcoming tap-to-pin handler reuses it to turn a tap into a lat/lon.
953
#[allow(dead_code)]
954
16
fn tile_x_to_lon(x: f64, tile_count: f64) -> f64 {
955
16
    x / tile_count * 360.0 - 180.0
956
16
}
957

            
958
/// Fractional tile-y → latitude (deg). Inverse of [`lat_to_tile_y`].
959
#[allow(dead_code)]
960
16
fn tile_y_to_lat(y: f64, tile_count: f64) -> f64 {
961
16
    let n = core::f64::consts::PI * (1.0 - 2.0 * y / tile_count);
962
16
    n.sinh().atan().to_degrees()
963
16
}
964

            
965
/// Apply a drag of `(dx_px, dy_px)` screen pixels to a viewport centre,
966
/// returning the new `(centre_lon_deg, centre_lat_deg)`. Dragging right
967
/// (+dx) pans the map content right, i.e. recentres on a *lower* longitude
968
/// (hence the minus). Latitude uses the small-angle Mercator approximation
969
/// (`d_lat ≈ dy·cos(lat)·360/world`), accurate to a few metres at city
970
/// zooms; the exact inverse only matters for very long drags near the
971
/// poles. Longitude wraps to [-180, 180); latitude clamps to the
972
/// Web-Mercator ±85.05° limit. The shared, unit-tested core of
973
/// `map_on_pointer_move`.
974
8
fn pan_viewport(
975
8
    centre_lat_deg: f64,
976
8
    centre_lon_deg: f64,
977
8
    zoom: f64,
978
8
    dx_px: f64,
979
8
    dy_px: f64,
980
8
) -> (f64, f64) {
981
    // World pixels at the current fractional zoom (256 px / tile).
982
8
    let world_px = 256.0 * (2.0_f64).powf(zoom);
983
8
    let d_lon = -dx_px * 360.0 / world_px;
984
8
    let d_lat = dy_px * 360.0 / world_px * centre_lat_deg.to_radians().cos();
985
8
    let new_lon = wrap_lon(centre_lon_deg + d_lon);
986
8
    let new_lat = (centre_lat_deg + d_lat).clamp(-85.0, 85.0);
987
8
    (new_lon, new_lat)
988
8
}
989

            
990
/// Parse a standalone `<svg>…</svg>` string into a `Dom` subtree via
991
/// the framework's existing XML→DOM path. The SVG is wrapped in a
992
/// minimal `<html><body>` envelope because `str_to_dom_unstyled`
993
/// expects a document root; the wrapper divs are zero-impact in
994
/// layout. Returns `None` if the `xml` feature is off or parsing
995
/// fails — the caller then falls back to the placeholder glyph.
996
// Render the decoded tile SVG to a COLOUR image node, reusing the framework's
997
// `render_svg_group` rasteriser (the one that renders the tiger), which honours
998
// the SVG `fill`/`stroke` attrs that `features_to_svg` emits. The DOM SVG path
999
// (`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"))]
pub fn svg_string_to_dom(svg: &str) -> Option<Dom> {
    let img = crate::cpurender::render_svg_to_imageref(svg.as_bytes(), 256, 256).ok()?;
    Some(
        Dom::create_image(img)
            .with_css("position: absolute; left: 0; top: 0; width: 100%; height: 100%;"),
    )
}
#[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> {
    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.
extern "C" fn map_on_after_mount(mut data: RefAny, mut info: CallbackInfo) -> Update {
    #[cfg(feature = "std")]
    if std::env::var("AZ_MAP_DEBUG").is_ok() {
        eprintln!("[map] after_mount fired");
    }
    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.
    let sweep = Timer::create(
        data.clone(),
        TimerCallback::create(map_fetch_sweep_tick),
        info.get_system_time_fn(),
    )
    .with_interval(Duration::System(SystemTimeDiff::from_millis(250)));
    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.
    info.trigger_all_virtual_view_rerender();
    Update::DoNothing
}
/// 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`.
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`.
    let mut to_spawn: Vec<TileFetchInit> = Vec::new();
    {
        let mut cache = match data.downcast_mut::<MapTileCache>() {
            Some(c) => c,
            None => return,
        };
        if cache.fetch_callback.is_none() {
            return; // no worker wired — leave tiles Pending (placeholder grid)
        }
        let template = cache.layer.url_template.as_str().to_string();
        let style_css = cache.layer.style_css.clone();
        let pending: Vec<MapTileId> = cache
            .tiles
            .iter()
            .filter(|(_, e)| matches!(e, TileEntry::Pending))
            .map(|(id, _)| *id)
            .take(MAX_SPAWN_PER_CALL)
            .collect();
        for tile in pending {
            let url = build_tile_url(&template, tile);
            cache.tiles.insert(tile, TileEntry::Fetching);
            to_spawn.push(TileFetchInit {
                tile,
                url: AzString::from(url),
                style_css: style_css.clone(),
            });
        }
        // 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.
        cache.prune_distant_tiles();
    }
    let cb = {
        let cache = match data.downcast_ref::<MapTileCache>() {
            Some(c) => c,
            None => return,
        };
        match cache.fetch_callback.as_ref() {
            Some(cb) => cb.clone(),
            None => return,
        }
    };
    #[cfg(feature = "std")]
    let spawn_count = to_spawn.len();
    for init in to_spawn {
        let init_data = RefAny::new(init);
        let writeback_data = data.clone(); // same cache dataset
        let thread = Thread::create(init_data, writeback_data, cb.clone());
        info.add_thread(ThreadId::unique(), thread);
    }
    #[cfg(feature = "std")]
    if std::env::var("AZ_MAP_DEBUG").is_ok() {
        eprintln!("[map] spawn_pending: {} thread(s) spawned", spawn_count);
    }
}
/// 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).
2
fn build_tile_url(template: &str, tile: MapTileId) -> alloc::string::String {
    use alloc::string::ToString;
2
    template
2
        .replace("{z}", &tile.z.to_string())
2
        .replace("{x}", &tile.x.to_string())
2
        .replace("{y}", &tile.y.to_string())
2
}
/// 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.
pub extern "C" fn map_tile_writeback(
    mut cache_dataset: RefAny,
    mut incoming: RefAny,
    mut info: CallbackInfo,
) -> Update {
    let msg = match incoming.downcast_ref::<TileReadyMsg>() {
        Some(m) => (m.tile, m.svg.clone(), m.error.clone()),
        None => return Update::DoNothing,
    };
    {
        let mut cache = match cache_dataset.downcast_mut::<MapTileCache>() {
            Some(c) => c,
            None => return Update::DoNothing,
        };
        #[cfg(feature = "std")]
        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()
            );
        }
        if msg.2.as_str().is_empty() {
            cache.mark_tile_ready(msg.0, msg.1);
        } else {
            cache.mark_tile_failed(msg.0, msg.2);
        }
    } // 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.
    info.trigger_all_virtual_view_rerender();
    Update::DoNothing
}
/// Inclusive `(x_min, x_max, y_min, y_max)` tile range covering a
/// `width_px × 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.
314
fn visible_tile_range(
314
    centre_x: f32,
314
    centre_y: f32,
314
    width_px: f32,
314
    height_px: f32,
314
    zoom_scale: f32,
314
    tile_count: u32,
314
) -> (i32, i32, i32, i32) {
314
    let tile_px = 256.0 * zoom_scale;
314
    let half_w = (width_px / tile_px).abs() * 0.5 + 1.0;
314
    let half_h = (height_px / tile_px).abs() * 0.5 + 1.0;
314
    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.
314
    let x_min = (centre_x - half_w).floor() as i32;
314
    let x_max = (centre_x + half_w).ceil() as i32;
314
    let y_min = ((centre_y - half_h).floor() as i32).max(0);
314
    let y_max = ((centre_y + half_h).ceil() as i32).min(max_idx);
314
    (x_min, x_max, y_min, y_max)
314
}
/// 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`.
12333
fn wrap_tile_x(x: i32, tile_count: u32) -> u32 {
12333
    x.rem_euclid(tile_count.max(1) as i32) as u32
12333
}
/// `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.
fn map_visible_tiles(
    viewport: &MapViewport,
    bounds: azul_core::geom::LogicalSize,
    layer: &MapTileLayer,
) -> alloc::vec::Vec<MapTileId> {
    let z_int =
        (viewport.zoom.floor() as i32).clamp(layer.min_zoom as i32, layer.max_zoom as i32) as u8;
    let tile_count = 1u32 << z_int as u32;
    let frac_zoom = viewport.zoom - z_int as f32;
    let zoom_scale = 2.0_f32.powf(frac_zoom);
    let centre_x = lon_to_tile_x(viewport.centre_lon_deg, tile_count as f64) as f32;
    let centre_y = lat_to_tile_y(viewport.centre_lat_deg, tile_count as f64) as f32;
    let (x_min, x_max, y_min, y_max) =
        visible_tile_range(centre_x, centre_y, bounds.width, bounds.height, zoom_scale, tile_count);
    let mut tiles = alloc::vec::Vec::new();
    for x in x_min..=x_max {
        for y in y_min..=y_max {
            tiles.push(MapTileId { z: z_int, x: wrap_tile_x(x, tile_count), y: y as u32 });
        }
    }
    tiles
}
// ────────── VirtualView callback — visible-tile rendering ─────────────
308
extern "C" fn map_widget_render(
308
    data: RefAny,
308
    info: VirtualViewCallbackInfo,
308
) -> VirtualViewReturn {
308
    let mut data = data;
308
    let bounds = info.get_bounds();
308
    let bounds_logical = bounds.get_logical_size();
308
    let width_px = bounds_logical.width;
308
    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.
308
    if !width_px.is_finite() || !height_px.is_finite() || width_px <= 0.0 || height_px <= 0.0 {
        if std::env::var("AZ_MAP_DEBUG").is_ok() {
            eprintln!("[map] non-finite bounds {}x{} — skipping render", width_px, height_px);
        }
        return VirtualViewReturn {
            dom: OptionDom::None,
            scroll_size: bounds_logical,
            scroll_offset: azul_core::geom::LogicalPosition::zero(),
            virtual_scroll_size: bounds_logical,
            virtual_scroll_offset: azul_core::geom::LogicalPosition::zero(),
        };
308
    }
308
    let (layer, viewport) = match data.downcast_ref::<MapTileCache>() {
308
        Some(c) => (c.layer.clone(), c.viewport),
        None => {
            return VirtualViewReturn {
                dom: OptionDom::None,
                scroll_size: bounds_logical,
                scroll_offset: azul_core::geom::LogicalPosition::zero(),
                virtual_scroll_size: bounds_logical,
                virtual_scroll_offset: azul_core::geom::LogicalPosition::zero(),
            };
        }
    };
    // Round the requested fractional zoom down to the nearest integer
    // tile zoom the layer supports.
308
    let z_int = (viewport.zoom.floor() as i32)
308
        .clamp(layer.min_zoom as i32, layer.max_zoom as i32)
308
        as u8;
308
    let tile_count = 1u32 << z_int as u32;
308
    let frac_zoom = viewport.zoom - z_int as f32;
308
    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).
308
    let centre_x = lon_to_tile_x(viewport.centre_lon_deg, tile_count as f64) as f32;
308
    let centre_y = lat_to_tile_y(viewport.centre_lat_deg, tile_count as f64) as f32;
    // 256 is the Mercator tile pixel size at integer zoom; tile_px is also
    // used below to position each tile div.
308
    let tile_px = 256.0 * zoom_scale;
308
    let (x_min, x_max, y_min, y_max) =
308
        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).
308
    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)
        );
308
    }
    // 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.
308
    if let Some(mut cache) = data.downcast_mut::<MapTileCache>() {
1540
        for x in x_min..=x_max {
6160
            for y in y_min..=y_max {
6160
                let id = MapTileId {
6160
                    z: z_int,
6160
                    x: wrap_tile_x(x, tile_count),
6160
                    y: y as u32,
6160
                };
6160
                cache.tiles.entry(id).or_insert(TileEntry::Pending);
6160
            }
        }
    }
    // 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.
    enum TileDisplay {
        Glyph(&'static str),
        Svg(AzString),
    }
308
    let states: BTreeMap<MapTileId, TileDisplay> = match data.downcast_ref::<MapTileCache>() {
308
        Some(c) => c
308
            .tiles
308
            .iter()
4928
            .map(|(id, e)| {
4928
                let disp = match e {
4928
                    TileEntry::Pending => TileDisplay::Glyph("…"),
                    TileEntry::Fetching => TileDisplay::Glyph("⟳"),
                    TileEntry::Ready { svg } => TileDisplay::Svg(svg.clone()),
                    TileEntry::Failed { .. } => TileDisplay::Glyph("✗"),
                };
4928
                (*id, disp)
4928
            })
308
            .collect(),
        None => BTreeMap::new(),
    };
    // 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.
308
    let mut grid = Dom::create_div().with_css(
308
        "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};
308
        grid = grid
308
            .with_callback(
308
                EventFilter::Hover(HoverEventFilter::MouseDown),
308
                data.clone(),
308
                Callback::from(map_on_pointer_down as CallbackType),
            )
308
            .with_callback(
308
                EventFilter::Hover(HoverEventFilter::MouseOver),
308
                data.clone(),
308
                Callback::from(map_on_pointer_move as CallbackType),
            )
308
            .with_callback(
308
                EventFilter::Hover(HoverEventFilter::MouseUp),
308
                data.clone(),
308
                Callback::from(map_on_pointer_up as CallbackType),
            )
308
            .with_callback(
308
                EventFilter::Hover(HoverEventFilter::MouseLeave),
308
                data.clone(),
308
                Callback::from(map_on_pointer_up as CallbackType),
            )
308
            .with_callback(
308
                EventFilter::Hover(HoverEventFilter::Scroll),
308
                data.clone(),
308
                Callback::from(map_on_scroll as CallbackType),
            );
    }
1540
    for x in x_min..=x_max {
6160
        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.
6160
            let id = MapTileId {
6160
                z: z_int,
6160
                x: wrap_tile_x(x, tile_count),
6160
                y: y as u32,
6160
            };
6160
            let screen_x =
6160
                ((x as f32 - centre_x) * tile_px + width_px * 0.5).round() as i32;
6160
            let screen_y =
6160
                ((y as f32 - centre_y) * tile_px + height_px * 0.5).round() as i32;
6160
            let size_px = tile_px.round().max(1.0) as i32;
            // 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").
6160
            let is_ready = matches!(states.get(&id), Some(TileDisplay::Svg(_)));
6160
            let chrome = if is_ready {
                ""
            } else {
6160
                "background: #e7e9ec; border: 1px solid #d0d4d9;"
            };
6160
            let style = alloc::format!(
6160
                "position: absolute; left: {}px; top: {}px; \
6160
                 width: {}px; height: {}px; {}",
                screen_x, screen_y, size_px, size_px, chrome
            );
6160
            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.
6160
            match states.get(&id) {
                Some(TileDisplay::Svg(svg)) => match svg_string_to_dom(svg.as_str()) {
                    Some(svg_dom) => {
                        tile_div = tile_div.with_child(svg_dom);
                    }
                    None => {
                        tile_div = tile_div.with_child(
                            Dom::create_text(alloc::format!("✓? z{}/{}/{}", z_int, x, y))
                                .with_css("position: absolute; left: 4px; top: 4px; font-size: 11px; color: #888;"),
                        );
                    }
                },
6160
                other => {
6160
                    let state_tag = match other {
6160
                        Some(TileDisplay::Glyph(g)) => *g,
                        _ => "",
                    };
6160
                    tile_div = tile_div.with_child(
6160
                        Dom::create_text(alloc::format!("{} z{}/{}/{}", state_tag, z_int, x, y))
6160
                            .with_css("position: absolute; left: 4px; top: 4px; font-size: 11px; color: #888;"),
                    );
                }
            }
6160
            grid = grid.with_child(tile_div);
        }
    }
308
    VirtualViewReturn {
308
        dom: OptionDom::Some(grid),
308
        scroll_size: bounds_logical,
308
        scroll_offset: azul_core::geom::LogicalPosition::zero(),
308
        virtual_scroll_size: bounds_logical,
308
        virtual_scroll_offset: azul_core::geom::LogicalPosition::zero(),
308
    }
308
}
#[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]
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!(lat_north <= 85.0 && lat_north >= -85.0);
1
        let (_, lat_south) = pan_viewport(-84.0, 0.0, 0.0, 0.0, -1.0e6);
1
        assert!(lat_south <= 85.0 && lat_south >= -85.0);
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(g.viewport.zoom as f64, viewport_at(2.0).zoom as f64, 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() {
        // 512×512 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
    }
}