diff --git a/include/mbgl/map/bound_options.hpp b/include/mbgl/map/bound_options.hpp index 5901a252cfa..00383af5649 100644 --- a/include/mbgl/map/bound_options.hpp +++ b/include/mbgl/map/bound_options.hpp @@ -12,6 +12,7 @@ namespace mbgl { */ struct BoundOptions { /// Sets the latitude and longitude bounds to which the camera center are constrained + /// If ConstrainMode is set to Screen these bounds describe what can be shown on screen. BoundOptions& withLatLngBounds(LatLngBounds b) { bounds = b; return *this; @@ -38,6 +39,7 @@ struct BoundOptions { } /// Constrain the center of the camera to be within these bounds. + /// If ConstrainMode is set to Screen these bounds describe what can be shown on screen. std::optional bounds; /// Maximum zoom level allowed. diff --git a/include/mbgl/map/mode.hpp b/include/mbgl/map/mode.hpp index 5835cf8df22..9c5219fb74d 100644 --- a/include/mbgl/map/mode.hpp +++ b/include/mbgl/map/mode.hpp @@ -19,12 +19,13 @@ enum class MapMode : EnumType { Tile ///< a once-off still image of a single tile }; -/// We can choose to constrain the map both horizontally or vertically, or only -/// vertically e.g. while panning. +/// We can choose to constrain the map both horizontally or vertically, only +/// vertically e.g. while panning, or screen to the specified bounds. enum class ConstrainMode : EnumType { None, HeightOnly, WidthAndHeight, + Screen, }; /// Satisfies embedding platforms that requires the viewport coordinate systems diff --git a/platform/ios/app-swift/Sources/MapLibreNavigationView.swift b/platform/ios/app-swift/Sources/MapLibreNavigationView.swift index 2cfe10120f2..db2c95a3208 100644 --- a/platform/ios/app-swift/Sources/MapLibreNavigationView.swift +++ b/platform/ios/app-swift/Sources/MapLibreNavigationView.swift @@ -17,6 +17,9 @@ struct MapLibreNavigationView: View { NavigationLink("BlockingGesturesExample") { BlockingGesturesExample() } + NavigationLink("MaximumScreenBoundsExample") { + MaximumScreenBoundsExample() + } NavigationLink("LineStyleLayerExample") { LineStyleLayerExampleUIViewControllerRepresentable() } diff --git a/platform/ios/app-swift/Sources/MaximumScreenBoundsExample.swift b/platform/ios/app-swift/Sources/MaximumScreenBoundsExample.swift new file mode 100644 index 00000000000..0b7ccc9d782 --- /dev/null +++ b/platform/ios/app-swift/Sources/MaximumScreenBoundsExample.swift @@ -0,0 +1,28 @@ +import MapLibre +import SwiftUI +import UIKit + +// Denver, Colorado +private let center = CLLocationCoordinate2D(latitude: 39.748947, longitude: -104.995882) + +// Colorado’s bounds +private let colorado = MLNCoordinateBounds( + sw: CLLocationCoordinate2D(latitude: 36.986207, longitude: -109.049896), + ne: CLLocationCoordinate2D(latitude: 40.989329, longitude: -102.062592) +) + +struct MaximumScreenBoundsExample: UIViewRepresentable { + func makeUIView(context _: Context) -> MLNMapView { + let mapView = MLNMapView(frame: .zero, styleURL: VERSATILES_COLORFUL_STYLE) + mapView.setCenter(center, zoomLevel: 10, direction: 0, animated: false) + mapView.maximumScreenBounds = MLNCoordinateBounds(sw: colorado.sw, ne: colorado.ne) + + return mapView + } + + func updateUIView(_: MLNMapView, context _: Context) {} + + func makeCoordinator() -> Coordinator { + Coordinator() + } +} diff --git a/platform/ios/src/MLNMapView.h b/platform/ios/src/MLNMapView.h index 1166bf9423a..3965a261d10 100644 --- a/platform/ios/src/MLNMapView.h +++ b/platform/ios/src/MLNMapView.h @@ -915,6 +915,13 @@ vertically on the map. */ @property (nonatomic) double maximumZoomLevel; +/** + * The maximum bounds of the map that can be shown on screen. + * + * @param MLNCoordinateBounds the bounds to constrain the screen to. + */ +@property (nonatomic) MLNCoordinateBounds maximumScreenBounds; + /** The heading of the map, measured in degrees clockwise from true north. diff --git a/platform/ios/src/MLNMapView.mm b/platform/ios/src/MLNMapView.mm index fa16cef7654..fe9c58a6df0 100644 --- a/platform/ios/src/MLNMapView.mm +++ b/platform/ios/src/MLNMapView.mm @@ -3867,6 +3867,21 @@ - (double)maximumZoomLevel return *self.mbglMap.getBounds().maxZoom; } +- (void)setMaximumScreenBounds:(MLNCoordinateBounds)maximumScreenBounds +{ + mbgl::LatLng sw = {maximumScreenBounds.sw.latitude, maximumScreenBounds.sw.longitude}; + mbgl::LatLng ne = {maximumScreenBounds.ne.latitude, maximumScreenBounds.ne.longitude}; + mbgl::BoundOptions newBounds = mbgl::BoundOptions().withLatLngBounds(mbgl::LatLngBounds::hull(sw, ne)); + + self.mbglMap.setBounds(newBounds); + self.mbglMap.setConstrainMode(mbgl::ConstrainMode::Screen); +} + +- (MLNCoordinateBounds)maximumScreenBounds +{ + return MLNCoordinateBoundsFromLatLngBounds(*self.mbglMap.getBounds().bounds);; +} + - (CGFloat)minimumPitch { return *self.mbglMap.getBounds().minPitch; diff --git a/src/mbgl/map/transform.cpp b/src/mbgl/map/transform.cpp index 5f7fcfcf876..dbf23ae9882 100644 --- a/src/mbgl/map/transform.cpp +++ b/src/mbgl/map/transform.cpp @@ -60,6 +60,23 @@ void Transform::resize(const Size size) { double scale{state.getScale()}; double x{state.getX()}; double y{state.getY()}; + + double lat; + double lon; + if (state.constrainScreen(scale, lat, lon)) { + // Turns out that if you resize during a transition any changes made to the state will be ignored :( + // So if we have to constrain because of a resize and a transition is in progress - cancel the transition! + if (inTransition()) { + cancelTransitions(); + } + + // It also turns out that state.setProperties isn't enough if you change the center, you need to set Cc and Bc + // too, which setLatLngZoom does. + state.setLatLngZoom(mbgl::LatLng{lat, lon}, state.scaleZoom(scale)); + observer.onCameraDidChange(MapObserver::CameraChangeMode::Immediate); + + return; + } state.constrain(scale, x, y); state.setProperties(TransformStateProperties().withScale(scale).withX(x).withY(y)); @@ -86,17 +103,23 @@ void Transform::jumpTo(const CameraOptions& camera) { * smooth animation between old and new values. The map will retain the current * values for any options not included in `options`. */ -void Transform::easeTo(const CameraOptions& camera, const AnimationOptions& animation) { +void Transform::easeTo(const CameraOptions& inputCamera, const AnimationOptions& animation) { + CameraOptions camera = inputCamera; + Duration duration = animation.duration.value_or(Duration::zero()); if (state.getLatLngBounds() == LatLngBounds() && !isGestureInProgress() && duration != Duration::zero()) { // reuse flyTo, without exaggerated animation, to achieve constant ground speed. return flyTo(camera, animation, true); } + + double zoom = camera.zoom.value_or(getZoom()); + state.constrainCameraAndZoomToBounds(camera, zoom); + const EdgeInsets& padding = camera.padding.value_or(state.getEdgeInsets()); LatLng startLatLng = getLatLng(LatLng::Unwrapped); const LatLng& unwrappedLatLng = camera.center.value_or(startLatLng); const LatLng& latLng = state.getLatLngBounds() != LatLngBounds() ? unwrappedLatLng : unwrappedLatLng.wrapped(); - double zoom = camera.zoom.value_or(getZoom()); + double bearing = camera.bearing ? util::deg2rad(-*camera.bearing) : getBearing(); double pitch = camera.pitch ? util::deg2rad(*camera.pitch) : getPitch(); @@ -176,10 +199,17 @@ void Transform::easeTo(const CameraOptions& camera, const AnimationOptions& anim Where applicable, local variable documentation begins with the associated variable or function in van Wijk (2003). */ -void Transform::flyTo(const CameraOptions& camera, const AnimationOptions& animation, bool linearZoomInterpolation) { +void Transform::flyTo(const CameraOptions& inputCamera, + const AnimationOptions& animation, + bool linearZoomInterpolation) { + CameraOptions camera = inputCamera; + + double zoom = camera.zoom.value_or(getZoom()); + state.constrainCameraAndZoomToBounds(camera, zoom); + const EdgeInsets& padding = camera.padding.value_or(state.getEdgeInsets()); const LatLng& latLng = camera.center.value_or(getLatLng(LatLng::Unwrapped)).wrapped(); - double zoom = camera.zoom.value_or(getZoom()); + double bearing = camera.bearing ? util::deg2rad(-*camera.bearing) : getBearing(); double pitch = camera.pitch ? util::deg2rad(*camera.pitch) : getPitch(); diff --git a/src/mbgl/map/transform_state.cpp b/src/mbgl/map/transform_state.cpp index d00ef81fec6..773b7d843f6 100644 --- a/src/mbgl/map/transform_state.cpp +++ b/src/mbgl/map/transform_state.cpp @@ -748,8 +748,27 @@ bool TransformState::rotatedNorth() const { return (orientation == NO::Leftwards || orientation == NO::Rightwards); } +bool TransformState::constrainScreen(double& scale_, double& lat, double& lon) const { + if (constrainMode == ConstrainMode::Screen) { + double zoom = scaleZoom(scale_); + CameraOptions options = CameraOptions(); + constrainCameraAndZoomToBounds(options, zoom); + + scale_ = zoomScale(zoom); + + if (options.center) { + LatLng center = options.center.value(); + lat = center.latitude(); + lon = center.longitude(); + + return true; + } + } + return false; +} + void TransformState::constrain(double& scale_, double& x_, double& y_) const { - if (constrainMode == ConstrainMode::None) { + if (constrainMode == ConstrainMode::None || constrainMode == ConstrainMode::Screen) { return; } @@ -768,6 +787,131 @@ void TransformState::constrain(double& scale_, double& x_, double& y_) const { } } +void TransformState::constrainCameraAndZoomToBounds(CameraOptions& requestedCamera, double& requestedZoom) const { + if (constrainMode != ConstrainMode::Screen || getLatLngBounds() == LatLngBounds()) { + return; + } + + LatLng centerLatLng = getLatLng(); + + if (requestedCamera.center) { + centerLatLng = requestedCamera.center.value(); + } + + Point anchorOffset{0, 0}; + double requestedScale = zoomScale(requestedZoom); + + // Since the transition calculations will include any specified anchor in the result + // we need to do the same when testing if the requested center and zoom is outside the bounds or not. + if (requestedCamera.anchor) { + ScreenCoordinate anchor = requestedCamera.anchor.value(); + anchor.y = getSize().height - anchor.y; + LatLng anchorLatLng = screenCoordinateToLatLng(anchor); + + // The screenCoordinateToLatLng function requires the matrices inside the state to reflect + // the requested scale. So we create a copy and set the requested zoom before the conversion. + // This will give us the same result as the transition calculations. + TransformState state{*this}; + state.setLatLngZoom(getLatLng(), scaleZoom(requestedScale)); + LatLng screenLatLng = state.screenCoordinateToLatLng(anchor); + + auto latLngCoord = Projection::project(anchorLatLng, requestedScale); + auto anchorCoord = Projection::project(screenLatLng, requestedScale); + anchorOffset = latLngCoord - anchorCoord; + } + + mbgl::LatLngBounds currentBounds = getLatLngBounds(); + mbgl::ScreenCoordinate neBounds = Projection::project(currentBounds.northeast(), requestedScale); + mbgl::ScreenCoordinate swBounds = Projection::project(currentBounds.southwest(), requestedScale); + mbgl::ScreenCoordinate center = Projection::project(centerLatLng, requestedScale); + mbgl::ScreenCoordinate currentCenter = Projection::project(getLatLng(), requestedScale); + + double minY = neBounds.y; + double maxY = swBounds.y; + double minX = swBounds.x; + double maxX = neBounds.x; + + double startX = center.x; + double startY = center.y; + + double resultX = startX; + double resultY = startY; + + uint32_t screenWidth = getSize().width; + uint32_t screenHeight = getSize().height; + + double h2 = screenHeight / 2.0; + if (startY - h2 + anchorOffset.y < minY) { + resultY = minY + h2; + } + if (startY + anchorOffset.y + h2 > maxY) { + resultY = maxY - h2; + } + + double w2 = screenWidth / 2.0; + if (startX + anchorOffset.x - w2 < minX) { + resultX = minX + w2; + } + if (startX + anchorOffset.x + w2 > maxX) { + resultX = maxX - w2; + } + + double scaleY = 0; + if (maxY - minY < screenHeight) { + scaleY = screenHeight / (maxY - minY); + resultY = (maxY + minY) / 2.0; + } + + double scaleX = 0; + if (maxX - minX < screenWidth) { + scaleX = screenWidth / (maxX - minX); + resultX = (maxX + minX) / 2.0; + } + + double maxScale = scaleX > scaleY ? scaleX : scaleY; + + // Max scale will be 1 when the screen is exactly the same size as the max bounds in either the X or Y direction. + // To avoid numerical instabilities we add small amount to the check to make sure we don't try to scale when we + // don't actually need it. + if (maxScale > 1.000001) { + requestedZoom += scaleZoom(maxScale); + + if (scaleY > scaleX) { + // If we scaled the y direction we want the resulting x position to be the same as the current x position. + resultX = currentCenter.x; + } else { + // If we scaled the x direction we want the resulting y position to be the same as the current y position. + resultY = currentCenter.y; + } + + // Since we changed the scale, we might display something outside the bounds. + // When checking we need to take into consideration that we just changed the scale, + // since the resultX and minX were calculated with the requested scale, and not the scale we + // just calculated to make sure we stay inside the bounds. + if (resultX * maxScale - w2 <= minX * maxScale) { + resultX = minX * maxScale + w2; + resultX /= maxScale; + } else if (resultX * maxScale + w2 >= maxX * maxScale) { + resultX = maxX * maxScale - w2; + resultX /= maxScale; + } + + if (resultY * maxScale - h2 <= minY * maxScale) { + resultY = minY * maxScale + h2; + resultY /= maxScale; + } else if (resultY * maxScale + h2 >= maxY * maxScale) { + resultY = maxY * maxScale - h2; + resultY /= maxScale; + } + } + + if (resultX != startX || resultY != startY) { + // If we made changes just drop any anchor point + requestedCamera.anchor.reset(); + requestedCamera.center = std::optional(Projection::unproject({resultX, resultY}, requestedScale)); + } +} + ScreenCoordinate TransformState::getCenterOffset() const { return {0.5 * (edgeInsets.left() - edgeInsets.right()), 0.5 * (edgeInsets.top() - edgeInsets.bottom())}; } diff --git a/src/mbgl/map/transform_state.hpp b/src/mbgl/map/transform_state.hpp index 28384eb0ba5..ae4ce86efd5 100644 --- a/src/mbgl/map/transform_state.hpp +++ b/src/mbgl/map/transform_state.hpp @@ -216,6 +216,9 @@ class TransformState { void setLatLngZoom(const LatLng& latLng, double zoom); void constrain(double& scale, double& x, double& y) const; + bool constrainScreen(double& scale_, double& x_, double& y_) const; + void constrainCameraAndZoomToBounds(CameraOptions& camera, double& zoom) const; + const mat4& getProjectionMatrix() const; const mat4& getInvProjectionMatrix() const; diff --git a/test/map/transform.test.cpp b/test/map/transform.test.cpp index 54edc8df27b..c2efcf9d70b 100644 --- a/test/map/transform.test.cpp +++ b/test/map/transform.test.cpp @@ -611,6 +611,26 @@ TEST(Transform, DefaultTransform) { point = state.latLngToScreenCoordinate(nullIsland); ASSERT_DOUBLE_EQ(point.x, center.x); ASSERT_DOUBLE_EQ(point.y, center.y); + + // Constrain to screen while resizing + transform.resize({1000, 500}); + transform.setLatLngBounds(LatLngBounds::hull({40.0, -10.0}, {70.0, 40.0})); + transform.setConstrainMode(ConstrainMode::Screen); + + // Request impossible zoom + AnimationOptions easeOptions(Seconds(1)); + transform.easeTo(CameraOptions().withCenter(LatLng{56, 11}).withZoom(1), easeOptions); + ASSERT_TRUE(transform.inTransition()); + transform.updateTransitions(transform.getTransitionStart() + Milliseconds(250)); + + // Rotate the screen during a transition (resize it) + transform.resize({500, 1000}); + + // The resize while constraining to screen should have stopped the transition and updated the state + ASSERT_FALSE(transform.inTransition()); + ASSERT_NEAR(transform.getLatLng().longitude(), 8.22103, 1e-4); + ASSERT_NEAR(transform.getLatLng().latitude(), 46.6905, 1e-4); + ASSERT_NEAR(transform.getState().getScale(), 38.1529, 1e-4); } TEST(Transform, LatLngBounds) { @@ -803,6 +823,60 @@ TEST(Transform, LatLngBounds) { ASSERT_DOUBLE_EQ(transform.getLatLng().longitude(), 120.0); } +TEST(Transform, ConstrainScreenToBounds) { + Transform transform; + + transform.resize({500, 500}); + transform.setLatLngBounds(LatLngBounds::hull({40.0, -10.0}, {70.0, 40.0})); + transform.setConstrainMode(ConstrainMode::Screen); + + // Request impossible zoom + transform.easeTo(CameraOptions().withCenter(LatLng{56, 11}).withZoom(1)); + ASSERT_NEAR(transform.getZoom(), 2.81378, 1e-4); + + // Request impossible center left + transform.easeTo(CameraOptions().withCenter(LatLng{56, -65}).withZoom(4)); + ASSERT_NEAR(transform.getLatLng().longitude(), 0.98632, 1e-4); + ASSERT_NEAR(transform.getLatLng().latitude(), 56.0, 1e-4); + + // Request impossible center top + transform.easeTo(CameraOptions().withCenter(LatLng{80, 11}).withZoom(4)); + ASSERT_NEAR(transform.getLatLng().longitude(), 11.0, 1e-4); + ASSERT_NEAR(transform.getLatLng().latitude(), 65.88603, 1e-4); + + // Request impossible center right + transform.easeTo(CameraOptions().withCenter(LatLng{56, 50}).withZoom(4)); + ASSERT_NEAR(transform.getLatLng().longitude(), 29.01367, 1e-4); + ASSERT_NEAR(transform.getLatLng().latitude(), 56.0, 1e-4); + + // Request impossible center bottom + transform.easeTo(CameraOptions().withCenter(LatLng{30, 11}).withZoom(4)); + ASSERT_NEAR(transform.getLatLng().longitude(), 11.0, 1e-4); + ASSERT_NEAR(transform.getLatLng().latitude(), 47.89217, 1e-4); + + // Request impossible center with anchor + transform.easeTo(CameraOptions().withAnchor(ScreenCoordinate{250, 250}).withCenter(LatLng{56, -65}).withZoom(4)); + ASSERT_NEAR(transform.getLatLng().longitude(), 0.98632, 1e-4); + ASSERT_NEAR(transform.getLatLng().latitude(), 56.0, 1e-4); + + // Request impossible center (anchor) + transform.easeTo(CameraOptions().withAnchor(ScreenCoordinate{250, 250}).withZoom(4)); + ASSERT_NEAR(transform.getLatLng().longitude(), 0.98632, 1e-4); + ASSERT_NEAR(transform.getLatLng().latitude(), 56.0, 1e-4); + + // Fly to impossible center + transform.flyTo(CameraOptions().withCenter(LatLng{56, -65}).withZoom(4)); + ASSERT_NEAR(transform.getZoom(), 4.0, 1e-4); + ASSERT_NEAR(transform.getLatLng().longitude(), 0.98632, 1e-4); + ASSERT_NEAR(transform.getLatLng().latitude(), 56.0, 1e-4); + + // Fly to impossible center and zoom + transform.flyTo(CameraOptions().withCenter(LatLng{56, -65}).withZoom(2)); + ASSERT_NEAR(transform.getZoom(), 4.0, 1e-4); + ASSERT_NEAR(transform.getLatLng().longitude(), 0.98632, 1e-4); + ASSERT_NEAR(transform.getLatLng().latitude(), 56.0, 1e-4); +} + TEST(Transform, InvalidPitch) { Transform transform; transform.resize({1, 1});