From 52ce1e48230bbedb83f954eb239194bc994bffdf Mon Sep 17 00:00:00 2001 From: David Green Date: Sun, 25 Oct 2026 11:12:23 -0700 Subject: [PATCH] fit and constrain the camera on a pitched map Camera fits now account for pitch. The contain constraints, which 04c3e14 broke on pitched maps, ignore pitch again, as in MapLibre GL JS, so they no longer refuse pitch changes. At pitch 0, fits and constraints behave as before. --- lib/src/map/camera/camera_constraint.dart | 20 +- lib/src/map/camera/camera_fit.dart | 172 +++++++++++++++++- lib/src/map/options/options.dart | 6 +- .../pitched_fit_and_constraint_test.dart | 163 +++++++++++++++++ 4 files changed, 356 insertions(+), 5 deletions(-) create mode 100644 test/map/camera/pitched_fit_and_constraint_test.dart 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), + ); + }); + }); +}