Allow constraining camera to maximum bounds (#2475)

This commit is contained in:
Christian Hansen
2025-01-09 13:38:35 +01:00
committed by GitHub
parent f582327191
commit 62b92e2038
10 changed files with 314 additions and 7 deletions

View File

@@ -12,6 +12,7 @@ namespace mbgl {
*/ */
struct BoundOptions { struct BoundOptions {
/// Sets the latitude and longitude bounds to which the camera center are constrained /// 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) { BoundOptions& withLatLngBounds(LatLngBounds b) {
bounds = b; bounds = b;
return *this; return *this;
@@ -38,6 +39,7 @@ struct BoundOptions {
} }
/// Constrain the center of the camera to be within these bounds. /// 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<LatLngBounds> bounds; std::optional<LatLngBounds> bounds;
/// Maximum zoom level allowed. /// Maximum zoom level allowed.

View File

@@ -19,12 +19,13 @@ enum class MapMode : EnumType {
Tile ///< a once-off still image of a single tile Tile ///< a once-off still image of a single tile
}; };
/// We can choose to constrain the map both horizontally or vertically, or only /// We can choose to constrain the map both horizontally or vertically, only
/// vertically e.g. while panning. /// vertically e.g. while panning, or screen to the specified bounds.
enum class ConstrainMode : EnumType { enum class ConstrainMode : EnumType {
None, None,
HeightOnly, HeightOnly,
WidthAndHeight, WidthAndHeight,
Screen,
}; };
/// Satisfies embedding platforms that requires the viewport coordinate systems /// Satisfies embedding platforms that requires the viewport coordinate systems

View File

@@ -17,6 +17,9 @@ struct MapLibreNavigationView: View {
NavigationLink("BlockingGesturesExample") { NavigationLink("BlockingGesturesExample") {
BlockingGesturesExample() BlockingGesturesExample()
} }
NavigationLink("MaximumScreenBoundsExample") {
MaximumScreenBoundsExample()
}
NavigationLink("LineStyleLayerExample") { NavigationLink("LineStyleLayerExample") {
LineStyleLayerExampleUIViewControllerRepresentable() LineStyleLayerExampleUIViewControllerRepresentable()
} }

View File

@@ -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()
}
}

View File

@@ -915,6 +915,13 @@ vertically on the map.
*/ */
@property (nonatomic) double maximumZoomLevel; @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. The heading of the map, measured in degrees clockwise from true north.

View File

@@ -3867,6 +3867,21 @@ static void *windowScreenContext = &windowScreenContext;
return *self.mbglMap.getBounds().maxZoom; 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 - (CGFloat)minimumPitch
{ {
return *self.mbglMap.getBounds().minPitch; return *self.mbglMap.getBounds().minPitch;

View File

@@ -60,6 +60,23 @@ void Transform::resize(const Size size) {
double scale{state.getScale()}; double scale{state.getScale()};
double x{state.getX()}; double x{state.getX()};
double y{state.getY()}; 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.constrain(scale, x, y);
state.setProperties(TransformStateProperties().withScale(scale).withX(x).withY(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 * smooth animation between old and new values. The map will retain the current
* values for any options not included in `options`. * 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()); Duration duration = animation.duration.value_or(Duration::zero());
if (state.getLatLngBounds() == LatLngBounds() && !isGestureInProgress() && duration != Duration::zero()) { if (state.getLatLngBounds() == LatLngBounds() && !isGestureInProgress() && duration != Duration::zero()) {
// reuse flyTo, without exaggerated animation, to achieve constant ground speed. // reuse flyTo, without exaggerated animation, to achieve constant ground speed.
return flyTo(camera, animation, true); 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()); const EdgeInsets& padding = camera.padding.value_or(state.getEdgeInsets());
LatLng startLatLng = getLatLng(LatLng::Unwrapped); LatLng startLatLng = getLatLng(LatLng::Unwrapped);
const LatLng& unwrappedLatLng = camera.center.value_or(startLatLng); const LatLng& unwrappedLatLng = camera.center.value_or(startLatLng);
const LatLng& latLng = state.getLatLngBounds() != LatLngBounds() ? unwrappedLatLng : unwrappedLatLng.wrapped(); 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 bearing = camera.bearing ? util::deg2rad(-*camera.bearing) : getBearing();
double pitch = camera.pitch ? util::deg2rad(*camera.pitch) : getPitch(); 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 Where applicable, local variable documentation begins with the associated
variable or function in van Wijk (2003). */ 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 EdgeInsets& padding = camera.padding.value_or(state.getEdgeInsets());
const LatLng& latLng = camera.center.value_or(getLatLng(LatLng::Unwrapped)).wrapped(); 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 bearing = camera.bearing ? util::deg2rad(-*camera.bearing) : getBearing();
double pitch = camera.pitch ? util::deg2rad(*camera.pitch) : getPitch(); double pitch = camera.pitch ? util::deg2rad(*camera.pitch) : getPitch();

View File

@@ -748,8 +748,27 @@ bool TransformState::rotatedNorth() const {
return (orientation == NO::Leftwards || orientation == NO::Rightwards); 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 { void TransformState::constrain(double& scale_, double& x_, double& y_) const {
if (constrainMode == ConstrainMode::None) { if (constrainMode == ConstrainMode::None || constrainMode == ConstrainMode::Screen) {
return; 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<double> 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 { ScreenCoordinate TransformState::getCenterOffset() const {
return {0.5 * (edgeInsets.left() - edgeInsets.right()), 0.5 * (edgeInsets.top() - edgeInsets.bottom())}; return {0.5 * (edgeInsets.left() - edgeInsets.right()), 0.5 * (edgeInsets.top() - edgeInsets.bottom())};
} }

View File

@@ -216,6 +216,9 @@ public:
void setLatLngZoom(const LatLng& latLng, double zoom); void setLatLngZoom(const LatLng& latLng, double zoom);
void constrain(double& scale, double& x, double& y) const; 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& getProjectionMatrix() const;
const mat4& getInvProjectionMatrix() const; const mat4& getInvProjectionMatrix() const;

View File

@@ -611,6 +611,26 @@ TEST(Transform, DefaultTransform) {
point = state.latLngToScreenCoordinate(nullIsland); point = state.latLngToScreenCoordinate(nullIsland);
ASSERT_DOUBLE_EQ(point.x, center.x); ASSERT_DOUBLE_EQ(point.x, center.x);
ASSERT_DOUBLE_EQ(point.y, center.y); 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) { TEST(Transform, LatLngBounds) {
@@ -803,6 +823,60 @@ TEST(Transform, LatLngBounds) {
ASSERT_DOUBLE_EQ(transform.getLatLng().longitude(), 120.0); 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) { TEST(Transform, InvalidPitch) {
Transform transform; Transform transform;
transform.resize({1, 1}); transform.resize({1, 1});