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
4
    fn default() -> Self {
93
4
        Self {
94
4
            url_template: AzString::from(
95
4
                "https://openfreemap.org/example/{z}/{x}/{y}.pbf",
96
4
            ),
97
4
            min_zoom: 0,
98
4
            max_zoom: 14,
99
4
            attribution: AzString::from("© OpenStreetMap contributors, ODbL"),
100
4
            style_css: AzString::from(""),
101
4
        }
102
4
    }
103
}
104

            
105
/// Centre + zoom + rotation state. The Leaflet shape
106
/// (`map.setView([lat, lon], zoom)`). `bearing_deg` + `pitch_deg` are
107
/// reserved for future 3D-camera work; most callers leave them at zero.
108
#[derive(Debug, Clone, Copy, PartialEq)]
109
#[repr(C)]
110
pub struct MapViewport {
111
    pub centre_lat_deg: f64,
112
    pub centre_lon_deg: f64,
113
    pub zoom: f32,
114
    pub bearing_deg: f32,
115
    pub pitch_deg: f32,
116
}
117

            
118
impl Default for MapViewport {
119
    fn default() -> Self {
120
        // A neutral "whole world, slightly zoomed in" default. Apps
121
        // care will replace this immediately.
122
        Self {
123
            centre_lat_deg: 0.0,
124
            centre_lon_deg: 0.0,
125
            zoom: 2.0,
126
            bearing_deg: 0.0,
127
            pitch_deg: 0.0,
128
        }
129
    }
130
}
131

            
132
/// A geographic coordinate in degrees. Returned by
133
/// [`MapWidget::latlon_at_px`] and (P3) the map's `on_pin_tap` hook.
134
#[derive(Debug, Clone, Copy, PartialEq)]
135
#[repr(C)]
136
pub struct MapLatLon {
137
    pub lat_deg: f64,
138
    pub lon_deg: f64,
139
}
140

            
141
// ────────── MapWidget builder ──────────────────────────────────────────
142

            
143
// NOTE: `MapWidget` mirrors the api.json struct field-for-field so the
144
// codegen FFI transmute stays sound. Callback fields (e.g.
145
// `on_viewport_changed`) ARE allowed: codegen keeps `AzMapWidget` in sync
146
// (the Button / Camera pattern). The Rust-only tile-fetch worker stays in
147
// the FFI-opaque `MapTileCache` dataset (supplied via `dom_with_fetch`).
148
#[derive(Debug, Clone, PartialEq)]
149
#[repr(C)]
150
pub struct MapWidget {
151
    pub layer: MapTileLayer,
152
    pub viewport: MapViewport,
153
    pub container_style: CssPropertyWithConditionsVec,
154
    /// Optional hook fired when the user pans / zooms (effects / persist
155
    /// the viewport). FFI-exposed; re-set on each fresh build.
156
    pub on_viewport_changed: OptionMapViewportChanged,
157
    /// Optional hook fired when the user taps the map, with the tapped
158
    /// lat/lon. FFI-exposed; re-set on each fresh build.
159
    pub on_pin_tap: OptionMapPinTap,
160
}
161

            
162
impl MapWidget {
163
    pub fn create(layer: MapTileLayer) -> Self {
164
        Self {
165
            layer,
166
            viewport: MapViewport::default(),
167
            container_style: CssPropertyWithConditionsVec::from_const_slice(&[]),
168
            on_viewport_changed: OptionMapViewportChanged::None,
169
            on_pin_tap: OptionMapPinTap::None,
170
        }
171
    }
172

            
173
    pub fn with_viewport(mut self, viewport: MapViewport) -> Self {
174
        self.viewport = viewport;
175
        self
176
    }
177

            
178
    pub fn with_container_style(mut self, css: CssPropertyWithConditionsVec) -> Self {
179
        self.container_style = css;
180
        self
181
    }
182

            
183
    /// Set a hook fired when the user pans / zooms the map. The map owns its
184
    /// own pan/pinch state; this lets your app observe or persist the
185
    /// resulting `MapViewport`. The backreference DI pattern (architecture.md).
186
    pub fn set_on_viewport_changed<C: Into<MapViewportChangedCallback>>(
187
        &mut self,
188
        data: RefAny,
189
        callback: C,
190
    ) {
191
        self.on_viewport_changed = Some(MapViewportChanged {
192
            refany: data,
193
            callback: callback.into(),
194
        })
195
        .into();
196
    }
197

            
198
    /// Builder form of [`set_on_viewport_changed`](Self::set_on_viewport_changed).
199
    pub fn with_on_viewport_changed<C: Into<MapViewportChangedCallback>>(
200
        mut self,
201
        data: RefAny,
202
        callback: C,
203
    ) -> Self {
204
        self.set_on_viewport_changed(data, callback);
205
        self
206
    }
207

            
208
    /// Set a hook fired when the user taps the map (a press + release at ~the
209
    /// same point, no drag), with the tapped lat/lon. The backreference DI
210
    /// pattern (architecture.md).
211
    pub fn set_on_pin_tap<C: Into<MapPinTapCallback>>(&mut self, data: RefAny, callback: C) {
212
        self.on_pin_tap = Some(MapPinTap {
213
            refany: data,
214
            callback: callback.into(),
215
        })
216
        .into();
217
    }
218

            
219
    /// Builder form of [`set_on_pin_tap`](Self::set_on_pin_tap).
220
    pub fn with_on_pin_tap<C: Into<MapPinTapCallback>>(
221
        mut self,
222
        data: RefAny,
223
        callback: C,
224
    ) -> Self {
225
        self.set_on_pin_tap(data, callback);
226
        self
227
    }
228

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

            
251
    /// Inverse of [`latlon_at_px`](Self::latlon_at_px): where `coord` lands in
252
    /// container pixels at `viewport`.
253
    pub fn px_at_latlon(
254
        viewport: MapViewport,
255
        coord: MapLatLon,
256
        container: azul_core::geom::LogicalSize,
257
    ) -> azul_core::geom::LogicalPosition {
258
        let world = 256.0_f64 * 2.0_f64.powf(viewport.zoom as f64);
259
        let cos_lat = viewport.centre_lat_deg.to_radians().cos();
260
        let px = container.width as f64 * 0.5
261
            + (coord.lon_deg - viewport.centre_lon_deg) * world / 360.0;
262
        let py = container.height as f64 * 0.5
263
            - (coord.lat_deg - viewport.centre_lat_deg) * world / (360.0 * cos_lat);
264
        azul_core::geom::LogicalPosition::new(px as f32, py as f32)
265
    }
266

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

            
286
    /// Like [`dom`](Self::dom), but wires a tile-fetch worker thread.
287
    /// `cb` runs on a framework `Thread` per visible tile: it reads the
288
    /// `TileFetchInit`, fetches + decodes, then
289
    /// `sender.send(ThreadReceiveMsg::WriteBack(...))` a `TileReadyMsg`
290
    /// targeting `map_tile_writeback`. The standard worker is
291
    /// `azul_dll::desktop::extra::map::tile_fetch_worker`; wrap it in a
292
    /// `ThreadCallback` to pass it here. See the recipe in
293
    /// `MOBILE_SESSION_LOG.md`.
294
    pub fn dom_with_fetch(self, cb: crate::thread::ThreadCallback) -> Dom {
295
        self.build_dom(Some(cb))
296
    }
297

            
298
    fn build_dom(self, fetch_cb: Option<crate::thread::ThreadCallback>) -> Dom {
299
        use azul_core::dom::{ComponentEventFilter, EventFilter, HoverEventFilter};
300

            
301
        let mut cache = MapTileCache::new(self.layer.clone(), self.viewport);
302
        cache.fetch_callback = fetch_cb;
303
        cache.on_viewport_changed = self.on_viewport_changed;
304
        cache.on_pin_tap = self.on_pin_tap;
305
        let dataset = RefAny::new(cache);
306
        let virtual_view_data = dataset.clone();
307

            
308
        Dom::create_div()
309
            .with_dataset(OptionRefAny::Some(dataset.clone()))
310
            .with_merge_callback(merge_map_tile_cache as DatasetMergeCallbackType)
311
            // AfterMount fires once when the widget first appears (and
312
            // again after a DOM-structure change re-mounts it). It's the
313
            // earliest point with a `CallbackInfo`, so we kick the
314
            // initial tile fetches here — without it the first frame's
315
            // tiles would stay `Pending` until the user panned/tapped.
316
            .with_callback(
317
                EventFilter::Component(ComponentEventFilter::AfterMount),
318
                dataset.clone(),
319
                crate::callbacks::Callback::from(map_on_after_mount as crate::callbacks::CallbackType),
320
            )
321
            .with_callback(
322
                EventFilter::Hover(HoverEventFilter::MouseDown),
323
                dataset.clone(),
324
                crate::callbacks::Callback::from(map_on_pointer_down as crate::callbacks::CallbackType),
325
            )
326
            .with_callback(
327
                EventFilter::Hover(HoverEventFilter::MouseOver),
328
                dataset.clone(),
329
                crate::callbacks::Callback::from(map_on_pointer_move as crate::callbacks::CallbackType),
330
            )
331
            .with_callback(
332
                EventFilter::Hover(HoverEventFilter::MouseUp),
333
                dataset.clone(),
334
                crate::callbacks::Callback::from(map_on_pointer_up as crate::callbacks::CallbackType),
335
            )
336
            .with_callback(
337
                EventFilter::Hover(HoverEventFilter::MouseLeave),
338
                dataset.clone(),
339
                crate::callbacks::Callback::from(map_on_pointer_up as crate::callbacks::CallbackType),
340
            )
341
            .with_callback(
342
                EventFilter::Hover(HoverEventFilter::TouchStart),
343
                dataset.clone(),
344
                crate::callbacks::Callback::from(map_on_pointer_down as crate::callbacks::CallbackType),
345
            )
346
            .with_callback(
347
                EventFilter::Hover(HoverEventFilter::TouchMove),
348
                dataset.clone(),
349
                crate::callbacks::Callback::from(map_on_pointer_move as crate::callbacks::CallbackType),
350
            )
351
            .with_callback(
352
                EventFilter::Hover(HoverEventFilter::TouchEnd),
353
                dataset.clone(),
354
                crate::callbacks::Callback::from(map_on_pointer_up as crate::callbacks::CallbackType),
355
            )
356
            .with_callback(
357
                EventFilter::Hover(HoverEventFilter::TouchCancel),
358
                dataset.clone(),
359
                crate::callbacks::Callback::from(map_on_pointer_up as crate::callbacks::CallbackType),
360
            )
361
            // Native gesture events (UIPinchGestureRecognizer on iOS,
362
            // ScaleGestureDetector on Android, NSMagnificationGestureRecognizer
363
            // on macOS) — fire through the same map_on_pointer_move handler
364
            // which reads `info.get_pinch()` and applies the zoom delta.
365
            .with_callback(
366
                EventFilter::Hover(HoverEventFilter::PinchIn),
367
                dataset.clone(),
368
                crate::callbacks::Callback::from(map_on_pointer_move as crate::callbacks::CallbackType),
369
            )
370
            .with_callback(
371
                EventFilter::Hover(HoverEventFilter::PinchOut),
372
                dataset,
373
                crate::callbacks::Callback::from(map_on_pointer_move as crate::callbacks::CallbackType),
374
            )
375
            .with_child(Dom::create_virtual_view(
376
                virtual_view_data,
377
                map_widget_render as azul_core::callbacks::VirtualViewCallbackType,
378
            ))
379
    }
380
}
381

            
382
// ────────── Tile cache (dataset RefAny payload) ───────────────────────
383

            
384
#[derive(Debug)]
385
pub struct MapTileCache {
386
    pub layer: MapTileLayer,
387
    pub viewport: MapViewport,
388
    /// `Ready(svg)` once the tile has been fetched + decoded;
389
    /// `Pending` while queued, `Fetching` while a worker thread is
390
    /// in flight; absent otherwise. `BTreeMap` for deterministic
391
    /// iteration so the debug log + e2e snapshots are stable.
392
    pub tiles: BTreeMap<MapTileId, TileEntry>,
393
    /// Worker thread entry point that fetches + decodes one tile.
394
    /// Supplied by `MapWidget::dom_with_fetch` (the caller, usually
395
    /// `azul_dll`'s map-tiles glue, provides this because the MVT
396
    /// decoder lives in `azul-dll`, which `azul-layout` can't depend
397
    /// on). `None` means "no fetch wired": tiles stay `Pending` and
398
    /// the placeholder grid renders. The merge callback carries this
399
    /// across relayout. Held as the `ThreadCallback` wrapper (not the
400
    /// raw fn pointer) so it round-trips through the FFI codegen.
401
    pub fetch_callback: Option<crate::thread::ThreadCallback>,
402
    /// Pixel coordinates of the cursor at the last mouse-down /
403
    /// touch-down on the widget. `Some` while a drag is in flight,
404
    /// `None` between drags. The framework consults this on every
405
    /// mouse-move to derive the pixel delta, which then converts to a
406
    /// lat/lon delta via the Web Mercator inverse.
407
    pub drag_anchor: Option<azul_core::geom::LogicalPosition>,
408
    /// Pinch reference distance (pixels) — the two-finger separation
409
    /// the last time a pinch event was observed for this widget.
410
    /// `Some` while a pinch is in flight, `None` between gestures.
411
    /// On each subsequent pinch update we compute
412
    /// `dz = log2(current_distance / pinch_anchor)` and add it to
413
    /// `viewport.zoom`, then reset the anchor to the current
414
    /// distance — so the gesture stays continuous across many frames.
415
    pub pinch_anchor: Option<f32>,
416
    /// The user's `on_viewport_changed` hook, copied here from the builder
417
    /// so the pan / pinch callbacks can fire it. Carried across relayout.
418
    pub on_viewport_changed: OptionMapViewportChanged,
419
    /// Pixel position of the last pointer-down (the original press point, not
420
    /// overwritten by pan moves). Used to tell a tap from a drag in pointer-up.
421
    pub press_origin: Option<azul_core::geom::LogicalPosition>,
422
    /// The user's `on_pin_tap` hook, copied from the builder so pointer-up can
423
    /// fire it. Carried across relayout.
424
    pub on_pin_tap: OptionMapPinTap,
425
}
426

            
427
impl MapTileCache {
428
4
    pub fn new(layer: MapTileLayer, viewport: MapViewport) -> Self {
429
4
        Self {
430
4
            layer,
431
4
            viewport,
432
4
            tiles: BTreeMap::new(),
433
4
            fetch_callback: None,
434
4
            drag_anchor: None,
435
4
            pinch_anchor: None,
436
4
            press_origin: None,
437
4
            on_viewport_changed: OptionMapViewportChanged::None,
438
4
            on_pin_tap: OptionMapPinTap::None,
439
4
        }
440
4
    }
441

            
442
    /// Worker-thread → main-thread write path. Set the decoded SVG for
443
    /// a tile (called from `map_tile_writeback`). Stamps `Ready`.
444
3
    pub fn mark_tile_ready(&mut self, tile: MapTileId, svg: AzString) {
445
3
        self.tiles.insert(tile, TileEntry::Ready { svg });
446
3
    }
447

            
448
    /// Mark a tile's fetch as failed so the grid doesn't re-spawn it
449
    /// every frame.
450
    pub fn mark_tile_failed(&mut self, tile: MapTileId, error: AzString) {
451
        self.tiles.insert(tile, TileEntry::Failed { error });
452
    }
453
}
454

            
455
#[derive(Debug, Clone)]
456
pub enum TileEntry {
457
    /// Needed by the viewport, fetch not yet spawned.
458
    Pending,
459
    /// A worker thread is fetching / decoding this tile right now.
460
    /// Distinct from `Pending` so the spawn pass doesn't double-fire.
461
    Fetching,
462
    /// Tile decoded into an SVG document. Held as the raw SVG
463
    /// string for now; the VirtualView callback will feed it
464
    /// through the framework's svg-to-dom pipeline on the next
465
    /// re-render.
466
    Ready { svg: AzString },
467
    /// Fetch failed. Held so the framework doesn't immediately
468
    /// re-try the same URL — caller can choose to clear failed
469
    /// entries on retry.
470
    Failed { error: AzString },
471
}
472

            
473
/// Worker-thread input: which tile to fetch, the resolved URL, and the
474
/// MapCSS stylesheet to apply when converting features to SVG. Boxed
475
/// into the `Thread::create` init `RefAny`.
476
#[derive(Debug, Clone)]
477
pub struct TileFetchInit {
478
    pub tile: MapTileId,
479
    pub url: AzString,
480
    /// Copy of `MapTileLayer::style_css` (empty = default palette).
481
    pub style_css: AzString,
482
}
483

            
484
/// Worker-thread output, sent back via `ThreadWriteBackMsg`. The
485
/// `map_tile_writeback` callback downcasts to this and stamps the
486
/// cache.
487
#[derive(Debug, Clone)]
488
pub struct TileReadyMsg {
489
    pub tile: MapTileId,
490
    /// Decoded SVG document for the tile, or empty on failure (with
491
    /// `error` set).
492
    pub svg: AzString,
493
    /// Empty on success; an error message on failure.
494
    pub error: AzString,
495
}
496

            
497
// ────────── Merge callback — cache survives relayout ─────────────────
498

            
499
/// Copy every entry from the previous frame's cache into the new
500
/// frame's cache. The next layout pass thus sees the same in-flight /
501
/// decoded set without re-fetching anything.
502
2
extern "C" fn merge_map_tile_cache(mut new_data: RefAny, mut old_data: RefAny) -> RefAny {
503
    {
504
2
        let new_guard_opt = new_data.downcast_mut::<MapTileCache>();
505
2
        let old_guard_opt = old_data.downcast_ref::<MapTileCache>();
506
2
        if let (Some(mut new_g), Some(old_g)) = (new_guard_opt, old_guard_opt) {
507
2
            for (id, entry) in old_g.tiles.iter() {
508
2
                new_g.tiles.entry(*id).or_insert_with(|| entry.clone());
509
            }
510
            // Inherit the worker callback the builder stored last
511
            // frame (the freshly-built cache from `dom()` has it too,
512
            // but be defensive in case a future caller drops it).
513
2
            if new_g.fetch_callback.is_none() {
514
2
                new_g.fetch_callback = old_g.fetch_callback.clone();
515
2
            }
516
2
            if let OptionMapViewportChanged::None = new_g.on_viewport_changed {
517
2
                new_g.on_viewport_changed = old_g.on_viewport_changed.clone();
518
2
            }
519
2
            if let OptionMapPinTap::None = new_g.on_pin_tap {
520
2
                new_g.on_pin_tap = old_g.on_pin_tap.clone();
521
2
            }
522
            // Keep the freshest viewport (the one the layout pass
523
            // just attached) — only inherit tile bytes + worker.
524
        }
525
    }
526
2
    new_data
527
2
}
528

            
529
// ────────── Pan + zoom callbacks ─────────────────────────────────────
530

            
531
use crate::callbacks::CallbackInfo;
532
use azul_core::callbacks::Update;
533

            
534
// --- User hook: on_viewport_changed (backreference DI, FFI-exposed) ---
535

            
536
/// User hook fired when the user pans or zooms the map. Lets app code observe
537
/// or persist the widget-driven `MapViewport` (which otherwise lives only in
538
/// the opaque `MapTileCache`). The backreference DI pattern (architecture.md).
539
pub type MapViewportChangedCallbackType =
540
    extern "C" fn(RefAny, CallbackInfo, MapViewport) -> Update;
