diff --git a/lib/src/map/camera/camera_constraint.dart b/lib/src/map/camera/camera_constraint.dart index 423452872..eb7b37d67 100644 --- a/lib/src/map/camera/camera_constraint.dart +++ b/lib/src/map/camera/camera_constraint.dart @@ -34,6 +34,10 @@ abstract class CameraConstraint { /// /// To instead constrain the center coordinate of the camera to these bounds, /// use [CameraConstraint.containCenter]. + /// + /// The edges are those of the map at pitch 0, at every pitch, as in + /// MapLibre GL JS. When the map is pitched, ground beyond [bounds] can be + /// visible toward the top of the map. const factory CameraConstraint.contain({ required LatLngBounds bounds, }) = ContainCamera._; @@ -42,6 +46,10 @@ abstract class CameraConstraint { /// /// Defaults to 90 & -90, to prevent the background color from appearing /// at the 'top' and 'bottom' of the typical map. + /// + /// The edges are those of the map at pitch 0, at every pitch, as in + /// MapLibre GL JS. When the map is pitched, ground beyond [a] and [b] can be + /// visible toward the top of the map. const factory CameraConstraint.containLatitude([ double a, double b, @@ -123,7 +131,11 @@ class ContainCamera extends CameraConstraint { final nePixel = camera.projectAtZoom(bounds.northEast, testZoom); final swPixel = camera.projectAtZoom(bounds.southWest, testZoom); - final halfSize = camera.size / 2; + // Not [MapCamera.size], which is the visible ground's bounding box when + // pitched: the constraint ignores pitch, as MapLibre GL JS's does. + final halfSize = + MapCamera.calculateRotatedSize(camera.rotation, camera.nonRotatedSize) / + 2; // Find the limits for the map center which would keep the camera within the // [latLngBounds]. @@ -186,7 +198,11 @@ class ContainCameraLatitude extends CameraConstraint { final Offset aPixel = camera.projectAtZoom(LatLng(a, 0), testZoom); final Offset bPixel = camera.projectAtZoom(LatLng(b, 0), testZoom); - final Size halfSize = camera.size / 2; + // Not [MapCamera.size], which is the visible ground's bounding box when + // pitched: the constraint ignores pitch, as MapLibre GL JS's does. + final Size halfSize = + MapCamera.calculateRotatedSize(camera.rotation, camera.nonRotatedSize) / + 2; // Find the limits for the map center which would keep the camera within the // [north] and [south] bounds. diff --git a/lib/src/map/camera/camera_fit.dart b/lib/src/map/camera/camera_fit.dart index 4fcb84aaf..0eb5fafb9 100644 --- a/lib/src/map/camera/camera_fit.dart +++ b/lib/src/map/camera/camera_fit.dart @@ -102,6 +102,28 @@ class FitBounds extends CameraFit { /// Returns a new [MapCamera] which fits this classes configuration. @override MapCamera fit(MapCamera camera) { + if (camera.pitch == 0) return _fitUnpitched(camera); + return _fitPitched( + camera, + [ + bounds.northWest, + bounds.northEast, + bounds.southEast, + bounds.southWest, + ], + padding, + start: _fitUnpitched(camera.withPitch(0)).withPitch(camera.pitch), + limitZoom: (zoom) => _limitZoom( + camera, + zoom, + minZoom: minZoom, + maxZoom: maxZoom, + forceIntegerZoomLevel: forceIntegerZoomLevel, + ), + ); + } + + MapCamera _fitUnpitched(MapCamera camera) { final paddingTL = Offset(padding.left, padding.top); final paddingBR = Offset(padding.right, padding.bottom); @@ -217,7 +239,49 @@ class FitInsideBounds extends CameraFit { }); @override - MapCamera fit(MapCamera camera) { + MapCamera fit(MapCamera camera) => + camera.pitch == 0 ? _fitUnpitched(camera) : _fitPitched(camera); + + /// The ground under the padded viewport, as offsets from the center in + /// projected pixels, has the same shape at every zoom, so the zoom follows + /// from the ratio of its bounding box to the projected bounds. + MapCamera _fitPitched(MapCamera camera) { + final viewport = padding.deflateRect(Offset.zero & camera.nonRotatedSize); + final ground = [ + viewport.topLeft, + viewport.topRight, + viewport.bottomRight, + viewport.bottomLeft, + ].map(camera.groundOffsetFromCenter); + if (ground.contains(null)) return camera; + final visible = RectExtension.containing(ground.nonNulls.toList()); + + final projectedBounds = Rect.fromPoints( + camera.projectAtZoom(bounds.southEast), + camera.projectAtZoom(bounds.northWest), + ); + final scale = math.max( + visible.width / projectedBounds.width, + visible.height / projectedBounds.height, + ); + var newZoom = camera.zoom + math.log(scale) / math.ln2; + if (forceIntegerZoomLevel) newZoom = newZoom.ceilToDouble(); + newZoom = newZoom.clamp( + math.max(camera.minZoom ?? 0, minZoom), + math.min(camera.maxZoom ?? double.infinity, maxZoom ?? double.infinity), + ); + + final boundsCenter = Rect.fromPoints( + camera.projectAtZoom(bounds.southEast, newZoom), + camera.projectAtZoom(bounds.northWest, newZoom), + ).center; + return camera.withPosition( + center: camera.unprojectAtZoom(boundsCenter - visible.center, newZoom), + zoom: newZoom, + ); + } + + MapCamera _fitUnpitched(MapCamera camera) { final paddingTL = Offset(padding.left, padding.top); final paddingBR = Offset(padding.right, padding.bottom); final paddingTotalXY = paddingTL + paddingBR; @@ -427,6 +491,23 @@ class FitCoordinates extends CameraFit { /// Returns a new [MapCamera] which fits this classes configuration. @override MapCamera fit(MapCamera camera) { + if (camera.pitch == 0) return _fitUnpitched(camera); + return _fitPitched( + camera, + coordinates, + padding, + start: _fitUnpitched(camera.withPitch(0)).withPitch(camera.pitch), + limitZoom: (zoom) => _limitZoom( + camera, + zoom, + minZoom: minZoom, + maxZoom: maxZoom, + forceIntegerZoomLevel: forceIntegerZoomLevel, + ), + ); + } + + MapCamera _fitUnpitched(MapCamera camera) { final paddingTL = Offset(padding.left, padding.top); final paddingBR = Offset(padding.right, padding.bottom); @@ -496,3 +577,92 @@ class FitCoordinates extends CameraFit { return newZoom.clamp(min, max); } } + +/// The zoom limits of a fit, applied to [zoom]. +double _limitZoom( + MapCamera camera, + double zoom, { + required double minZoom, + required double? maxZoom, + required bool forceIntegerZoomLevel, +}) { + final limited = forceIntegerZoomLevel ? zoom.floorToDouble() : zoom; + return limited.clamp( + math.max(camera.minZoom ?? 0, minZoom), + math.min(camera.maxZoom ?? double.infinity, maxZoom ?? double.infinity), + ); +} + +/// The camera, with the rotation and pitch of [start], at which [points] lie +/// inside the viewport reduced by [padding], at the largest zoom that +/// [limitZoom] allows. +/// +/// Starts from [start] and repeats one step until it converges: move the +/// center so that the middle of the points' screen extent is at the middle of +/// the padded viewport, and change the zoom by the ratio of the two extents. +MapCamera _fitPitched( + MapCamera camera, + List points, + EdgeInsets padding, { + required MapCamera start, + required double Function(double zoom) limitZoom, +}) { + final target = padding.deflateRect(Offset.zero & camera.nonRotatedSize); + if (target.width <= 0 || target.height <= 0 || points.isEmpty) return start; + + var fitted = start; + var zoomChange = double.infinity; + for (var i = 0; i < 20 && zoomChange.abs() >= 1e-4; i++) { + final extent = _screenExtent(fitted, points); + if (extent == null) { + zoomChange = -1; + } else { + fitted = _centered(fitted, extent.center, target.center); + final scale = math.min( + target.width / extent.width, + target.height / extent.height, + ); + zoomChange = scale.isFinite ? math.log(scale) / math.ln2 : 0; + } + fitted = fitted.withPosition(zoom: fitted.zoom + zoomChange); + } + + fitted = fitted.withPosition(zoom: limitZoom(fitted.zoom)); + for (var i = 0; i < 20; i++) { + final extent = _screenExtent(fitted, points); + if (extent == null || (extent.center - target.center).distance < 0.1) { + break; + } + fitted = _centered(fitted, extent.center, target.center); + } + return fitted; +} + +/// The bounding rectangle of the screen positions of [points], or null if a +/// point is behind the camera. +Rect? _screenExtent(MapCamera camera, List points) { + final transform = camera.pixelToScreenTransform; + final positions = []; + for (final point in points) { + final projected = camera.projectAtZoom(point); + final w = transform.entry(3, 0) * projected.dx + + transform.entry(3, 1) * projected.dy + + transform.entry(3, 3); + if (w <= 0) return null; + positions.add(MatrixUtils.transformPoint(transform, projected)); + } + return RectExtension.containing(positions); +} + +/// [camera] with its center moved so that the ground under the screen +/// position [from] is at [to]. +MapCamera _centered(MapCamera camera, Offset from, Offset to) { + final fromGround = camera.groundOffsetFromCenter(from); + final toGround = camera.groundOffsetFromCenter(to); + if (fromGround == null || toGround == null) return camera; + return camera.withPosition( + center: camera.unprojectAtZoom( + camera.projectAtZoom(camera.center) + fromGround - toGround, + ), + ); +} diff --git a/lib/src/map/options/options.dart b/lib/src/map/options/options.dart index 8ed8d408c..bb22bfbfa 100644 --- a/lib/src/map/options/options.dart +++ b/lib/src/map/options/options.dart @@ -91,8 +91,10 @@ class MapOptions { /// * a layer that computes screen positions itself, instead of drawing at /// `projectAtZoom(coordinate) - pixelOrigin`, must use /// [MapCamera.latLngToScreenOffset] and [MapCamera.screenOffsetToLatLng] - /// * [CameraFit] and [CameraConstraint] compute as if the map were not - /// pitched + /// * the built-in [CameraFit]s fit at the current pitch and rotation + /// * [CameraConstraint.contain] and [CameraConstraint.containLatitude] + /// ignore pitch, so ground beyond their bounds can be visible toward the + /// top of the map /// * the tile layer loads up to 7.5 times as many tiles at 60° as when /// unpitched /// diff --git a/test/map/camera/pitched_fit_and_constraint_test.dart b/test/map/camera/pitched_fit_and_constraint_test.dart new file mode 100644 index 000000000..cef1ae0a4 --- /dev/null +++ b/test/map/camera/pitched_fit_and_constraint_test.dart @@ -0,0 +1,163 @@ +import 'dart:math' as math; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +const _center = LatLng(45.5, -122.6); + +MapCamera _camera({double pitch = 0, double rotation = 0, double zoom = 10}) => + MapCamera( + crs: const Epsg3857(), + center: _center, + zoom: zoom, + rotation: rotation, + pitch: pitch, + nonRotatedSize: const Size(400, 600), + ); + +// About 730 x 1040 projected px at zoom 10: larger than the rotated viewport, +// smaller than the ground visible at pitch 60. +final _bounds = LatLngBounds( + LatLng(_center.latitude - 0.5, _center.longitude - 0.5), + LatLng(_center.latitude + 0.5, _center.longitude + 0.5), +); + +/// The smallest distance from [positions] to the edges of [area], negative if +/// a position is outside it. +double _margin(Iterable positions, Rect area) => positions + .map((p) => [ + p.dx - area.left, + area.right - p.dx, + p.dy - area.top, + area.bottom - p.dy, + ].reduce(math.min)) + .reduce(math.min); + +Iterable _screen(MapCamera camera, List points) => + points.map(camera.latLngToScreenOffset); + +final _corners = [ + _bounds.northWest, + _bounds.northEast, + _bounds.southEast, + _bounds.southWest, +]; + +final _coordinates = [ + const LatLng(45.40, -122.75), + const LatLng(45.62, -122.70), + const LatLng(45.55, -122.45), + const LatLng(45.45, -122.50), + const LatLng(45.50, -122.60), +]; + +void main() { + group('contain constraints when pitched', () { + for (final (name, constraint) in [ + ('contain', CameraConstraint.contain(bounds: _bounds)), + ( + 'containLatitude', + CameraConstraint.containLatitude(_bounds.south, _bounds.north), + ), + ]) { + test('$name accepts a pitch change inside the bounds', () { + final camera = _camera(pitch: 60, rotation: 30); + final constrained = constraint.constrain(camera); + expect(constrained, isNotNull); + expect(constrained!.center, _center); + expect(constrained.pitch, 60); + }); + + test('$name constrains the center independently of the pitch', () { + final beyond = LatLng(_center.latitude + 2, _center.longitude + 2); + MapCamera constrained(double pitch) => constraint.constrain( + _camera(pitch: pitch, rotation: 30).withPosition(center: beyond), + )!; + expect(constrained(60).center, constrained(0).center); + expect(constrained(60).center, isNot(beyond)); + }); + } + }); + + group('camera fit when pitched', () { + const viewport = Rect.fromLTWH(0, 0, 400, 600); + + test('fit bounds', () { + final fitted = CameraFit.bounds(bounds: _bounds) + .fit(_camera(pitch: 60, rotation: 30)); + expect(fitted.pitch, 60); + expect(fitted.rotation, 30); + final margin = _margin(_screen(fitted, _corners), viewport); + expect(margin, greaterThan(-0.01)); + expect(margin, lessThan(1)); + }); + + test('fit coordinates with padding', () { + final fitted = CameraFit.coordinates( + coordinates: _coordinates, + padding: const EdgeInsets.all(20), + ).fit(_camera(pitch: 45)); + final margin = _margin(_screen(fitted, _coordinates), viewport); + expect(margin, greaterThan(20 - 0.01)); + expect(margin, lessThan(21)); + }); + + test('maximum zoom keeps the coordinates inside', () { + final unlimited = CameraFit.coordinates(coordinates: _coordinates) + .fit(_camera(pitch: 60, rotation: 30)); + final fitted = CameraFit.coordinates( + coordinates: _coordinates, + maxZoom: unlimited.zoom - 0.5, + ).fit(_camera(pitch: 60, rotation: 30)); + expect(fitted.zoom, closeTo(unlimited.zoom - 0.5, 1e-9)); + expect( + _margin(_screen(fitted, _coordinates), viewport), + greaterThan(-0.01), + ); + }); + + test('integer zoom keeps the bounds inside', () { + final fitted = CameraFit.bounds( + bounds: _bounds, + forceIntegerZoomLevel: true, + ).fit(_camera(pitch: 60, rotation: 30)); + expect(fitted.zoom, fitted.zoom.roundToDouble()); + expect(_margin(_screen(fitted, _corners), viewport), greaterThan(-0.01)); + }); + + test('fit inside bounds', () { + final fitted = CameraFit.insideBounds(bounds: _bounds) + .fit(_camera(pitch: 60, rotation: 30)); + final projectedBounds = Rect.fromPoints( + fitted.projectAtZoom(_bounds.southEast), + fitted.projectAtZoom(_bounds.northWest), + ); + final ground = [ + viewport.topLeft, + viewport.topRight, + viewport.bottomRight, + viewport.bottomLeft, + ].map((corner) => + fitted.projectAtZoom(fitted.screenOffsetToLatLng(corner))); + final margin = _margin(ground, projectedBounds); + expect(margin, greaterThan(-0.01)); + expect(margin, lessThan(1)); + }); + + test('fit a single coordinate', () { + const coordinate = LatLng(45.6, -122.5); + MapCamera fit(double pitch) => const CameraFit.coordinates( + coordinates: [coordinate], + maxZoom: 16, + ).fit(_camera(pitch: pitch, rotation: 30)); + final fitted = fit(60); + expect(fitted.zoom, fit(0).zoom); + expect( + (fitted.latLngToScreenOffset(coordinate) - viewport.center).distance, + lessThan(0.1), + ); + }); + }); +}