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}