From b67151fafd00f1f0f5ff844b82ba2d69f3050d5c Mon Sep 17 00:00:00 2001 From: Rob Jacobson Date: Mon, 12 Oct 2026 20:10:43 -0700 Subject: [PATCH] fix(android): fit camera to zero-height bounds - Expand zero-height camera-fitting bounds symmetrically by one millionth of a degree. - Preserve exact camera constraints and nonzero-height bounds. - Add regression coverage for the midpoint, unchanged bounds, and latitude limits. --- .../rnmbx/components/camera/CameraStop.kt | 2 +- .../com/rnmapbox/rnmbx/utils/LatLngBounds.kt | 18 +++++++- .../rnmapbox/rnmbx/utils/LatLngBoundsTest.kt | 42 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 android/src/test/kotlin/com/rnmapbox/rnmbx/utils/LatLngBoundsTest.kt diff --git a/android/src/main/java/com/rnmapbox/rnmbx/components/camera/CameraStop.kt b/android/src/main/java/com/rnmapbox/rnmbx/components/camera/CameraStop.kt index 5e6a233852..7e1078bdac 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/components/camera/CameraStop.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/components/camera/CameraStop.kt @@ -130,7 +130,7 @@ class CameraStop { // cameraForCoordinateBounds insets the bounds within the map's current padding, so omitted edges must add nothing. val boundsPadding = intArrayOf(mPaddingLeft ?: 0, mPaddingTop ?: 0, mPaddingRight ?: 0, mPaddingBottom ?: 0) val boundsCamera = map.cameraForCoordinateBounds( - mBounds!!.toBounds(), + mBounds!!.toCameraBounds(), convert(clippedPadding(boundsPadding, mapView)), bearing, tilt diff --git a/android/src/main/java/com/rnmapbox/rnmbx/utils/LatLngBounds.kt b/android/src/main/java/com/rnmapbox/rnmbx/utils/LatLngBounds.kt index 08f612b51b..dca85a2fb3 100644 --- a/android/src/main/java/com/rnmapbox/rnmbx/utils/LatLngBounds.kt +++ b/android/src/main/java/com/rnmapbox/rnmbx/utils/LatLngBounds.kt @@ -2,7 +2,6 @@ package com.rnmapbox.rnmbx.utils import com.mapbox.geojson.Point import com.mapbox.maps.CoordinateBounds -import com.rnmapbox.rnmbx.utils.LatLngBounds class LatLngBounds internal constructor( var latNorth: Double, @@ -27,9 +26,24 @@ class LatLngBounds internal constructor( ) } + fun toCameraBounds(): CoordinateBounds { + if (latNorth != latSouth) { + return toBounds() + } + + // Mapbox returns zoom 0 when fitting zero-height bounds (#4050). + // Expand only the fitting bounds, preserving the center and exact camera constraints. + val latitudePadding = 0.000001 + return CoordinateBounds( + Point.fromLngLat(lonWest, (latSouth - latitudePadding).coerceAtLeast(-90.0)), + Point.fromLngLat(lonEast, (latNorth + latitudePadding).coerceAtMost(90.0)), + false + ) + } + companion object { fun from(bbox: Double, bbox1: Double, bbox2: Double, bbox3: Double): LatLngBounds { return LatLngBounds(bbox, bbox1, bbox2, bbox3) } } -} \ No newline at end of file +} diff --git a/android/src/test/kotlin/com/rnmapbox/rnmbx/utils/LatLngBoundsTest.kt b/android/src/test/kotlin/com/rnmapbox/rnmbx/utils/LatLngBoundsTest.kt new file mode 100644 index 0000000000..970f098cb8 --- /dev/null +++ b/android/src/test/kotlin/com/rnmapbox/rnmbx/utils/LatLngBoundsTest.kt @@ -0,0 +1,42 @@ +package com.rnmapbox.rnmbx.utils + +import org.junit.jupiter.api.Assertions.* +import org.junit.jupiter.api.Test + +class LatLngBoundsTest { + @Test + fun `horizontal camera bounds enclose the original line without shifting its center`() { + val bounds = LatLngBounds.from(37.0, -121.2, 37.0, -122.1) + val cameraBounds = bounds.toCameraBounds() + + assertTrue(cameraBounds.southwest.latitude() < 37.0) + assertTrue(cameraBounds.northeast.latitude() > 37.0) + assertEquals(37.0, (cameraBounds.southwest.latitude() + cameraBounds.northeast.latitude()) / 2, 1e-12) + assertEquals(-122.1, cameraBounds.southwest.longitude()) + assertEquals(-121.2, cameraBounds.northeast.longitude()) + + // Camera constraints must still use the exact bounds after a fit. + assertEquals(37.0, bounds.toBounds().southwest.latitude()) + assertEquals(37.0, bounds.toBounds().northeast.latitude()) + } + + @Test + fun `vertical and very thin nonzero bounds retain their exact coordinates`() { + for (bounds in listOf( + LatLngBounds.from(37.1, -121.1, 37.0, -121.1), + LatLngBounds.from(37.00000001, -121.2, 37.0, -122.1) + )) { + assertEquals(bounds.toBounds(), bounds.toCameraBounds()) + } + } + + @Test + fun `expanding a horizontal fit never exceeds valid latitudes`() { + for (latitude in listOf(-90.0, 90.0)) { + val cameraBounds = LatLngBounds.from(latitude, 1.0, latitude, -1.0).toCameraBounds() + assertTrue(cameraBounds.southwest.latitude() >= -90.0) + assertTrue(cameraBounds.northeast.latitude() <= 90.0) + assertTrue(cameraBounds.southwest.latitude() < cameraBounds.northeast.latitude()) + } + } +}