Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions example/lib/pages/repeated_worlds.dart
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ class _RepeatedWorldsPageState extends State<RepeatedWorldsPage> {
initialCenter: const LatLng(0, 0),
initialZoom: 2,
onTap: (_, p) => setState(() => _customMarkers.add(_buildPin(p))),
cameraConstraint: const CameraConstraint.containLatitude(),
),
children: [
openStreetMapTileLayer,
Expand Down
77 changes: 76 additions & 1 deletion lib/src/map/camera/camera_constraint.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
72 changes: 72 additions & 0 deletions test/map/camera/camera_constraint_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
}