541
impl_widget_callback!(
542
    MapViewportChanged,
543
    OptionMapViewportChanged,
544
    MapViewportChangedCallback,
545
    MapViewportChangedCallbackType
546
);
547
azul_core::impl_managed_callback! {
548
    wrapper:        MapViewportChangedCallback,
549
    info_ty:        CallbackInfo,
550
    return_ty:      Update,
551
    default_ret:    Update::DoNothing,
552
    invoker_static: MAP_VIEWPORT_CHANGED_INVOKER,
553
    invoker_ty:     AzMapViewportChangedCallbackInvoker,
554
    thunk_fn:       az_map_viewport_changed_callback_thunk,
555
    setter_fn:      AzApp_setMapViewportChangedCallbackInvoker,
556
    from_handle_fn: AzMapViewportChangedCallback_createFromHostHandle,
557
    extra_args:     [ viewport: MapViewport ],
558
}
559

            
560
/// Invoke a map widget's optional `on_viewport_changed` hook with the new
561
/// viewport, returning the user's `Update` (`DoNothing` if no hook is set).
562
fn invoke_viewport_changed(
563
    hook: &OptionMapViewportChanged,
564
    info: &CallbackInfo,
565
    viewport: MapViewport,
566
) -> Update {
567
    match hook {
568
        OptionMapViewportChanged::Some(h) => {
569
            (h.callback.cb)(h.refany.clone(), info.clone(), viewport)
570
        }
571
        OptionMapViewportChanged::None => Update::DoNothing,
572
    }
573
}
574

            
575
// --- User hook: on_pin_tap (backreference DI, FFI-exposed) ---
576

            
577
/// User hook fired when the user taps the map (a press + release at ~the same
578
/// point, no pan/pinch). Receives the tapped [`MapLatLon`] (projected via
579
/// [`MapWidget::latlon_at_px`]) so apps can drop a pin without wiring their own
580
/// tap handling + projection. The backreference DI pattern (architecture.md).
581
pub type MapPinTapCallbackType = extern "C" fn(RefAny, CallbackInfo, MapLatLon) -> Update;
582
impl_widget_callback!(
583
    MapPinTap,
584
    OptionMapPinTap,
585
    MapPinTapCallback,
586
    MapPinTapCallbackType
587
);
588
azul_core::impl_managed_callback! {
589
    wrapper:        MapPinTapCallback,
590
    info_ty:        CallbackInfo,
591
    return_ty:      Update,
592
    default_ret:    Update::DoNothing,
593
    invoker_static: MAP_PIN_TAP_INVOKER,
594
    invoker_ty:     AzMapPinTapCallbackInvoker,
595
    thunk_fn:       az_map_pin_tap_callback_thunk,
596
    setter_fn:      AzApp_setMapPinTapCallbackInvoker,
597
    from_handle_fn: AzMapPinTapCallback_createFromHostHandle,
598
    extra_args:     [ coord: MapLatLon ],
599
}
600

            
601
/// Invoke a map widget's optional `on_pin_tap` hook with the tapped coordinate.
602
fn invoke_pin_tap(hook: &OptionMapPinTap, info: &CallbackInfo, coord: MapLatLon) -> Update {
603
    match hook {
604
        OptionMapPinTap::Some(h) => (h.callback.cb)(h.refany.clone(), info.clone(), coord),
605
        OptionMapPinTap::None => Update::DoNothing,
606
    }
607
}
608

            
609
/// Pointer down → record the drag anchor. The widget knows nothing
610
/// about the user's overall state RefAny — only its own dataset —
611
/// so the anchor lives in `MapTileCache::drag_anchor`.
612
extern "C" fn map_on_pointer_down(mut data: RefAny, info: CallbackInfo) -> Update {
613
    let pos = match info.get_cursor_relative_to_node().into_option() {
614
        Some(p) => azul_core::geom::LogicalPosition::new(p.x, p.y),
615
        None => return Update::DoNothing,
616
    };
617
    if let Some(mut cache) = data.downcast_mut::<MapTileCache>() {
618
        cache.drag_anchor = Some(pos);
619
        cache.press_origin = Some(pos);
620
    }
621
    Update::DoNothing
622
}
623

            
624
/// Pointer move during an active drag → translate the pixel delta
625
/// into a lat/lon delta via the Web Mercator inverse and update
626
/// `viewport.centre_lat_deg / centre_lon_deg`. Updates the anchor so
627
/// the next move computes a fresh delta.
628
///
629
/// If a pinch gesture is in flight (two fingers on the widget), the
630
/// pan branch is skipped and the move event drives zoom instead —
631
/// `dz = log2(current_distance / pinch_anchor)`. The next move resets
632
/// the anchor to the current distance so the gesture stays
633
/// continuous across many frames.
634
extern "C" fn map_on_pointer_move(mut data: RefAny, info: CallbackInfo) -> Update {
635
    // Active pinch wins over single-finger pan.
636
    if let Some(pinch) = info.get_pinch().into_option() {
637
        let mut cache = match data.downcast_mut::<MapTileCache>() {
638
            Some(c) => c,
639
            None => return Update::DoNothing,
640
        };
641
        let anchor = *cache.pinch_anchor.get_or_insert(pinch.current_distance);
642
        if anchor > 1.0 && pinch.current_distance > 1.0 {
643
            let dz = (pinch.current_distance / anchor).log2();
644
            let min = cache.layer.min_zoom as f32;
645
            let max = cache.layer.max_zoom as f32;
646
            cache.viewport.zoom = (cache.viewport.zoom + dz).clamp(min, max);
647
        }
648
        cache.pinch_anchor = Some(pinch.current_distance);
649
        // Pinch is exclusive with pan — clear the drag anchor so the
650
        // pinch end doesn't accidentally drop into a pan.
651
        cache.drag_anchor = None;
652
        let hook = cache.on_viewport_changed.clone();
653
        let vp = cache.viewport;
654
        drop(cache);
655
        invoke_viewport_changed(&hook, &info, vp);
656
        return Update::RefreshDom;
657
    }
658

            
659
    let pos = match info.get_cursor_relative_to_node().into_option() {
660
        Some(p) => azul_core::geom::LogicalPosition::new(p.x, p.y),
661
        None => return Update::DoNothing,
662
    };
663
    let mut cache_guard = match data.downcast_mut::<MapTileCache>() {
664
        Some(c) => c,
665
        None => return Update::DoNothing,
666
    };
667
    let anchor = match cache_guard.drag_anchor {
668
        Some(a) => a,
669
        None => return Update::DoNothing, // no active drag
670
    };
671

            
672
    let dx_px = (pos.x - anchor.x) as f64;
673
    let dy_px = (pos.y - anchor.y) as f64;
674
    if dx_px.abs() < 0.5 && dy_px.abs() < 0.5 {
675
        return Update::DoNothing;
676
    }
677

            
678
    let (new_lon, new_lat) = pan_viewport(
679
        cache_guard.viewport.centre_lat_deg,
680
        cache_guard.viewport.centre_lon_deg,
681
        cache_guard.viewport.zoom as f64,
682
        dx_px,
683
        dy_px,
684
    );
685
    cache_guard.viewport.centre_lon_deg = new_lon;
686
    cache_guard.viewport.centre_lat_deg = new_lat;
687
    cache_guard.drag_anchor = Some(pos);
688

            
689
    let hook = cache_guard.on_viewport_changed.clone();
690
    let vp = cache_guard.viewport;
691
    drop(cache_guard);
692
    invoke_viewport_changed(&hook, &info, vp);
693
    Update::RefreshDom
694
}
695

            
696
/// Pointer up / pointer leave → end the drag *and* the pinch. Either
697
/// can be in flight (and pinch supersedes pan in the move handler);
698
/// clear both anchors on release.
699
extern "C" fn map_on_pointer_up(mut data: RefAny, mut info: CallbackInfo) -> Update {
700
    // Cursor + container size for tap projection (read before borrowing data).
701
    let up_pos = info
702
        .get_cursor_relative_to_node()
703
        .into_option()
704
        .map(|p| azul_core::geom::LogicalPosition::new(p.x, p.y));
705
    let container = info
706
        .get_hit_node_rect()
707
        .map(|r| r.size)
708
        .unwrap_or(azul_core::geom::LogicalSize::new(0.0, 0.0));
709
    let (press, viewport, hook) = match data.downcast_mut::<MapTileCache>() {
710
        Some(mut cache) => {
711
            let out = (cache.press_origin, cache.viewport, cache.on_pin_tap.clone());
712
            cache.drag_anchor = None;
713
            cache.pinch_anchor = None;
714
            cache.press_origin = None;
715
            out
716
        }
717
        None => (None, MapViewport::default(), OptionMapPinTap::None),
718
    };
719
    // A press + release at ~the same point (no pan/pinch) is a tap: project it
720
    // to lat/lon and fire the user's on_pin_tap hook.
721
    if let (Some(origin), Some(up)) = (press, up_pos) {
722
        let dx = (up.x - origin.x) as f64;
723
        let dy = (up.y - origin.y) as f64;
724
        if dx * dx + dy * dy < 36.0 {
725
            let coord = MapWidget::latlon_at_px(viewport, up, container);
726
            invoke_pin_tap(&hook, &info, coord);
727
        }
728
    }
729
    // After a pan / pinch settles, kick off fetches for any tiles the new
730
    // viewport needs. (Only a `CallbackInfo`-bearing callback can spawn them.)
731
    spawn_pending_tile_fetches(&mut data, &mut info);
732
    Update::RefreshDom
733
}
734

            
735
18
fn wrap_lon(lon: f64) -> f64 {
736
    // `rem_euclid` (not `%`) so even large negative deltas normalise:
737
    // `%` follows the dividend's sign and would leak values < -180.
738
18
    (lon + 180.0).rem_euclid(360.0) - 180.0
739
18
}
740

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

            
750
/// Longitude (deg) → fractional tile-x at the given `tile_count`.
751
20
fn lon_to_tile_x(lon_deg: f64, tile_count: f64) -> f64 {
752
20
    (lon_deg + 180.0) / 360.0 * tile_count
753
20
}
754

            
755
/// Latitude (deg) → fractional tile-y at the given `tile_count`.
756
19
fn lat_to_tile_y(lat_deg: f64, tile_count: f64) -> f64 {
757
19
    let lat_rad = lat_deg.to_radians();
758
19
    let mercator =
759
19
        (1.0 - (lat_rad.tan() + 1.0 / lat_rad.cos()).ln() / core::f64::consts::PI) / 2.0;
760
19
    mercator * tile_count
761
19
}
762

            
763
/// Fractional tile-x → longitude (deg). Inverse of [`lon_to_tile_x`].
764
/// Verified against the forward direction in the tests below; the
765
/// upcoming tap-to-pin handler reuses it to turn a tap into a lat/lon.
766
#[allow(dead_code)]
767
16
fn tile_x_to_lon(x: f64, tile_count: f64) -> f64 {
768
16
    x / tile_count * 360.0 - 180.0
769
16
}
770

            
771
/// Fractional tile-y → latitude (deg). Inverse of [`lat_to_tile_y`].
772
#[allow(dead_code)]
773
16
fn tile_y_to_lat(y: f64, tile_count: f64) -> f64 {
774
16
    let n = core::f64::consts::PI * (1.0 - 2.0 * y / tile_count);
775
16
    n.sinh().atan().to_degrees()
776
16
}
777

            
778
/// Apply a drag of `(dx_px, dy_px)` screen pixels to a viewport centre,
779
/// returning the new `(centre_lon_deg, centre_lat_deg)`. Dragging right
780
/// (+dx) pans the map content right, i.e. recentres on a *lower* longitude
781
/// (hence the minus). Latitude uses the small-angle Mercator approximation
782
/// (`d_lat ≈ dy·cos(lat)·360/world`), accurate to a few metres at city
783
/// zooms; the exact inverse only matters for very long drags near the
784
/// poles. Longitude wraps to [-180, 180); latitude clamps to the
785
/// Web-Mercator ±85.05° limit. The shared, unit-tested core of
786
/// `map_on_pointer_move`.
787
8
fn pan_viewport(
788
8
    centre_lat_deg: f64,
789
8
    centre_lon_deg: f64,
790
8
    zoom: f64,
791
8
    dx_px: f64,
792
8
    dy_px: f64,
793
8
) -> (f64, f64) {
794
    // World pixels at the current fractional zoom (256 px / tile).
795
8
    let world_px = 256.0 * (2.0_f64).powf(zoom);
796
8
    let d_lon = -dx_px * 360.0 / world_px;
797
8
    let d_lat = dy_px * 360.0 / world_px * centre_lat_deg.to_radians().cos();
798
8
    let new_lon = wrap_lon(centre_lon_deg + d_lon);
799
8
    let new_lat = (centre_lat_deg + d_lat).clamp(-85.0, 85.0);
800
8
    (new_lon, new_lat)
801
8
}
802

            
803
/// Parse a standalone `<svg>…</svg>` string into a `Dom` subtree via
804
/// the framework's existing XML→DOM path. The SVG is wrapped in a
805
/// minimal `<html><body>` envelope because `str_to_dom_unstyled`
806
/// expects a document root; the wrapper divs are zero-impact in
807
/// layout. Returns `None` if the `xml` feature is off or parsing
808
/// fails — the caller then falls back to the placeholder glyph.
809
#[cfg(feature = "xml")]
810
fn svg_string_to_dom(svg: &str) -> Option<Dom> {
811
    use azul_core::xml::{str_to_dom_unstyled, ComponentMap};
812

            
813
    let wrapped = alloc::format!("<html><body>{}</body></html>", svg);
814
    let nodes = crate::xml::parse_xml_string(&wrapped).ok()?;
815
    let component_map = ComponentMap::default();
816
    str_to_dom_unstyled(nodes.as_ref(), &component_map).ok()
817
}
818

            
819
#[cfg(not(feature = "xml"))]
820
fn svg_string_to_dom(_svg: &str) -> Option<Dom> {
821
    None
822
}
823

            
824
/// Fires once when the widget first mounts. Kicks the initial tile
825
/// fetches so the map populates without waiting for a user gesture.
826
/// (The VirtualView marks the viewport's tiles `Pending` during the
827
/// layout pass that precedes mount-event dispatch; this handler then
828
/// spawns the workers for them.) Returns `RefreshDom` so the
829
/// `Fetching` state shows immediately.
830
extern "C" fn map_on_after_mount(mut data: RefAny, mut info: CallbackInfo) -> Update {
831
    spawn_pending_tile_fetches(&mut data, &mut info);
832
    Update::RefreshDom
833
}
834

            
835
/// Scan the cache for `Pending` tiles and spawn one framework `Thread`
836
/// per tile (capped per call so a big viewport jump doesn't spawn
837
/// hundreds at once). Each thread gets:
838
/// - init `RefAny` = `TileFetchInit { tile, url }`
839
/// - writeback `RefAny` = a clone of the cache dataset, so
840
///   `map_tile_writeback` mutates the same cache the VirtualView reads.
841
///
842
/// Tiles transition `Pending → Fetching` here so they aren't
843
/// re-spawned next frame. No-op when the cache has no `fetch_callback`.
844
fn spawn_pending_tile_fetches(data: &mut RefAny, info: &mut CallbackInfo) {
845
    use crate::thread::Thread;
846
    use azul_core::task::ThreadId;
847

            
848
    // Per-call spawn cap — bounds the burst on a big viewport jump.
849
    const MAX_SPAWN_PER_CALL: usize = 16;
850

            
851
    // Collect the work first (URL build + state flip) under one borrow,
852
    // then spawn outside it so we don't hold the cache lock across
853
    // `info.add_thread`.
854
    let mut to_spawn: Vec<TileFetchInit> = Vec::new();
855
    {
856
        let mut cache = match data.downcast_mut::<MapTileCache>() {
857
            Some(c) => c,
858
            None => return,
859
        };
860
        if cache.fetch_callback.is_none() {
861
            return; // no worker wired — leave tiles Pending (placeholder grid)
862
        }
863
        let template = cache.layer.url_template.as_str().to_string();
864
        let style_css = cache.layer.style_css.clone();
865
        let pending: Vec<MapTileId> = cache
866
            .tiles
867
            .iter()
868
            .filter(|(_, e)| matches!(e, TileEntry::Pending))
869
            .map(|(id, _)| *id)
870
            .take(MAX_SPAWN_PER_CALL)
871
            .collect();
872
        for tile in pending {
873
            let url = build_tile_url(&template, tile);
874
            cache.tiles.insert(tile, TileEntry::Fetching);
875
            to_spawn.push(TileFetchInit {
876
                tile,
877
                url: AzString::from(url),
878
                style_css: style_css.clone(),
879
            });
880
        }
881
    }
882

            
883
    let cb = {
884
        let cache = match data.downcast_ref::<MapTileCache>() {
885
            Some(c) => c,
886
            None => return,
887
        };
888
        match cache.fetch_callback.as_ref() {
889
            Some(cb) => cb.clone(),
890
            None => return,
891
        }
892
    };
893

            
894
    for init in to_spawn {
895
        let init_data = RefAny::new(init);
896
        let writeback_data = data.clone(); // same cache dataset
897
        let thread = Thread::create(init_data, writeback_data, cb.clone());
898
        info.add_thread(ThreadId::unique(), thread);
899
    }
900
}
901

            
902
/// `{z}/{x}/{y}` substitution. Mirrors `azul_dll`'s `build_tile_url`
903
/// (the widget can't reach the dll, so it's duplicated here — trivial).
904
2
fn build_tile_url(template: &str, tile: MapTileId) -> alloc::string::String {
905
    use alloc::string::ToString;
906
2
    template
907
2
        .replace("{z}", &tile.z.to_string())
908
2
        .replace("{x}", &tile.x.to_string())
909
2
        .replace("{y}", &tile.y.to_string())
910
2
}
911

            
912
/// Worker-thread → main-thread writeback. `cache_dataset` is the
913
/// `writeback_data` handed to `Thread::create` (the same
914
/// `MapTileCache` the widget reads); `incoming` is the `TileReadyMsg`
915
/// the worker sent. Stamps the tile `Ready` (or `Failed`) and asks for
916
/// a relayout so the VirtualView renders the new content.
917
pub extern "C" fn map_tile_writeback(
918
    mut cache_dataset: RefAny,
919
    mut incoming: RefAny,
920
    _info: CallbackInfo,
921
) -> Update {
922
    let msg = match incoming.downcast_ref::<TileReadyMsg>() {
923
        Some(m) => (m.tile, m.svg.clone(), m.error.clone()),
924
        None => return Update::DoNothing,
925
    };
926
    let mut cache = match cache_dataset.downcast_mut::<MapTileCache>() {
927
        Some(c) => c,
928
        None => return Update::DoNothing,
929
    };
930
    if msg.2.as_str().is_empty() {
931
        cache.mark_tile_ready(msg.0, msg.1);
932
    } else {
933
        cache.mark_tile_failed(msg.0, msg.2);
934
    }
935
    Update::RefreshDom
936
}
937

            
938
/// Inclusive `(x_min, x_max, y_min, y_max)` tile range covering a
939
/// `width_px × height_px` viewport centred at tile-space `(centre_x,
940
/// centre_y)`, at fractional `zoom_scale` and integer `tile_count` (2^z).
941
/// A one-tile margin (`+ 1.0`) is added each side so a tile scrolling into
942
/// view is already requested; the result is clamped to the valid
943
/// `0..=tile_count-1` grid. The pure core of `map_widget_render`'s grid
944
/// loop — what decides which tiles get fetched.
945
6
fn visible_tile_range(
946
6
    centre_x: f32,
947
6
    centre_y: f32,
948
6
    width_px: f32,
949
6
    height_px: f32,
950
6
    zoom_scale: f32,
951
6
    tile_count: u32,
952
6
) -> (i32, i32, i32, i32) {
953
6
    let tile_px = 256.0 * zoom_scale;
954
6
    let half_w = (width_px / tile_px).abs() * 0.5 + 1.0;
955
6
    let half_h = (height_px / tile_px).abs() * 0.5 + 1.0;
956
6
    let max_idx = tile_count as i32 - 1;
957
6
    let x_min = ((centre_x - half_w).floor() as i32).max(0);
958
6
    let x_max = ((centre_x + half_w).ceil() as i32).min(max_idx);
959
6
    let y_min = ((centre_y - half_h).floor() as i32).max(0);
960
6
    let y_max = ((centre_y + half_h).ceil() as i32).min(max_idx);
961
6
    (x_min, x_max, y_min, y_max)
962
6
}
963

            
964
// ────────── VirtualView callback — visible-tile rendering ─────────────
965

            
966
extern "C" fn map_widget_render(
967
    data: RefAny,
968
    info: VirtualViewCallbackInfo,
969
) -> VirtualViewReturn {
970
    let mut data = data;
971
    let bounds = info.get_bounds();
972
    let bounds_logical = bounds.get_logical_size();
973
    let width_px = bounds_logical.width;
974
    let height_px = bounds_logical.height;
975

            
976
    let (layer, viewport) = match data.downcast_ref::<MapTileCache>() {
977
        Some(c) => (c.layer.clone(), c.viewport),
978
        None => {
979
            return VirtualViewReturn {
980
                dom: OptionDom::None,
981
                scroll_size: bounds_logical,
982
                scroll_offset: azul_core::geom::LogicalPosition::zero(),
983
                virtual_scroll_size: bounds_logical,
984
                virtual_scroll_offset: azul_core::geom::LogicalPosition::zero(),
985
            };
986
        }
987
    };
988

            
989
    // Round the requested fractional zoom down to the nearest integer
990
    // tile zoom the layer supports.
991
    let z_int = (viewport.zoom.floor() as i32)
992
        .clamp(layer.min_zoom as i32, layer.max_zoom as i32)
993
        as u8;
994
    let tile_count = 1u32 << z_int as u32;
995
    let frac_zoom = viewport.zoom - z_int as f32;
996
    let zoom_scale = 2.0_f32.powf(frac_zoom);
997

            
998
    // Convert WGS-84 → Web-Mercator-XYZ tile-space via the shared
999
    // projection helpers (the single source of truth, unit-tested below).
    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;
    // 256 is the Mercator tile pixel size at integer zoom; tile_px is also
    // used below to position each tile div.
    let tile_px = 256.0 * zoom_scale;
    let (x_min, x_max, y_min, y_max) =
        visible_tile_range(centre_x, centre_y, width_px, height_px, zoom_scale, tile_count);
    // 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.
    if let Some(mut cache) = data.downcast_mut::<MapTileCache>() {
        for x in x_min..=x_max {
            for y in y_min..=y_max {
                let id = MapTileId {
                    z: z_int,
                    x: x as u32,
                    y: y as u32,
                };
                cache.tiles.entry(id).or_insert(TileEntry::Pending);
            }
        }
    }
    // 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),
    }
    let states: BTreeMap<MapTileId, TileDisplay> = match data.downcast_ref::<MapTileCache>() {
        Some(c) => c
            .tiles
            .iter()
            .map(|(id, e)| {
                let disp = match e {
                    TileEntry::Pending => TileDisplay::Glyph("…"),
                    TileEntry::Fetching => TileDisplay::Glyph("⟳"),
                    TileEntry::Ready { svg } => TileDisplay::Svg(svg.clone()),
                    TileEntry::Failed { .. } => TileDisplay::Glyph("✗"),
                };
                (*id, disp)
            })
            .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.
    let mut grid = Dom::create_div().with_css(
        "position: absolute; left: 0; top: 0; width: 100%; height: 100%; overflow: hidden;",
    );
    for x in x_min..=x_max {
        for y in y_min..=y_max {
            let id = MapTileId {
                z: z_int,
                x: x as u32,
                y: y as u32,
            };
            let screen_x =
                ((x as f32 - centre_x) * tile_px + width_px * 0.5).round() as i32;
            let screen_y =
                ((y as f32 - centre_y) * tile_px + height_px * 0.5).round() as i32;
            let size_px = tile_px.round().max(1.0) as i32;
            let style = alloc::format!(
                "position: absolute; left: {}px; top: {}px; \
                 width: {}px; height: {}px; \
                 background: #e7e9ec; border: 1px solid #d0d4d9;",
                screen_x, screen_y, size_px, size_px
            );
            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.
            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;"),
                        );
                    }
                },
                other => {
                    let state_tag = match other {
                        Some(TileDisplay::Glyph(g)) => *g,
                        _ => "",
                    };
                    tile_div = tile_div.with_child(
                        Dom::create_text(alloc::format!("{} z{}/{}/{}", state_tag, z_int, x, y))
                            .with_css("position: absolute; left: 4px; top: 4px; font-size: 11px; color: #888;"),
                    );
                }
            }
            grid = grid.with_child(tile_div);
        }
    }
    VirtualViewReturn {
        dom: OptionDom::Some(grid),
        scroll_size: bounds_logical,
        scroll_offset: azul_core::geom::LogicalPosition::zero(),
        virtual_scroll_size: bounds_logical,
        virtual_scroll_offset: azul_core::geom::LogicalPosition::zero(),
    }
}
#[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
    }
