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 5e6a23385..aac2707b2 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 @@ -127,13 +127,18 @@ class CameraStop { val tilt = if (mTilt != null) mTilt!! else currentCamera.pitch val bearing = if (mBearing != null) mBearing!! else currentCamera.bearing - // 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(), - convert(clippedPadding(boundsPadding, mapView)), - bearing, - tilt + val bounds = mBounds!!.toCameraBounds() + // Apply viewport padding before fitting; coordinatesPadding would add to it. + val boundsCamera = map.cameraForCoordinates( + coordinates = listOf(bounds.southwest, bounds.northeast), + camera = CameraOptions.Builder() + .padding(cameraPaddingEdgeInsets) + .bearing(bearing) + .pitch(tilt) + .build(), + coordinatesPadding = EdgeInsets(0.0, 0.0, 0.0, 0.0), + maxZoom = null, + offset = null ) builder.center(boundsCamera.center) builder.anchor(boundsCamera.anchor) @@ -243,4 +248,4 @@ class CameraStop { } } } -} \ No newline at end of file +} 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 08f612b51..dca85a2fb 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/components/camera/CameraStopTest.kt b/android/src/test/kotlin/com/rnmapbox/rnmbx/components/camera/CameraStopTest.kt new file mode 100644 index 000000000..88f636e82 --- /dev/null +++ b/android/src/test/kotlin/com/rnmapbox/rnmbx/components/camera/CameraStopTest.kt @@ -0,0 +1,97 @@ +package com.rnmapbox.rnmbx.components.camera + +import com.mapbox.geojson.Point +import com.mapbox.maps.CameraOptions +import com.mapbox.maps.CameraState +import com.mapbox.maps.EdgeInsets +import com.mapbox.maps.MapboxMap +import com.rnmapbox.rnmbx.components.mapview.RNMBXMapView +import com.rnmapbox.rnmbx.utils.LatLngBounds +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class CameraStopTest { + @Test + fun `bounds fit expands horizontal bounds and uses resolved viewport padding`() { + val map = mock() + val mapView = mock() + val cameraState = mock() + val fittedCamera = CameraOptions.Builder() + .center(Point.fromLngLat(-121.65, 37.0)) + .zoom(10.0) + .bearing(15.0) + .pitch(20.0) + .padding(EdgeInsets(10.0, 20.0, 300.0, 40.0)) + .build() + + whenever(mapView.getMapboxMap()).thenReturn(map) + whenever(mapView.width).thenReturn(1000) + whenever(mapView.height).thenReturn(800) + whenever(map.cameraState).thenReturn(cameraState) + whenever(cameraState.center).thenReturn(Point.fromLngLat(-122.1, 37.0)) + whenever(cameraState.zoom).thenReturn(14.0) + whenever(cameraState.bearing).thenReturn(15.0) + whenever(cameraState.pitch).thenReturn(20.0) + whenever(cameraState.padding).thenReturn(EdgeInsets(10.0, 20.0, 30.0, 40.0)) + whenever( + map.cameraForCoordinates( + any(), + any(), + anyOrNull(), + anyOrNull(), + anyOrNull() + ) + ).thenReturn(fittedCamera) + + val stop = CameraStop().apply { + setBounds( + LatLngBounds.from(37.0, -121.2, 37.0, -122.1), + paddingLeft = null, + paddingRight = null, + paddingTop = null, + paddingBottom = 300 + ) + } + + stop.toCameraUpdate(mapView) + + val coordinates = argumentCaptor>() + val camera = argumentCaptor() + val coordinatesPadding = argumentCaptor() + verify(map).cameraForCoordinates( + coordinates.capture(), + camera.capture(), + coordinatesPadding.capture(), + anyOrNull(), + anyOrNull() + ) + + val southWest = coordinates.firstValue[0] + val northEast = coordinates.firstValue[1] + assertEquals(-122.1, southWest.longitude()) + assertEquals(-121.2, northEast.longitude()) + assertTrue(southWest.latitude() < 37.0) + assertTrue(northEast.latitude() > 37.0) + assertEquals(37.0, (southWest.latitude() + northEast.latitude()) / 2.0, 1e-12) + + assertEquals(0.0, coordinatesPadding.firstValue.top) + assertEquals(0.0, coordinatesPadding.firstValue.left) + assertEquals(0.0, coordinatesPadding.firstValue.bottom) + assertEquals(0.0, coordinatesPadding.firstValue.right) + + val cameraPadding = camera.firstValue.padding!! + assertEquals(10.0, cameraPadding.top) + assertEquals(20.0, cameraPadding.left) + assertEquals(300.0, cameraPadding.bottom) + assertEquals(40.0, cameraPadding.right) + assertEquals(15.0, camera.firstValue.bearing) + assertEquals(20.0, camera.firstValue.pitch) + } +} 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 000000000..970f098cb --- /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()) + } + } +}