diff --git a/example/lib/pages/repeated_worlds.dart b/example/lib/pages/repeated_worlds.dart index ae1236cbc..ffd6fed90 100644 --- a/example/lib/pages/repeated_worlds.dart +++ b/example/lib/pages/repeated_worlds.dart @@ -49,6 +49,7 @@ class _RepeatedWorldsPageState extends State { initialCenter: const LatLng(0, 0), initialZoom: 2, onTap: (_, p) => setState(() => _customMarkers.add(_buildPin(p))), + cameraConstraint: const CameraConstraint.containLatitude(), ), children: [ openStreetMapTileLayer, diff --git a/lib/src/map/camera/camera_constraint.dart b/lib/src/map/camera/camera_constraint.dart index 06a4f3cf8..423452872 100644 --- a/lib/src/map/camera/camera_constraint.dart +++ b/lib/src/map/camera/camera_constraint.dart @@ -38,6 +38,15 @@ abstract class CameraConstraint { required LatLngBounds bounds, }) = ContainCamera._; + /// Constrains the edges of the camera to within latitudes [a] and [b] + /// + /// Defaults to 90 & -90, to prevent the background color from appearing + /// at the 'top' and 'bottom' of the typical map. + const factory CameraConstraint.containLatitude([ + double a, + double b, + ]) = ContainCameraLatitude._; + /// Create a new constrained camera based off the current [camera] /// /// May return `null` if no appropriate camera could be generated by movement, @@ -95,7 +104,8 @@ class ContainCameraCenter extends CameraConstraint { /// Constrains the edges of the camera to within [bounds] /// /// To instead constrain the center coordinate of the camera to these bounds, -/// use [ContainCameraCenter]. +/// use [ContainCameraCenter]. If only latitude needs to be constrained, +/// use [ContainCameraLatitude]. /// /// See [CameraConstraint] for more information. @immutable @@ -147,3 +157,68 @@ class ContainCamera extends CameraConstraint { @override int get hashCode => bounds.hashCode; } + +/// Constrains the edges of the camera to within latitudes [a] and [b] +/// +/// See [CameraConstraint] for more information. +@immutable +class ContainCameraLatitude extends CameraConstraint { + const ContainCameraLatitude._([ + this.a = 90, + this.b = -90, + ]); + + /// One edge latitude + /// + /// [a] & [b] are not ordered. + final double a; + + /// Other edge latitude + /// + /// [a] & [b] are not ordered. + final double b; + + @override + MapCamera? constrain(MapCamera camera) { + final double testZoom = camera.zoom; + final LatLng testCenter = camera.center; + + final Offset aPixel = camera.projectAtZoom(LatLng(a, 0), testZoom); + final Offset bPixel = camera.projectAtZoom(LatLng(b, 0), testZoom); + + final Size halfSize = camera.size / 2; + + // Find the limits for the map center which would keep the camera within the + // [north] and [south] bounds. + final double topOkCenter = math.min(aPixel.dy, bPixel.dy) + halfSize.height; + final double botOkCenter = math.max(aPixel.dy, bPixel.dy) - halfSize.height; + + // Stop if we are zoomed out so far that the camera cannot be translated to + // stay within the [north] and [south] bounds. + if (topOkCenter > botOkCenter) { + return null; + } + + final Offset centerPix = camera.projectAtZoom(testCenter, testZoom); + final newCenterPix = Offset( + centerPix.dx, + centerPix.dy.clamp(topOkCenter, botOkCenter), + ); + + if (newCenterPix == centerPix) { + return camera; + } + + return camera.withPosition( + center: camera.unprojectAtZoom(newCenterPix, testZoom), + ); + } + + @override + bool operator ==(Object other) { + return other is ContainCameraLatitude && other.a == a && other.b == b; + } + + @override + int get hashCode => a.hashCode ^ b.hashCode; +} diff --git a/test/map/camera/camera_constraint_test.dart b/test/map/camera/camera_constraint_test.dart index 17c3e228c..73d9e60a7 100644 --- a/test/map/camera/camera_constraint_test.dart +++ b/test/map/camera/camera_constraint_test.dart @@ -30,5 +30,77 @@ void main() { expect(clamped.center.longitude, closeTo(-55.703, 0.001)); }); }); + + group('containVertically', () { + test('western longitude', () { + const mapConstraint = CameraConstraint.containLatitude(); + + final camera = MapCamera( + crs: const Epsg3857(), + center: const LatLng(0, -179.9), + zoom: 1, + rotation: 0, + nonRotatedSize: const Size(200, 300), + ); + + final clamped = mapConstraint.constrain(camera)!; + + expect(clamped.zoom, 1); + expect(clamped.center.latitude, closeTo(0, 0.001)); + expect(clamped.center.longitude, closeTo(-179.9, 0.001)); + }); + }); + + test('top right corner', () { + const mapConstraint = CameraConstraint.containLatitude(); + + final camera = MapCamera( + crs: const Epsg3857(), + center: const LatLng(-90, 179), + zoom: 1, + rotation: 0, + nonRotatedSize: const Size(200, 300), + ); + + final clamped = mapConstraint.constrain(camera)!; + + expect(clamped.zoom, 1); + expect(clamped.center.latitude, closeTo(-59.534, 0.001)); + expect(clamped.center.longitude, closeTo(179, 0.001)); + }); + + test('northern hemisphere', () { + const mapConstraint = CameraConstraint.containLatitude(0, 90); + + final camera = MapCamera( + crs: const Epsg3857(), + center: const LatLng(-10, 179), + zoom: 2, + rotation: 0, + nonRotatedSize: const Size(200, 300), + ); + + final clamped = mapConstraint.constrain(camera)!; + + expect(clamped.zoom, 2); + expect(clamped.center.latitude, closeTo(46.558, 0.001)); + expect(clamped.center.longitude, closeTo(179, 0.001)); + }); + + test('can not translate camera within bounds', () { + const mapConstraint = CameraConstraint.containLatitude(0, 90); + + final camera = MapCamera( + crs: const Epsg3857(), + center: const LatLng(60, 179), + zoom: 1, + rotation: 0, + nonRotatedSize: const Size(200, 300), + ); + + final clamped = mapConstraint.constrain(camera); + + expect(clamped, isNull); + }); }); }