Skip to content

Commit

Permalink
Allow constraining camera to maximum bounds (#2475)
Browse files Browse the repository at this point in the history
  • Loading branch information
christian-boks authored Jan 9, 2025
1 parent f582327 commit 62b92e2
Show file tree
Hide file tree
Showing 10 changed files with 314 additions and 7 deletions.
2 changes: 2 additions & 0 deletions include/mbgl/map/bound_options.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<LatLngBounds> bounds;

/// Maximum zoom level allowed.
Expand Down
5 changes: 3 additions & 2 deletions include/mbgl/map/mode.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions platform/ios/app-swift/Sources/MapLibreNavigationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ struct MapLibreNavigationView: View {
NavigationLink("BlockingGesturesExample") {
BlockingGesturesExample()
}
NavigationLink("MaximumScreenBoundsExample") {
MaximumScreenBoundsExample()
}
NavigationLink("LineStyleLayerExample") {
LineStyleLayerExampleUIViewControllerRepresentable()
}
Expand Down
28 changes: 28 additions & 0 deletions platform/ios/app-swift/Sources/MaximumScreenBoundsExample.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
7 changes: 7 additions & 0 deletions platform/ios/src/MLNMapView.h
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions platform/ios/src/MLNMapView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
38 changes: 34 additions & 4 deletions src/mbgl/map/transform.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand All @@ -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();

Expand Down Expand Up @@ -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();

Expand Down
146 changes: 145 additions & 1 deletion src/mbgl/map/transform_state.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -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 {
return {0.5 * (edgeInsets.left() - edgeInsets.right()), 0.5 * (edgeInsets.top() - edgeInsets.bottom())};
}
Expand Down
3 changes: 3 additions & 0 deletions src/mbgl/map/transform_state.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
Loading

0 comments on commit 62b92e2

Please sign in to comment.