4
    fn viewport_at(zoom: f32) -> MapViewport {
4
        MapViewport {
4
            centre_lat_deg: 0.0,
4
            centre_lon_deg: 0.0,
4
            zoom,
4
            bearing_deg: 0.0,
4
            pitch_deg: 0.0,
4
        }
4
    }
    #[test]
1
    fn merge_preserves_old_tiles_and_keeps_new_viewport() {
        // The merge callback is what lets the tile cache survive relayout:
        // a tile downloaded last frame must still be present in the cache
        // the layout pass rebuilds this frame, without re-fetching.
1
        let tile = MapTileId { z: 5, x: 1, y: 2 };
1
        let mut old_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(5.0));
1
        old_cache.mark_tile_ready(tile, AzString::from("<svg/>"));
        // Fresh cache as rebuilt by dom() each relayout: new viewport, no tiles.
1
        let new_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(9.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();
        // Downloaded tile survived the relayout...
1
        assert!(g.tiles.contains_key(&tile), "old tile must survive relayout");
        // ...but the freshest viewport (just attached by the layout pass) wins.
1
        approx(g.viewport.zoom as f64, 9.0, 1e-6);
1
    }
    #[test]
1
    fn merge_keeps_new_tile_over_old() {
        // When both frames have the same tile, the new frame's entry wins
        // (or_insert_with must not clobber a freshly-stamped tile).
1
        let tile = MapTileId { z: 5, x: 1, y: 2 };
1
        let mut old_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(5.0));
