Skip to main content

slint_mapping/
viewport.rs

1//! Visible-tile computation.
2//!
3//! Given the camera (`centre_lon`, `centre_lat`, `zoom`) and viewport
4//! size in pixels, decide which `(x, y, z)` tiles overlap the viewport
5//! and at what pixel offset each one should be drawn.
6
7use crate::projection::{lonlat_to_tile, tile_to_lonlat};
8use crate::source::TileKey;
9
10/// One tile selected for display, with its pixel offset inside the
11/// viewport. Mirrors the `Tile` slint struct exposed by `MapView`.
12#[derive(Debug, Clone, Copy)]
13pub struct PlacedTile {
14    pub key: TileKey,
15    /// Pixel offset of the tile's top-left corner from the viewport
16    /// top-left. May be negative when the tile extends off-screen left
17    /// or above the viewport.
18    pub x: f32,
19    pub y: f32,
20    pub size: f32,
21}
22
23/// Compute the set of tiles that overlap the viewport, in row-major
24/// order (top-to-bottom, left-to-right within each row). The result
25/// is small (typically <50 tiles even for a large window) so we
26/// return an owned `Vec`.
27pub fn visible_tiles(
28    centre_longitude: f64,
29    centre_latitude: f64,
30    zoom: f64,
31    viewport_width: f64,
32    viewport_height: f64,
33    tile_size: u32,
34) -> Vec<PlacedTile> {
35    // Use `floor` to pick which integer zoom layer of tiles to fetch,
36    // and scale each tile by `2^(zoom - z_floor)` when rendered. This
37    // keeps the tile layer at the same geographic scale as the
38    // fractional zoom used by marker projection + the cursor-anchor
39    // math in `lonlat_to_viewport_px` / `center_for_anchor_at_viewport_px`
40    // — without scaling, a half-step wheel notch (zoom 10 → 10.5)
41    // rendered tiles as if at zoom 11 while markers and the anchor
42    // computation lived at 10.5, so the cursor and markers visibly
43    // drifted off the tile features they should sit on.
44    let z_floor = zoom.floor().clamp(0.0, 22.0) as u8;
45    let z_for_proj = z_floor as f64;
46    let frac = zoom - z_for_proj;
47    let scale = 2.0_f64.powf(frac);
48    let tile_size_f = tile_size as f64 * scale;
49
50    // Where is the camera centre, in fractional tile-space at z_floor?
51    let (centre_tx, centre_ty) = lonlat_to_tile(centre_longitude, centre_latitude, z_for_proj);
52
53    // Which pixel inside the centre tile is the viewport centre?
54    let centre_px_in_tile_x = (centre_tx.fract()) * tile_size_f;
55    let centre_px_in_tile_y = (centre_ty.fract()) * tile_size_f;
56    let centre_tile_x = centre_tx.floor() as i64;
57    let centre_tile_y = centre_ty.floor() as i64;
58
59    // Viewport centre in screen pixels.
60    let vp_cx = viewport_width / 2.0;
61    let vp_cy = viewport_height / 2.0;
62
63    // How many tiles do we need on each side of the centre tile to
64    // cover the viewport? Add one extra ring for slight over-draw so
65    // panning doesn't expose blank edges before the next refresh.
66    let tiles_left = ((vp_cx + centre_px_in_tile_x) / tile_size_f).ceil() as i64 + 1;
67    let tiles_right = ((viewport_width - vp_cx + (tile_size_f - centre_px_in_tile_x)) / tile_size_f)
68        .ceil() as i64
69        + 1;
70    let tiles_above = ((vp_cy + centre_px_in_tile_y) / tile_size_f).ceil() as i64 + 1;
71    let tiles_below = ((viewport_height - vp_cy + (tile_size_f - centre_px_in_tile_y))
72        / tile_size_f)
73        .ceil() as i64
74        + 1;
75
76    let max_tile_idx = 1i64 << z_floor as i64; // 2^z_floor
77
78    let mut out =
79        Vec::with_capacity(((tiles_left + tiles_right) * (tiles_above + tiles_below)) as usize);
80
81    for ty in (centre_tile_y - tiles_above)..=(centre_tile_y + tiles_below) {
82        // Clamp Y to valid tile range; outside is "no tile here" (poles).
83        if ty < 0 || ty >= max_tile_idx {
84            continue;
85        }
86        for tx in (centre_tile_x - tiles_left)..=(centre_tile_x + tiles_right) {
87            // Wrap X across the antimeridian so the world tiles
88            // seamlessly when panned past ±180°.
89            let wrapped_tx = ((tx % max_tile_idx) + max_tile_idx) % max_tile_idx;
90            // Pixel offset: where this tile's top-left sits relative to
91            // the viewport's top-left. Negative for tiles whose left
92            // edge is off-screen.
93            let pixel_x = vp_cx - centre_px_in_tile_x + ((tx - centre_tile_x) as f64) * tile_size_f;
94            let pixel_y = vp_cy - centre_px_in_tile_y + ((ty - centre_tile_y) as f64) * tile_size_f;
95            out.push(PlacedTile {
96                key: TileKey {
97                    x: wrapped_tx as u32,
98                    y: ty as u32,
99                    z: z_floor,
100                },
101                x: pixel_x as f32,
102                y: pixel_y as f32,
103                // Rendered tile edge length on screen, including the
104                // fractional-zoom scale-up factor. Tiles between
105                // integer zoom levels render larger than their native
106                // 256 px so the visual scale matches the marker /
107                // anchor maths.
108                size: tile_size_f as f32,
109            });
110        }
111    }
112    out
113}
114
115/// Project a geographic point (`lon`, `lat`) into viewport pixel
116/// coordinates for the same camera (`centre_lon`, `centre_lat`, `zoom`)
117/// and viewport size used by [`visible_tiles`]. Used to place markers /
118/// overlays on top of the tile layer at the correct screen position.
119///
120/// Returns the viewport-space (x, y) in pixels with origin at the
121/// viewport's top-left. Values can fall outside `[0, viewport_width]` /
122/// `[0, viewport_height]` for points that are off-screen — callers
123/// that want to cull should filter on those bounds.
124// Pure projection: every parameter is a meaningful coordinate that
125// shows up in the math directly. Bundling them into a struct would
126// add an indirection at every call site for no clarity win.
127#[allow(clippy::too_many_arguments)]
128pub fn lonlat_to_viewport_px(
129    lon: f64,
130    lat: f64,
131    centre_longitude: f64,
132    centre_latitude: f64,
133    zoom: f64,
134    viewport_width: f64,
135    viewport_height: f64,
136    tile_size: u32,
137) -> (f64, f64) {
138    let ts = tile_size as f64;
139    let (tx_c, ty_c) = lonlat_to_tile(centre_longitude, centre_latitude, zoom);
140    let (tx_p, ty_p) = lonlat_to_tile(lon, lat, zoom);
141    (
142        viewport_width / 2.0 + (tx_p - tx_c) * ts,
143        viewport_height / 2.0 + (ty_p - ty_c) * ts,
144    )
145}
146
147/// Inverse of [`lonlat_to_viewport_px`]: given a viewport pixel and the
148/// current camera, return the geographic point that pixel represents.
149/// Used at the start of a zoom burst to lock the anchor's lon/lat —
150/// see [`center_for_anchor_at_viewport_px`] for the corresponding
151/// "place this lon/lat at this pixel" step.
152#[allow(clippy::too_many_arguments)]
153pub fn viewport_px_to_lonlat(
154    px: f64,
155    py: f64,
156    centre_longitude: f64,
157    centre_latitude: f64,
158    zoom: f64,
159    viewport_width: f64,
160    viewport_height: f64,
161    tile_size: u32,
162) -> (f64, f64) {
163    let ts = tile_size as f64;
164    let (tx_c, ty_c) = lonlat_to_tile(centre_longitude, centre_latitude, zoom);
165    let dx = px - viewport_width / 2.0;
166    let dy = py - viewport_height / 2.0;
167    tile_to_lonlat(tx_c + dx / ts, ty_c + dy / ts, zoom)
168}
169
170/// Given a fixed geographic anchor (`anchor_lon`, `anchor_lat`) and the
171/// viewport pixel where it must land, compute the camera centre
172/// (lon, lat) at the given zoom. The pairing of this with
173/// [`viewport_px_to_lonlat`] lets callers run a multi-event zoom
174/// gesture against a single anchor captured at burst-start, so the
175/// camera doesn't drift between scroll-events as it would if the
176/// anchor's geographic position were re-derived each event.
177#[allow(clippy::too_many_arguments)]
178pub fn center_for_anchor_at_viewport_px(
179    anchor_lon: f64,
180    anchor_lat: f64,
181    anchor_x_px: f64,
182    anchor_y_px: f64,
183    zoom: f64,
184    viewport_width: f64,
185    viewport_height: f64,
186    tile_size: u32,
187) -> (f64, f64) {
188    let ts = tile_size as f64;
189    let (tx_a, ty_a) = lonlat_to_tile(anchor_lon, anchor_lat, zoom);
190    let adx = anchor_x_px - viewport_width / 2.0;
191    let ady = anchor_y_px - viewport_height / 2.0;
192    tile_to_lonlat(tx_a - adx / ts, ty_a - ady / ts, zoom)
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn zoom_zero_returns_just_the_world_tile() {
201        let tiles = visible_tiles(0.0, 0.0, 0.0, 256.0, 256.0, 256);
202        assert!(
203            tiles.iter().any(|t| t.key == TileKey { x: 0, y: 0, z: 0 }),
204            "should include (0,0,0)"
205        );
206        // Other "tiles" requested past the single 1×1 world get clamped
207        // out by the Y check; X wraps to 0.
208    }
209
210    #[test]
211    fn antimeridian_wraps() {
212        // At zoom 1 there are 2 tiles wide. Camera near +180° should
213        // still find tiles at x=0 (the wrap) and x=1.
214        let tiles = visible_tiles(179.0, 0.0, 1.0, 1024.0, 256.0, 256);
215        let xs: Vec<u32> = tiles.iter().map(|t| t.key.x).collect();
216        assert!(xs.contains(&0));
217        assert!(xs.contains(&1));
218    }
219
220    #[test]
221    fn polar_extents_are_clipped() {
222        // North of Mercator's valid range (~85.05°) there are no
223        // tiles. With the camera centred at extreme high latitude and
224        // a tall viewport, we should NOT see tiles with y < 0 or
225        // y >= 2^z.
226        let z = 4u8;
227        let max_y = 1u32 << z;
228        let tiles = visible_tiles(0.0, 85.0, z as f64, 1024.0, 2048.0, 256);
229        for t in &tiles {
230            assert!(
231                t.key.y < max_y,
232                "y={} exceeds valid range at zoom {z}",
233                t.key.y
234            );
235        }
236    }
237
238    #[test]
239    fn every_tile_has_unique_pixel_position_at_centre_camera() {
240        // No two visible tiles should collide on (x, y) — would mean
241        // the viewport math placed them on top of each other.
242        let tiles = visible_tiles(0.0, 0.0, 3.0, 1024.0, 768.0, 256);
243        for (i, a) in tiles.iter().enumerate() {
244            for b in &tiles[i + 1..] {
245                let same_x = (a.x - b.x).abs() < 0.5;
246                let same_y = (a.y - b.y).abs() < 0.5;
247                assert!(
248                    !(same_x && same_y),
249                    "tiles {a:?} and {b:?} share a pixel slot"
250                );
251            }
252        }
253    }
254
255    #[test]
256    fn project_centre_lands_at_viewport_centre() {
257        // The camera's own centre point should always land at the
258        // viewport's centre pixel, regardless of zoom / size.
259        let (x, y) =
260            lonlat_to_viewport_px(-0.1276, 51.5074, -0.1276, 51.5074, 13.0, 800.0, 600.0, 256);
261        assert!((x - 400.0).abs() < 1e-6, "x={x}");
262        assert!((y - 300.0).abs() < 1e-6, "y={y}");
263    }
264
265    #[test]
266    fn project_offset_point_falls_off_centre_in_expected_direction() {
267        // Point east of camera centre → x > viewport centre x.
268        let cx = 400.0;
269        let cy = 300.0;
270        let (x_east, y_east) =
271            lonlat_to_viewport_px(1.0, 51.5074, 0.0, 51.5074, 8.0, 800.0, 600.0, 256);
272        assert!(
273            x_east > cx,
274            "east of camera should be right-of-centre, got x={x_east}"
275        );
276        assert!(
277            (y_east - cy).abs() < 1.0,
278            "same latitude → near vertical centre"
279        );
280
281        // Point south → y > viewport centre y (Mercator y increases going south).
282        let (x_south, y_south) =
283            lonlat_to_viewport_px(0.0, 50.0, 0.0, 51.5074, 8.0, 800.0, 600.0, 256);
284        assert!(
285            y_south > cy,
286            "south of camera should be below centre, got y={y_south}"
287        );
288        assert!(
289            (x_south - cx).abs() < 1.0,
290            "same longitude → near horizontal centre"
291        );
292    }
293
294    #[test]
295    fn viewport_round_trip_is_identity() {
296        // lon/lat → px → lon/lat should round-trip to high precision
297        // when nothing else changes — the projection itself is
298        // numerically stable at typical zooms.
299        let (centre_lon, centre_lat, zoom) = (-0.1276, 51.5074, 13.0);
300        let pts = [(-0.05, 51.51), (-0.18, 51.49), (0.0, 51.5074)];
301        for (lon, lat) in pts {
302            let (px, py) =
303                lonlat_to_viewport_px(lon, lat, centre_lon, centre_lat, zoom, 800.0, 600.0, 256);
304            let (rlon, rlat) =
305                viewport_px_to_lonlat(px, py, centre_lon, centre_lat, zoom, 800.0, 600.0, 256);
306            assert!((rlon - lon).abs() < 1e-9, "lon: {lon} → {rlon}");
307            assert!((rlat - lat).abs() < 1e-9, "lat: {lat} → {rlat}");
308        }
309    }
310
311    #[test]
312    fn burst_zoom_keeps_anchor_pinned_to_pixel() {
313        // Simulate a multi-event zoom burst: capture anchor at burst
314        // start, then zoom several steps. Anchor must land at the
315        // same viewport pixel after each step. This is the property
316        // the burst-locked zoom in the viewer relies on to stop the
317        // camera drifting between scroll events.
318        let (centre_lon, centre_lat) = (-0.1276, 51.5074);
319        let (vp_w, vp_h) = (800.0, 600.0);
320        let (anchor_px, anchor_py) = (620.0, 180.0); // offset from centre
321        let start_zoom = 10.0;
322
323        let (alon, alat) = viewport_px_to_lonlat(
324            anchor_px, anchor_py, centre_lon, centre_lat, start_zoom, vp_w, vp_h, 256,
325        );
326
327        for &z in &[10.5_f64, 11.0, 12.0, 13.7, 15.0] {
328            let (new_lon, new_lat) = center_for_anchor_at_viewport_px(
329                alon, alat, anchor_px, anchor_py, z, vp_w, vp_h, 256,
330            );
331            let (rpx, rpy) =
332                lonlat_to_viewport_px(alon, alat, new_lon, new_lat, z, vp_w, vp_h, 256);
333            assert!(
334                (rpx - anchor_px).abs() < 1e-6,
335                "zoom={z} px: {anchor_px} → {rpx}"
336            );
337            assert!(
338                (rpy - anchor_py).abs() < 1e-6,
339                "zoom={z} py: {anchor_py} → {rpy}"
340            );
341        }
342    }
343
344    // ============================================================
345    // Cursor-anchored zoom mechanics
346    // ============================================================
347    //
348    // End-to-end simulation of the burst-locked zoom-on-cursor flow
349    // implemented in `slint-mobile-components/crates/viewer/src/main.rs`:
350    //
351    //   1. Lock the anchor at burst start:
352    //        anchor_geo = viewport_px_to_lonlat(cursor_px, camera_before)
353    //   2. For each subsequent event in the burst, recompute the camera
354    //      so that the (unchanged) anchor_geo lands back on the
355    //      (unchanged) cursor pixel:
356    //        new_centre = center_for_anchor_at_viewport_px(
357    //            anchor_geo, cursor_px, new_zoom)
358    //   3. Verify: lonlat_to_viewport_px(anchor_geo, new_centre, new_zoom)
359    //      == cursor_px
360    //
361    // The point on the map under the cursor must not move — that's the
362    // whole UX promise. Tested at three deliberately chosen cursor
363    // positions (top-left corner, off-centre, "random") and a sweep of
364    // zoom deltas covering both directions plus fractional steps.
365
366    /// Helper — for a given cursor position, run a multi-step zoom
367    /// burst through every (zoom_before + delta) listed and assert the
368    /// anchor's geographic position lands back at the original cursor
369    /// pixel after each step.
370    fn assert_anchor_stays_pinned(label: &str, cursor_px: (f64, f64)) {
371        // London at z=10. Arbitrary but realistic — exercises non-zero
372        // tile-coord fractions on both axes.
373        let centre_lon = -0.1276_f64;
374        let centre_lat = 51.5074_f64;
375        let zoom_before = 10.0_f64;
376        let (vp_w, vp_h) = (800.0_f64, 600.0_f64);
377        let tile_size = 256_u32;
378
379        let (anchor_lon, anchor_lat) = viewport_px_to_lonlat(
380            cursor_px.0,
381            cursor_px.1,
382            centre_lon,
383            centre_lat,
384            zoom_before,
385            vp_w,
386            vp_h,
387            tile_size,
388        );
389
390        // Sweep deltas: small + large, positive + negative, integer +
391        // fractional. Any of these breaking would surface as the cursor
392        // visually drifting across a continuous scroll.
393        let deltas = [0.25_f64, 0.5, 1.0, 1.7, 3.0, -0.5, -1.0, -2.3];
394        for delta in deltas {
395            let new_zoom = zoom_before + delta;
396            let (new_centre_lon, new_centre_lat) = center_for_anchor_at_viewport_px(
397                anchor_lon,
398                anchor_lat,
399                cursor_px.0,
400                cursor_px.1,
401                new_zoom,
402                vp_w,
403                vp_h,
404                tile_size,
405            );
406            let (rpx, rpy) = lonlat_to_viewport_px(
407                anchor_lon,
408                anchor_lat,
409                new_centre_lon,
410                new_centre_lat,
411                new_zoom,
412                vp_w,
413                vp_h,
414                tile_size,
415            );
416            // Tolerance is 1e-6 logical pixels — well below any
417            // possible visible drift. Float precision lets us go even
418            // tighter; this leaves headroom.
419            assert!(
420                (rpx - cursor_px.0).abs() < 1e-6,
421                "{label}: x drifted at delta={delta} — wanted {}, got {rpx}",
422                cursor_px.0,
423            );
424            assert!(
425                (rpy - cursor_px.1).abs() < 1e-6,
426                "{label}: y drifted at delta={delta} — wanted {}, got {rpy}",
427                cursor_px.1,
428            );
429        }
430    }
431
432    #[test]
433    fn zoom_anchor_pinned_at_top_left_corner() {
434        // Worst case for anchor math: maximal (adx, ady) magnitudes
435        // relative to the viewport centre, so any centre-offset bug
436        // would show up here largest.
437        assert_anchor_stays_pinned("top-left", (10.0, 10.0));
438    }
439
440    #[test]
441    fn zoom_anchor_pinned_off_centre() {
442        // Realistic "user puts cursor on a feature near the top-right"
443        // — non-symmetric offset on both axes.
444        assert_anchor_stays_pinned("off-centre", (650.0, 120.0));
445    }
446
447    #[test]
448    fn zoom_anchor_pinned_at_random_point() {
449        // A deterministically-chosen "random" cursor — picked once via
450        // a hash of the test name to land at a non-axis-aligned spot
451        // that wouldn't be caught by a symmetric configuration. Kept
452        // hard-coded so failures are reproducible.
453        assert_anchor_stays_pinned("random", (317.42, 463.81));
454    }
455
456    #[test]
457    fn zoom_anchor_pinned_through_a_simulated_burst() {
458        // Multi-event burst from the same anchor — what actually
459        // happens during a continuous wheel-spin. Each successive
460        // event reuses the burst's locked anchor against an updated
461        // camera; the cursor pixel must still pin.
462        let (vp_w, vp_h) = (800.0_f64, 600.0_f64);
463        let tile_size = 256_u32;
464        let cursor = (520.0_f64, 95.0_f64);
465
466        let mut centre_lon = -0.1276_f64;
467        let mut centre_lat = 51.5074_f64;
468        let mut zoom = 10.0_f64;
469
470        // Lock the anchor against the *initial* camera (burst start).
471        let (anchor_lon, anchor_lat) = viewport_px_to_lonlat(
472            cursor.0, cursor.1, centre_lon, centre_lat, zoom, vp_w, vp_h, tile_size,
473        );
474
475        // Apply a burst of zoom-in events.
476        for step in 0..8 {
477            zoom += 0.5;
478            let (new_centre_lon, new_centre_lat) = center_for_anchor_at_viewport_px(
479                anchor_lon, anchor_lat, cursor.0, cursor.1, zoom, vp_w, vp_h, tile_size,
480            );
481            centre_lon = new_centre_lon;
482            centre_lat = new_centre_lat;
483
484            let (rpx, rpy) = lonlat_to_viewport_px(
485                anchor_lon, anchor_lat, centre_lon, centre_lat, zoom, vp_w, vp_h, tile_size,
486            );
487            assert!(
488                (rpx - cursor.0).abs() < 1e-6 && (rpy - cursor.1).abs() < 1e-6,
489                "step {step}: cursor drifted from {cursor:?} to ({rpx}, {rpy}) at zoom {zoom}",
490            );
491        }
492    }
493
494    #[test]
495    fn zoom_anchor_pinned_across_full_bleed_phone_viewport() {
496        // Regression for the bug that motivated these tests: hardcoded
497        // 412×892 viewport constants in the viewer didn't match the
498        // page's actual MapEmbed size at runtime, so the anchor maths
499        // computed against the wrong viewport centre and the cursor
500        // appeared to drift. Run with the real cell size to make sure
501        // a future regression of "hardcoded constants creep back in"
502        // would be caught with realistic geometry.
503        let (vp_w, vp_h) = (412.0_f64, 892.0_f64);
504        let tile_size = 256_u32;
505        let cursor = (305.0_f64, 740.0_f64);
506        let centre_lon = -0.1276_f64;
507        let centre_lat = 51.5074_f64;
508        let zoom_before = 13.0_f64;
509
510        let (anchor_lon, anchor_lat) = viewport_px_to_lonlat(
511            cursor.0,
512            cursor.1,
513            centre_lon,
514            centre_lat,
515            zoom_before,
516            vp_w,
517            vp_h,
518            tile_size,
519        );
520
521        for &dz in &[0.5_f64, 1.0, 2.0, -1.0] {
522            let new_zoom = zoom_before + dz;
523            let (new_centre_lon, new_centre_lat) = center_for_anchor_at_viewport_px(
524                anchor_lon, anchor_lat, cursor.0, cursor.1, new_zoom, vp_w, vp_h, tile_size,
525            );
526            let (rpx, rpy) = lonlat_to_viewport_px(
527                anchor_lon,
528                anchor_lat,
529                new_centre_lon,
530                new_centre_lat,
531                new_zoom,
532                vp_w,
533                vp_h,
534                tile_size,
535            );
536            assert!(
537                (rpx - cursor.0).abs() < 1e-6,
538                "x at dz={dz}: {} → {rpx}",
539                cursor.0
540            );
541            assert!(
542                (rpy - cursor.1).abs() < 1e-6,
543                "y at dz={dz}: {} → {rpy}",
544                cursor.1
545            );
546        }
547    }
548
549    #[test]
550    fn fractional_zoom_scales_rendered_tile_size() {
551        // Regression: visible_tiles used to snap zoom to the nearest
552        // integer and render tiles at native size, while marker /
553        // anchor maths ran at the fractional zoom. Cursor + markers
554        // drifted off the tile features they should have sat on at
555        // every non-integer zoom.
556        //
557        // Now: at z = z_floor + frac, each tile renders at
558        // 256 * 2^frac px on screen, so the tile layer's geographic
559        // scale matches the maths that uses fractional zoom directly.
560        let at_int = visible_tiles(0.0, 0.0, 10.0, 800.0, 600.0, 256);
561        let at_half = visible_tiles(0.0, 0.0, 10.5, 800.0, 600.0, 256);
562        let at_one_below_next = visible_tiles(0.0, 0.0, 10.999, 800.0, 600.0, 256);
563
564        let int_size = at_int[0].size as f64;
565        let half_size = at_half[0].size as f64;
566        let nearly_next_size = at_one_below_next[0].size as f64;
567
568        assert!((int_size - 256.0).abs() < 1e-3, "int zoom: {int_size}");
569        // 256 * 2^0.5 ≈ 362.04
570        assert!(
571            (half_size - 362.039).abs() < 0.1,
572            "half-step zoom should scale tile size to ~362, got {half_size}",
573        );
574        // 256 * 2^0.999 ≈ 511.65 — almost back to native-double
575        assert!(
576            (nearly_next_size - 511.65).abs() < 0.2,
577            "almost-next zoom should scale to ~512, got {nearly_next_size}",
578        );
579
580        // All three should pick z_floor for their tile keys (10 in
581        // every case, since 10.999 still floors to 10).
582        for t in &at_half {
583            assert_eq!(t.key.z, 10, "fractional zoom must keep z_floor in tile key");
584        }
585        for t in &at_one_below_next {
586            assert_eq!(t.key.z, 10);
587        }
588    }
589
590    #[test]
591    fn marker_lines_up_with_tile_under_it_at_fractional_zoom() {
592        // The actual UX guarantee: a marker placed at the camera centre
593        // should land at the viewport centre regardless of fractional
594        // zoom, AND the centre tile (the one containing the camera
595        // centre) should render across the viewport centre too. Tile
596        // and marker maths must agree on what "1 fractional tile"
597        // means on screen.
598        let centre_lon = -0.1276_f64;
599        let centre_lat = 51.5074_f64;
600        let (vp_w, vp_h) = (800.0_f64, 600.0_f64);
601
602        for zoom in [10.0_f64, 10.25, 10.5, 10.75, 11.0] {
603            let (mpx, mpy) = lonlat_to_viewport_px(
604                centre_lon, centre_lat, centre_lon, centre_lat, zoom, vp_w, vp_h, 256,
605            );
606            assert!(
607                (mpx - vp_w / 2.0).abs() < 1e-6,
608                "marker x at z={zoom}: {mpx}"
609            );
610            assert!(
611                (mpy - vp_h / 2.0).abs() < 1e-6,
612                "marker y at z={zoom}: {mpy}"
613            );
614
615            // The tile whose footprint contains the camera centre.
616            let tiles = visible_tiles(centre_lon, centre_lat, zoom, vp_w, vp_h, 256);
617            let scale = 2.0_f64.powf(zoom - zoom.floor());
618            let expected_tile_size = 256.0 * scale;
619            for t in &tiles {
620                assert!(
621                    (t.size as f64 - expected_tile_size).abs() < 1e-3,
622                    "tile size at z={zoom}: expected {expected_tile_size}, got {}",
623                    t.size,
624                );
625            }
626        }
627    }
628
629    #[test]
630    fn larger_viewport_returns_at_least_as_many_tiles() {
631        // Monotonicity: doubling the viewport size should never reduce
632        // the visible-tile count.
633        let small = visible_tiles(0.0, 0.0, 4.0, 512.0, 512.0, 256).len();
634        let big = visible_tiles(0.0, 0.0, 4.0, 1024.0, 1024.0, 256).len();
635        assert!(
636            big >= small,
637            "bigger viewport had fewer tiles ({big} vs {small})"
638        );
639    }
640}