1
        old_cache.mark_tile_ready(tile, AzString::from("OLD"));
1
        let mut new_cache = MapTileCache::new(MapTileLayer::default(), viewport_at(5.0));
1
        new_cache.mark_tile_ready(tile, AzString::from("NEW"));
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();
1
        match g.tiles.get(&tile) {
1
            Some(TileEntry::Ready { svg }) => {
1
                assert_eq!(svg.as_str(), "NEW", "new frame's tile must not be clobbered");
            }
            other => panic!("expected Ready, got {other:?}"),
        }
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 tile_range_clamps_to_single_tile_world_at_zoom0() {
        // zoom 0 → tile_count 1, so the only valid index is 0 regardless of
        // viewport size; the margin must not produce out-of-range indices.
1
        let (x0, x1, y0, y1) = visible_tile_range(0.5, 0.5, 256.0, 256.0, 1.0, 1);
1
        assert_eq!((x0, x1, y0, y1), (0, 0, 0, 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_at_grid_edges() {
        // Centre at the left/top edge: no negative indices.
1
        let (x0, _, y0, _) = visible_tile_range(0.0, 0.0, 512.0, 512.0, 1.0, 16);
1
        assert!(x0 >= 0 && y0 >= 0);
        // Centre at the right/bottom edge: never past tile_count-1.
1
        let (_, x1, _, y1) = visible_tile_range(15.0, 15.0, 512.0, 512.0, 1.0, 16);
1
        assert!(x1 <= 15 && y1 <= 15);
1
    }
}