From 88fd29101f545b08f1d579ce18e32b73e4e6fae4 Mon Sep 17 00:00:00 2001 From: David Green Date: Mon, 26 Oct 2026 08:44:34 -0700 Subject: [PATCH] =?UTF-8?q?raise=20the=20pitch=20limit=20to=2085=C2=B0=20w?= =?UTF-8?q?ith=20a=20far=20limit=20of=20the=20visible=20ground?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MapOptions.pitchLimit is now 85. Above a pitch of about 71°, the visible ground ends at a far limit, where the view ray is 89.25° from straight down, as in MapLibre Native. Above it, ground layers draw nothing and the background color shows, markers and polygon labels are not drawn, and gestures act at the far limit directly below the pointer. The visible bounds, tile cover and camera fits use the ground below the far limit. Up to a pitch of about 71° nothing changes. --- example/lib/pages/pitch.dart | 5 +- lib/src/gestures/map_interactive_viewer.dart | 43 +-- lib/src/layer/marker_layer/marker_layer.dart | 7 + .../polygon_layer/label/label_painter.dart | 5 +- .../shared/mobile_layer_transformer.dart | 25 +- .../tile_layer/tile_cover_calculator.dart | 14 +- lib/src/map/camera/camera.dart | 117 ++++--- lib/src/map/camera/camera_fit.dart | 93 ++++-- lib/src/map/options/options.dart | 12 +- test/gestures/pitched_gestures_test.dart | 36 +- test/layer/far_limit_layers_test.dart | 309 ++++++++++++++++++ .../tile_cover_calculator_test.dart | 59 +++- test/map/camera/far_limit_test.dart | 229 +++++++++++++ .../pitched_fit_and_constraint_test.dart | 50 +++ test/map/camera/pitched_focal_point_test.dart | 12 +- test/map/pitch_controller_offsets_test.dart | 34 +- test/map/pitch_options_test.dart | 10 +- 17 files changed, 937 insertions(+), 123 deletions(-) create mode 100644 test/layer/far_limit_layers_test.dart create mode 100644 test/map/camera/far_limit_test.dart diff --git a/example/lib/pages/pitch.dart b/example/lib/pages/pitch.dart index 894cc3eb2..4aa770e8d 100644 --- a/example/lib/pages/pitch.dart +++ b/example/lib/pages/pitch.dart @@ -34,7 +34,8 @@ class _PitchPageState extends State { const Text( 'Markers and labels face the viewer; tiles, polygons and ' 'polylines lie on the ground. Far ground uses coarser ' - 'tiles.', + 'tiles. Above a pitch of about 71°, the ground ends at a ' + 'far limit, and the background color shows above it.', textAlign: TextAlign.center, style: TextStyle(fontSize: 12), ), @@ -86,6 +87,7 @@ class _PitchPageState extends State { initialZoom: 14, initialPitch: _pitch, maxPitch: MapOptions.pitchLimit, + backgroundColor: const Color(0xFFCFE3F3), onMapEvent: (event) { if (event.camera.pitch != _pitch || event.camera.rotation != _rotation) { @@ -112,6 +114,7 @@ class _PitchPageState extends State { borderColor: Colors.purple, borderStrokeWidth: 3, label: 'Polygon', + rotateLabel: true, labelStyle: const TextStyle( color: Colors.black, fontWeight: FontWeight.bold, diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index 98d492dd8..f762acb5e 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -774,13 +774,10 @@ class MapInteractiveViewerState extends State widget.controller.moveStarted(MapEventSource.dragStart); } - final groundShift = _groundShift( - _lastFocalLocal, - details.localFocalPoint, + widget.controller.dragUpdated( + MapEventSource.onDrag, + _groundShift(_lastFocalLocal, details.localFocalPoint), ); - if (groundShift == null) return; - - widget.controller.dragUpdated(MapEventSource.onDrag, groundShift); } } @@ -889,8 +886,7 @@ class MapInteractiveViewerState extends State final oldFocalPt = _camera.projectAtZoom(_focalStartLatLng, zoomAfterPinchZoom); final zoomDifference = oldFocalPt - newFocalPt; - final moveDifference = - _groundShift(_focalStartLocal, _lastFocalLocal) ?? Offset.zero; + final moveDifference = _groundShift(_focalStartLocal, _lastFocalLocal); final newCenterPt = oldCenterPt + zoomDifference + moveDifference; return _camera.unprojectAtZoom(newCenterPt, zoomAfterPinchZoom); @@ -1011,8 +1007,7 @@ class MapInteractiveViewerState extends State if (_camera.pitch != 0) { _flingCenterStart = _camera.center; _flingGroundStep = - _groundShift(_lastFocalLocal + direction, _lastFocalLocal) ?? - _rotateOffset(direction); + _groundShift(_lastFocalLocal + direction, _lastFocalLocal); _flingDirection = direction; _flingOffset = flingOffset; } @@ -1622,26 +1617,10 @@ class MapInteractiveViewerState extends State } /// The offset, in projected pixels, by which to move the center so that the - /// ground under the screen position [from] moves under [to], or null if - /// either is above the horizon. - Offset? _groundShift(Offset from, Offset to) { - final fromGround = _camera.groundOffsetFromCenter(from); - final toGround = _camera.groundOffsetFromCenter(to); - if (fromGround == null || toGround == null) return null; - return fromGround - toGround; - } - - Offset _rotateOffset(Offset offset) { - final radians = _camera.rotationRad; - if (radians != 0.0) { - final cos = math.cos(radians); - final sin = math.sin(radians); - final nx = (cos * offset.dx) + (sin * offset.dy); - final ny = (cos * offset.dy) - (sin * offset.dx); - - return Offset(nx, ny); - } - - return offset; - } + /// ground under the screen position [from] moves under [to]. A position + /// above the far limit of the visible ground acts as the position on the far + /// limit directly below it, so that the ground moves by a bounded distance. + Offset _groundShift(Offset from, Offset to) => + _camera.clampedGroundOffsetFromCenter(from) - + _camera.clampedGroundOffsetFromCenter(to); } diff --git a/lib/src/layer/marker_layer/marker_layer.dart b/lib/src/layer/marker_layer/marker_layer.dart index 819ff8d92..b1a79cc80 100644 --- a/lib/src/layer/marker_layer/marker_layer.dart +++ b/lib/src/layer/marker_layer/marker_layer.dart @@ -238,6 +238,13 @@ class _MarkerLayerState extends State { final screen = MatrixUtils.transformPoint(pixelToScreen, shifted); + // Beyond the far limit, which also ends the loop over worlds + // when the horizon is in view: world copies far away converge + // to it, inside the viewport + if (camera.pitch != 0 && screen.dy < camera.farLimit) { + return null; + } + // Without rotation, the marker's corners are within the larger // of its sizes from its point if (camera.pitch != 0 && diff --git a/lib/src/layer/polygon_layer/label/label_painter.dart b/lib/src/layer/polygon_layer/label/label_painter.dart index bd7206941..7abe1ff52 100644 --- a/lib/src/layer/polygon_layer/label/label_painter.dart +++ b/lib/src/layer/polygon_layer/label/label_painter.dart @@ -42,12 +42,15 @@ class _PolygonLabelPainter extends CustomPainter 0) { return WorldWorkControl.invisible; } + final screen = MatrixUtils.transformPoint(canvasToScreen, point); + // Beyond the far limit; see the marker layer + if (screen.dy < camera.farLimit) return WorldWorkControl.invisible; // In screen space, a label that turns with the map is rotated and an // upright label is not: the reverse of the unpitched painter. final painter = _buildLabelTextPainter( mapSize: size, - placementPoint: MatrixUtils.transformPoint(canvasToScreen, point), + placementPoint: screen, bounds: bounds, textPainter: textPainter, rotationRad: -camera.rotationRad, diff --git a/lib/src/layer/shared/mobile_layer_transformer.dart b/lib/src/layer/shared/mobile_layer_transformer.dart index dcdb44627..3692fbeeb 100644 --- a/lib/src/layer/shared/mobile_layer_transformer.dart +++ b/lib/src/layer/shared/mobile_layer_transformer.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/src/map/camera/camera.dart'; @@ -29,6 +30,12 @@ class MobileLayerTransformer extends StatelessWidget { // The child's top-left corner is at [MapCamera.pixelOrigin], which is not // centered on the map's center when pitched. final origin = camera.pixelOrigin; + // Clipped to the visible ground, because a point behind the camera would + // be drawn mirrored, and nothing is drawn beyond the far limit. + final fromOrigin = camera.projectAtZoom(camera.center) - origin; + final ground = [ + for (final corner in camera.visibleGroundFromCenter) fromOrigin + corner, + ]; return OverflowBox( alignment: Alignment.topLeft, minWidth: camera.size.width, @@ -38,8 +45,24 @@ class MobileLayerTransformer extends StatelessWidget { child: Transform( transform: camera.pixelToScreenTransform ..multiply(Matrix4.translationValues(origin.dx, origin.dy, 0)), - child: child, + child: ClipPath( + clipper: _PolygonClipper(ground), + child: child, + ), ), ); } } + +class _PolygonClipper extends CustomClipper { + final List points; + + const _PolygonClipper(this.points); + + @override + Path getClip(Size size) => Path()..addPolygon(points, true); + + @override + bool shouldReclip(_PolygonClipper oldClipper) => + !listEquals(oldClipper.points, points); +} diff --git a/lib/src/layer/tile_layer/tile_cover_calculator.dart b/lib/src/layer/tile_layer/tile_cover_calculator.dart index cc457e999..395a61146 100644 --- a/lib/src/layer/tile_layer/tile_cover_calculator.dart +++ b/lib/src/layer/tile_layer/tile_cover_calculator.dart @@ -55,8 +55,8 @@ class _Cover { final int minNativeZoom; final double viewingZoom; - /// The ground under the corners of the viewport, from the center, in pixels - /// at [viewingZoom]. + /// The ground under the corners of [MapCamera.groundViewport], from the + /// center, in pixels at [viewingZoom]. final List _corners; final Offset _pixelCenter; final int _tileZoom; @@ -74,15 +74,7 @@ class _Cover { camera.projectAtZoom(center, tileZoom.toDouble()).floor(), _corners = camera.nonRotatedSize.isEmpty ? const [Offset.zero] - : [ - Offset.zero, - Offset(camera.nonRotatedSize.width, 0), - Offset( - camera.nonRotatedSize.width, camera.nonRotatedSize.height), - Offset(0, camera.nonRotatedSize.height), - ] - .map((corner) => camera.groundOffsetFromCenter(corner)!) - .toList(); + : camera.visibleGroundFromCenter; int _zoomAtScale(double scale) => (viewingZoom + math.log(scale) / math.ln2) .round() diff --git a/lib/src/map/camera/camera.dart b/lib/src/map/camera/camera.dart index e45ef92a1..c615bb821 100644 --- a/lib/src/map/camera/camera.dart +++ b/lib/src/map/camera/camera.dart @@ -428,16 +428,19 @@ class MapCamera { /// Calculate the [LatLng] coordinates for a [offset]. /// - /// When the map is pitched and [offset] is above the horizon, there is no - /// coordinate at [offset]; this returns a distant coordinate in its - /// direction instead. [maybeScreenOffsetToLatLng] returns null there. + /// When the map is pitched and [offset] is above the far limit of the + /// visible ground, there is no visible ground at [offset]; this returns the + /// coordinate on the far limit directly below [offset] instead. + /// [maybeScreenOffsetToLatLng] returns null there. LatLng screenOffsetToLatLng(Offset offset) => - unprojectAtZoom(_screenToPixel(offset, aboveHorizon: _distantGround)!); + unprojectAtZoom(_screenToPixel(offset, clampToFarLimit: true)!); /// Calculate the [LatLng] coordinates for a [offset], or null when the map - /// is pitched and [offset] is above the horizon. + /// is pitched and [offset] is above the far limit of the visible ground. + /// The far limit is where the view ray is 89.25 degrees from straight down, + /// just below the horizon. LatLng? maybeScreenOffsetToLatLng(Offset offset) { - final pixel = _screenToPixel(offset, aboveHorizon: null); + final pixel = _screenToPixel(offset, clampToFarLimit: false); return pixel == null ? null : unprojectAtZoom(pixel); } @@ -450,17 +453,7 @@ class MapCamera { /// [nonRotatedSize], [rotation] and [pitch], because [zoom] is the scale at /// [center]. Rect get _visibleGroundFromCenter => _footprintFromCenter ??= () { - final projectedCenter = projectAtZoom(center); - final corners = [ - Offset.zero, - Offset(nonRotatedSize.width, 0), - Offset(nonRotatedSize.width, nonRotatedSize.height), - Offset(0, nonRotatedSize.height), - ] - .map((corner) => - _screenToPixel(corner, aboveHorizon: _distantGround)! - - projectedCenter) - .toList(); + final corners = visibleGroundFromCenter; return Rect.fromLTRB( corners.map((c) => c.dx).reduce(math.min), corners.map((c) => c.dy).reduce(math.min), @@ -469,22 +462,62 @@ class MapCamera { ); }(); - /// The smallest divisor of [_screenToPixel] that [screenOffsetToLatLng] - /// uses above the horizon, where the divisor is not positive. - static const _distantGround = 1e-3; + /// The angle from straight down of the view ray at the far limit of the + /// visible ground, as in MapLibre Native. + static const _farLimitAngle = 89.25 * degrees2Radians; + + /// The y position in the map view, before rotation, of the far limit of the + /// visible ground: ground points whose view ray is [_farLimitAngle] from + /// straight down. It is above the map view, and may be negative infinity, + /// unless [pitch] is above about 70.8 degrees. + @internal + double get farLimit => pitch == 0 + ? double.negativeInfinity + : nonRotatedSize.height / 2 - + _cameraDistance * math.tan(_farLimitAngle - pitchRad); + + /// The part of the map view, before rotation, that shows ground: the map + /// view, with its top moved down to [farLimit] when [farLimit] is in it. + @internal + Rect get groundViewport => Rect.fromLTRB( + 0, + math.max(0, farLimit), + nonRotatedSize.width, + nonRotatedSize.height, + ); + + /// The ground under the corners of [groundViewport], clockwise from the + /// top-left, as offsets in projected pixels at [zoom] from [center]. + @internal + List get visibleGroundFromCenter { + final viewport = groundViewport; + return [ + viewport.topLeft, + viewport.topRight, + viewport.bottomRight, + viewport.bottomLeft, + ].map(clampedGroundOffsetFromCenter).toList(); + } /// The projected pixel at [zoom] of the ground point under the screen /// position [offset]. See [_groundFromCenter]. - Offset? _screenToPixel(Offset offset, {required double? aboveHorizon}) { - final fromCenter = _groundFromCenter(offset, aboveHorizon: aboveHorizon); + Offset? _screenToPixel(Offset offset, {required bool clampToFarLimit}) { + final fromCenter = + _groundFromCenter(offset, clampToFarLimit: clampToFarLimit); return fromCenter == null ? null : projectAtZoom(center) + fromCenter; } /// The offset, in projected pixels at [zoom], from [center] to the ground - /// point under the screen position [offset], or null above the horizon. + /// point under the screen position [offset], or null above [farLimit]. @internal Offset? groundOffsetFromCenter(Offset offset) => - _groundFromCenter(offset, aboveHorizon: null); + _groundFromCenter(offset, clampToFarLimit: false); + + /// As [groundOffsetFromCenter], but above [farLimit] uses the position on + /// [farLimit] directly below [offset]. + @internal + Offset clampedGroundOffsetFromCenter(Offset offset) => + _groundFromCenter(offset, clampToFarLimit: true)!; /// The size on screen of one projected pixel at the ground point [offset] /// from [center], where [offset] is in projected pixels at [zoom]. It is 1 at @@ -499,20 +532,23 @@ class MapCamera { /// The offset, in projected pixels at [zoom], from [center] to the ground /// point under the screen position [offset], found by casting the view ray - /// to the ground. Above the horizon, uses [aboveHorizon] as the divisor, or - /// returns null if it is null. + /// to the ground. Above [farLimit], uses the position on [farLimit] directly + /// below [offset] if [clampToFarLimit], or returns null otherwise. /// /// The result doesn't depend on [center] or [zoom], because [zoom] is the /// scale at [center]. - Offset? _groundFromCenter(Offset offset, {required double? aboveHorizon}) { - final fromCenter = offset - nonRotatedSize.center(Offset.zero); + Offset? _groundFromCenter(Offset offset, {required bool clampToFarLimit}) { + final farLimit = this.farLimit; + final belowFarLimit = offset.dy >= farLimit + ? offset + : clampToFarLimit + ? Offset(offset.dx, farLimit) + : null; + if (belowFarLimit == null) return null; + final fromCenter = belowFarLimit - nonRotatedSize.center(Offset.zero); final sinPitch = math.sin(pitchRad); - var divisor = + final divisor = math.cos(pitchRad) + fromCenter.dy * sinPitch / _cameraDistance; - if (divisor <= 0) { - if (aboveHorizon == null) return null; - divisor = aboveHorizon; - } final y = fromCenter.dy / divisor; final x = fromCenter.dx * (1 - y * sinPitch / _cameraDistance); return Offset(x, y).rotate(rotationRad); @@ -554,11 +590,12 @@ class MapCamera { /// zoom level. If [zoom] is not provided the current zoom level of the /// [MapCamera] gets used. /// - /// When the map is pitched and [offset] is above the horizon, this returns a - /// distant coordinate in its direction. + /// When the map is pitched and [offset] is above the far limit of the + /// visible ground, this returns the coordinate on the far limit directly + /// below [offset]. LatLng offsetToCrs(Offset offset, [double? zoom]) { final focalStartPt = projectAtZoom(center, zoom ?? this.zoom); - final point = _groundFromCenter(offset, aboveHorizon: _distantGround)!; + final point = _groundFromCenter(offset, clampToFarLimit: true)!; final newCenterPt = focalStartPt + point; final worldWidth = getWorldWidthAtZoom(zoom ?? this.zoom); @@ -577,11 +614,11 @@ class MapCamera { /// Calculate the center point which would keep the same point of the map /// visible at the given [cursorPos] with the zoom set to [zoom]. /// - /// When the map is pitched and [cursorPos] is above the horizon, this - /// returns [center]. + /// When the map is pitched and [cursorPos] is above the far limit of the + /// visible ground, this keeps the point on the far limit directly below + /// [cursorPos] instead. LatLng focusedZoomCenter(Offset cursorPos, double zoom) { - final offset = _groundFromCenter(cursorPos, aboveHorizon: null); - if (offset == null) return center; + final offset = clampedGroundOffsetFromCenter(cursorPos); // Match new center coordinate to mouse cursor position final scale = getZoomScale(zoom, this.zoom); final newOffset = offset * (1.0 - 1.0 / scale); diff --git a/lib/src/map/camera/camera_fit.dart b/lib/src/map/camera/camera_fit.dart index 0eb5fafb9..782d772c9 100644 --- a/lib/src/map/camera/camera_fit.dart +++ b/lib/src/map/camera/camera_fit.dart @@ -246,15 +246,16 @@ class FitInsideBounds extends CameraFit { /// 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 = [ + final viewport = padding + .deflateRect(Offset.zero & camera.nonRotatedSize) + .intersect(camera.groundViewport); + if (viewport.width <= 0 || viewport.height <= 0) return camera; + final visible = RectExtension.containing([ viewport.topLeft, viewport.topRight, viewport.bottomRight, viewport.bottomLeft, - ].map(camera.groundOffsetFromCenter); - if (ground.contains(null)) return camera; - final visible = RectExtension.containing(ground.nonNulls.toList()); + ].map(camera.clampedGroundOffsetFromCenter).toList()); final projectedBounds = Rect.fromPoints( camera.projectAtZoom(bounds.southEast), @@ -594,12 +595,13 @@ double _limitZoom( } /// 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. +/// inside the viewport reduced by [padding] and to the part below the far +/// limit of the visible ground, 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. +/// The screen extent of the points changes too unevenly with the zoom near the +/// far limit for a step by the ratio of the extents to converge, so this +/// searches for the largest zoom at which the points, centered in the area, +/// fit: in steps of 1 from [start], then by bisection. MapCamera _fitPitched( MapCamera camera, List points, @@ -607,24 +609,64 @@ MapCamera _fitPitched( required MapCamera start, required double Function(double zoom) limitZoom, }) { - final target = padding.deflateRect(Offset.zero & camera.nonRotatedSize); + final target = padding + .deflateRect(Offset.zero & camera.nonRotatedSize) + .intersect(camera.groundViewport); 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; + + /// [fitted] at [zoom], with the middle of the points' screen extent at the + /// middle of [target], or null if a point is behind the camera. + MapCamera? centeredAt(double zoom) { + var centered = fitted.withPosition(zoom: zoom); + for (var i = 0; i < 20; i++) { + final extent = _screenExtent(centered, points); + if (extent == null) return null; + if ((extent.center - target.center).distance < 0.1) break; + centered = _centered(centered, extent.center, target.center); + } + return centered; + } + + final tolerance = target.inflate(0.01); + bool fits(MapCamera? centered) { + if (centered == null) return false; + final extent = _screenExtent(centered, points); + return extent != null && tolerance.expandToInclude(extent) == tolerance; + } + + final startExtent = _screenExtent(start, points); + if (startExtent == null || !startExtent.isEmpty) { + var low = start.zoom; + var high = start.zoom; + var lowCamera = centeredAt(low); + if (fits(lowCamera)) { + for (var i = 0; i < 32; i++) { + high = low + 1; + final higher = centeredAt(high); + if (!fits(higher)) break; + low = high; + lowCamera = higher; + } } 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; + for (var i = 0; i < 32 && !fits(lowCamera); i++) { + high = low; + low -= 1; + lowCamera = centeredAt(low); + } + } + while (high - low > 1e-3 && lowCamera != null) { + final middle = (low + high) / 2; + final middleCamera = centeredAt(middle); + if (fits(middleCamera)) { + low = middle; + lowCamera = middleCamera; + } else { + high = middle; + } } - fitted = fitted.withPosition(zoom: fitted.zoom + zoomChange); + if (lowCamera != null) fitted = lowCamera; } fitted = fitted.withPosition(zoom: limitZoom(fitted.zoom)); @@ -657,9 +699,8 @@ Rect? _screenExtent(MapCamera camera, List points) { /// [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; + final fromGround = camera.clampedGroundOffsetFromCenter(from); + final toGround = camera.clampedGroundOffsetFromCenter(to); 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 48b9fd81a..550766b2c 100644 --- a/lib/src/map/options/options.dart +++ b/lib/src/map/options/options.dart @@ -98,6 +98,12 @@ class MapOptions { /// * the tile layer loads coarser tiles for far ground, and near ground at /// the same zoom as when unpitched, so near tiles can look blurry; use /// vector tiles where the near ground must be sharp + /// * above a pitch of about 70.8 degrees, the visible ground ends at a far + /// limit, a horizontal line where the view ray is 89.25 degrees from + /// straight down, as in MapLibre Native; above it, layers in a + /// [MobileLayerTransformer] draw nothing and [backgroundColor] shows, + /// [MapCamera.maybeScreenOffsetToLatLng] returns null, and gestures act + /// at the far limit directly below the pointer /// /// While [maxPitch] is above 0 and the interactive flags contain /// [InteractiveFlag.pitch], the user can pitch the map with a two-finger @@ -105,9 +111,9 @@ class MapOptions { /// cursor/keyboard rotation (see [CursorKeyboardRotationOptions]). final double maxPitch; - /// The largest [maxPitch] that takes effect. Above it, the visible ground - /// grows without bound. - static const pitchLimit = 60.0; + /// The largest [maxPitch] that takes effect, as in MapLibre GL JS by + /// default. + static const pitchLimit = 85.0; /// Defines the visible bounds when the map is first loaded. Takes precedence /// over [initialCenter]/[initialZoom]. diff --git a/test/gestures/pitched_gestures_test.dart b/test/gestures/pitched_gestures_test.dart index 2a4db5953..16cdd9427 100644 --- a/test/gestures/pitched_gestures_test.dart +++ b/test/gestures/pitched_gestures_test.dart @@ -25,7 +25,7 @@ Future _pump( initialZoom: 12, initialRotation: rotation, initialPitch: pitch, - maxPitch: 60, + maxPitch: 85, ), children: const [], ), @@ -128,6 +128,40 @@ void main() { expect(_distanceOnScreen(controller, coordinate, cursor), lessThan(0.5)); }); + testWidgets('drag from above the far limit', (tester) async { + final controller = await _pump(tester, pitch: 85); + final farLimit = controller.camera.farLimit; + expect(farLimit, greaterThan(100)); + final coordinate = + controller.camera.screenOffsetToLatLng(Offset(200, farLimit)); + final gesture = await tester.startGesture(const Offset(200, 40)); + for (int i = 1; i <= 24; i++) { + await gesture.moveTo(Offset(200, 40 + i * 20.0)); + await tester.pump(const Duration(milliseconds: 100)); + } + expect( + _distanceOnScreen(controller, coordinate, const Offset(200, 520)), + lessThan(0.5), + ); + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('scroll-wheel zoom with the cursor above the far limit', + (tester) async { + final controller = await _pump(tester, pitch: 85); + const cursor = Offset(300, 40); + final onFarLimit = Offset(300, controller.camera.farLimit); + final coordinate = controller.camera.screenOffsetToLatLng(onFarLimit); + await tester.sendEventToBinding( + const PointerScrollEvent(position: cursor, scrollDelta: Offset(0, -100)), + ); + await tester.pumpAndSettle(); + expect(controller.camera.zoom, greaterThan(12)); + expect( + _distanceOnScreen(controller, coordinate, onFarLimit), lessThan(0.5)); + }); + // The unpitched gesture already leaves a drift proportional to the rotation // per frame, so pitch is checked to add none. for (final degreesPerFrame in [0.75, 3.0]) { diff --git a/test/layer/far_limit_layers_test.dart b/test/layer/far_limit_layers_test.dart new file mode 100644 index 000000000..61b7eefd4 --- /dev/null +++ b/test/layer/far_limit_layers_test.dart @@ -0,0 +1,309 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +import '../test_utils/test_tile_provider.dart'; + +const _size = Size(400, 300); +const _center = LatLng(45.5, -122.6); +const _background = Color(0xFF0000FF); +const _red = Color(0xFFFF0000); + +final _boundary = GlobalKey(); + +Future _pump( + WidgetTester tester, { + double rotation = 0, + required List children, +}) async { + final controller = MapController(); + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: RepaintBoundary( + key: _boundary, + child: SizedBox.fromSize( + size: _size, + child: FlutterMap( + mapController: controller, + options: MapOptions( + initialCenter: _center, + initialZoom: 12, + initialRotation: rotation, + initialPitch: 85, + maxPitch: 85, + backgroundColor: _background, + ), + children: children, + ), + ), + ), + ), + )); + return controller; +} + +/// The rendered map, after letting tile images load. +Future<_Pixels> _render(WidgetTester tester) async { + await tester + .runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await tester.pumpAndSettle(); + final boundary = tester.renderObject( + find.byKey(_boundary), + ); + final data = await tester.runAsync(() async { + final image = await boundary.toImage(); + return image.toByteData(); + }); + return _Pixels(data!); +} + +class _Pixels { + final ByteData data; + + const _Pixels(this.data); + + Color at(int x, int y) { + final value = data.getUint32((y * _size.width.toInt() + x) * 4); + return Color(((value & 0xFF) << 24) | (value >> 8)); + } +} + +/// A polygon from far beyond the far limit to latitude [south], 2 degrees of +/// longitude wide. The default [south] is behind the camera. +Polygon _band({double south = 40}) => Polygon( + points: [ + const LatLng(65, -123.6), + const LatLng(65, -121.6), + LatLng(south, -121.6), + LatLng(south, -123.6), + ], + color: _red, + hitValue: 'band', + ); + +bool _inBand(LatLng coordinate, {double south = 40}) => + coordinate.latitude > south && + coordinate.latitude < 65 && + coordinate.longitude > -123.6 && + coordinate.longitude < -121.6; + +void main() { + for (final rotation in [0.0, 45.0]) { + testWidgets( + 'tiles cover the viewport below the far limit and nothing above, ' + 'rotation $rotation', (tester) async { + final controller = await _pump( + tester, + rotation: rotation, + children: [ + TileLayer( + tileProvider: TestTileProvider(), + tileDisplay: const TileDisplay.instantaneous(), + ), + ], + ); + final pixels = await _render(tester); + final farLimit = controller.camera.farLimit; + expect(farLimit, inExclusiveRange(10, 290)); + for (var y = 0; y < 300; y += 3) { + if ((y - farLimit).abs() < 2) continue; + for (var x = 0; x < 400; x += 7) { + expect( + pixels.at(x, y), + y < farLimit ? _background : const Color(0xFFFFFFFF), + reason: '($x, $y)', + ); + } + } + }); + } + + testWidgets('polygon beyond the far limit', (tester) async { + // In front of the camera: Flutter 3.27 draws nothing of a polygon with a + // vertex behind it. + const south = 45.45; + final controller = await _pump( + tester, + rotation: 30, + children: [ + PolygonLayer(polygons: [_band(south: south)]), + ], + ); + final pixels = await _render(tester); + final camera = controller.camera; + final farLimit = camera.farLimit; + var inside = 0; + for (var y = 0; y < 300; y += 3) { + if ((y - farLimit).abs() < 2) continue; + for (var x = 0; x < 400; x += 2) { + final Color expected; + if (y < farLimit) { + expected = _background; + } else { + final neighbors = [ + for (final dx in const [-3, 0, 3]) + for (final dy in const [-3, 0, 3]) + _inBand( + camera + .screenOffsetToLatLng(Offset(x + 0.5 + dx, y + 0.5 + dy)), + south: south, + ), + ]; + if (neighbors.toSet().length > 1) continue; + expected = neighbors.first ? _red : _background; + if (neighbors.first) inside++; + } + expect(pixels.at(x, y), expected, reason: '($x, $y)'); + } + } + expect(inside, greaterThan(100)); + }); + + testWidgets('polygon near the far limit is drawn', (tester) async { + final controller = await _pump(tester, children: []); + final camera = controller.camera; + final farLimit = camera.farLimit; + final near = camera.screenOffsetToLatLng(Offset(150, farLimit + 6)); + final far = camera.screenOffsetToLatLng(Offset(250, farLimit + 1)); + await _pump(tester, children: [ + PolygonLayer(polygons: [ + Polygon( + points: [ + LatLng(far.latitude, near.longitude), + LatLng(far.latitude, far.longitude), + LatLng(near.latitude, far.longitude), + LatLng(near.latitude, near.longitude), + ], + color: _red, + ), + ]), + ]); + final pixels = await _render(tester); + final y = (farLimit + 3).round(); + expect(pixels.at(200, y), _red); + }); + + testWidgets('tap above the far limit hits nothing', (tester) async { + final hitNotifier = LayerHitNotifier(null); + final hits = ?>[]; + final controller = await _pump( + tester, + children: [ + GestureDetector( + onTap: () => hits.add(hitNotifier.value), + child: PolygonLayer(hitNotifier: hitNotifier, polygons: [_band()]), + ), + ], + ); + final farLimit = controller.camera.farLimit; + for (final (position, hit) in [ + (Offset(200, farLimit - 20), false), + (const Offset(200, 5), false), + (Offset(200, farLimit + 20), true), + (const Offset(200, 290), true), + ]) { + hits.clear(); + await tester.tapAt(position); + await tester.pump(const Duration(milliseconds: 500)); + expect(hits.isNotEmpty && hits.last != null, hit, reason: '$position'); + if (hit) expect(hits.last!.hitValues, ['band']); + } + }); + + testWidgets('markers beyond the far limit and in other worlds are not built', + (tester) async { + // With rotation 90, the copies of a marker in the worlds to the east + // converge to the middle of the horizon, which is in the viewport. + await _pump( + tester, + rotation: 90, + children: [ + MarkerLayer( + markers: [ + for (final (key, point) in const [ + ('near', _center), + ('far', LatLng(60, -122.6)), + ]) + Marker( + point: point, + width: 20, + height: 20, + child: SizedBox.expand(key: ValueKey(key)), + ), + ], + ), + ], + ); + expect(find.byKey(const ValueKey('near')), findsOneWidget); + expect(find.byKey(const ValueKey('far')), findsNothing); + }); + + testWidgets('polygon labels are drawn only below the far limit', + (tester) async { + Polygon square(double lat, String label) => Polygon( + points: [ + LatLng(lat + 0.05, -122.65), + LatLng(lat + 0.05, -122.55), + LatLng(lat - 0.05, -122.55), + LatLng(lat - 0.05, -122.65), + ], + label: label, + ); + final near = square(45.5, 'near'); + // As for markers, the copies to the east converge to the middle of the + // horizon at rotation 90. + final controller = await _pump( + tester, + rotation: 90, + children: [ + PolygonLayer(polygons: [near, square(60, 'far')]), + ], + ); + final centers = _paragraphCenters( + tester, + find.descendant( + of: find.byType(BillboardLayerTransformer), + matching: find.byType(CustomPaint), + ), + ); + expect(centers, hasLength(1)); + final expected = controller.camera.latLngToScreenOffset(near.labelPosition); + expect((centers.single - expected).distance, lessThan(0.01)); + }); +} + +/// The screen centers of the paragraphs that the [CustomPaint] [paint] draws. +List _paragraphCenters(WidgetTester tester, Finder paint) { + final box = tester.renderObject(paint); + final canvas = TestRecordingCanvas(); + box.painter!.paint(canvas, box.size); + final saved = []; + var transform = box.getTransformTo(null); + final centers = []; + for (final recorded in canvas.invocations) { + final args = recorded.invocation.positionalArguments; + switch (recorded.invocation.memberName) { + case #save || #saveLayer: + saved.add(transform.clone()); + case #restore: + transform = saved.removeLast(); + case #translate: + transform.multiply( + Matrix4.translationValues(args[0] as double, args[1] as double, 0)); + case #rotate: + transform.rotateZ(args[0] as double); + case #drawParagraph: + final paragraph = args[0] as ui.Paragraph; + final offset = args[1] as Offset; + centers.add(MatrixUtils.transformPoint(transform, + offset + Offset(paragraph.width / 2, paragraph.height / 2))); + } + } + return centers; +} diff --git a/test/layer/tile_layer/tile_cover_calculator_test.dart b/test/layer/tile_layer/tile_cover_calculator_test.dart index b6e953418..87921936c 100644 --- a/test/layer/tile_layer/tile_cover_calculator_test.dart +++ b/test/layer/tile_layer/tile_cover_calculator_test.dart @@ -85,7 +85,9 @@ void main() { final pixelCenter = camera.projectAtZoom(camera.center, tileZoom.toDouble()).floor(); final result = <(Offset, TileCoordinates?, double)>[]; - for (var y = 0.0; y <= camera.nonRotatedSize.height; y += step) { + for (var y = camera.groundViewport.top; + y <= camera.nonRotatedSize.height; + y += step) { for (var x = 0.0; x <= camera.nonRotatedSize.width; x += step) { final screen = Offset(x, y); final ground = camera.groundOffsetFromCenter(screen)!; @@ -256,6 +258,61 @@ void main() { expect(worldEdge, {true, false}); }); + test('at pitch 60 is unchanged', () { + final c = MapCamera( + crs: const Epsg3857(), + center: const LatLng(45.5, -122.6), + zoom: 10, + rotation: 30, + pitch: 60, + nonRotatedSize: const Size(400, 300), + ); + expect( + cover(c).coordinates, + const [ + TileCoordinates(80, 181, 9), + TileCoordinates(81, 181, 9), + TileCoordinates(80, 182, 9), + TileCoordinates(162, 364, 10), + TileCoordinates(163, 364, 10), + TileCoordinates(162, 365, 10), + TileCoordinates(163, 365, 10), + TileCoordinates(161, 366, 10), + TileCoordinates(162, 366, 10), + TileCoordinates(163, 366, 10), + TileCoordinates(162, 367, 10), + TileCoordinates(163, 367, 10), + TileCoordinates(164, 366, 10), + ], + ); + }); + + for (final rotation in [0.0, 45.0]) { + test( + 'at pitch 85 covers the viewport below the far limit, ' + 'rotation $rotation', () { + final c = camera(zoom: 14.45, rotation: rotation, pitch: 85); + expect(c.groundViewport.top, greaterThan(0)); + final tiles = cover(c); + final largestScale = {}; + for (final (screen, tile, scale) in sample(c, tiles, step: 4)) { + expect(tile, isNotNull, reason: 'no tile at $screen'); + largestScale[tile!] = math.max(largestScale[tile] ?? 0, scale); + } + for (final tile in tiles.coordinates.where((t) => t.z < 14)) { + final scale = largestScale[tile]; + if (scale == null) continue; + expect( + scale * math.pow(2, 14.45 - tile.z), + lessThanOrEqualTo(math.sqrt2), + reason: '$tile', + ); + } + expect(tiles.coordinates.map((t) => t.z).reduce(math.min), + lessThanOrEqualTo(12)); + }); + } + test('the buffer adds neighbors outside the visible ground', () { final c = camera(rotation: 45, pitch: 60); final tiles = cover(c); diff --git a/test/map/camera/far_limit_test.dart b/test/map/camera/far_limit_test.dart new file mode 100644 index 000000000..3c0f9ec96 --- /dev/null +++ b/test/map/camera/far_limit_test.dart @@ -0,0 +1,229 @@ +import 'dart: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'; + +import 'pitch_reference_data.dart'; + +const _phone = Size(393, 851); + +MapCamera _camera({ + double pitch = 85, + double rotation = 30, + Size size = _phone, +}) => + MapCamera( + crs: const Epsg3857(), + center: const LatLng(45.5, -122.6), + zoom: 10, + rotation: rotation, + nonRotatedSize: size, + pitch: pitch, + ); + +/// The screen position of [coordinate] in MapLibre Native's camera model, +/// computed from the camera's geometry instead of a matrix. +Offset _model(MapCamera camera, LatLng coordinate) { + final ground = + camera.projectAtZoom(coordinate) - camera.projectAtZoom(camera.center); + final angle = camera.rotationRad; + final x = ground.dx * cos(angle) - ground.dy * sin(angle); + final y = ground.dx * sin(angle) + ground.dy * cos(angle); + final size = camera.nonRotatedSize; + final distance = size.height / 2 / tan(MapCamera.fieldOfView / 2); + final depth = distance - y * sin(camera.pitchRad); + return Offset( + size.width / 2 + distance * x / depth, + size.height / 2 + distance * y * cos(camera.pitchRad) / depth, + ); +} + +/// The angle, in degrees, between the view ray through screen row [y] and +/// straight down. +double _rayAngle(MapCamera camera, double y) { + final distance = + camera.nonRotatedSize.height / 2 / tan(MapCamera.fieldOfView / 2); + return camera.pitch + + atan((camera.nonRotatedSize.height / 2 - y) / distance) * 180 / pi; +} + +/// Screen positions in a grid over the viewport below the far limit. +Iterable _belowFarLimit(MapCamera camera) sync* { + final top = camera.groundViewport.top; + final size = camera.nonRotatedSize; + for (var i = 0; i <= 6; i++) { + for (var j = 0; j <= 8; j++) { + yield Offset(size.width * i / 6, top + (size.height - top) * j / 8); + } + } +} + +void main() { + group('far limit', () { + test('is in the viewport only above 70.82 degrees', () { + final below = _camera(pitch: 70.81); + expect(below.farLimit, lessThan(0)); + expect(below.groundViewport, Offset.zero & _phone); + final above = _camera(pitch: 70.82); + expect(above.farLimit, greaterThan(0)); + expect(above.groundViewport.top, above.farLimit); + }); + + test('position at pitch 85', () { + final camera = _camera(); + expect(camera.farLimit, closeTo(330.6, 0.1)); + expect(_rayAngle(camera, camera.farLimit), closeTo(89.25, 1e-9)); + expect( + camera.groundViewport, + Rect.fromLTRB(0, camera.farLimit, _phone.width, _phone.height), + ); + }); + + test('is above any position at pitch 0', () { + expect(_camera(pitch: 0).farLimit, double.negativeInfinity); + }); + }); + + group('footprint', () { + test('at pitch 60 is unchanged', () { + final camera = _camera(pitch: 60, size: const Size(400, 300)); + expect(camera.size.width, 969.6152422706655); + expect(camera.size.height, 1079.4228634059837); + expect(camera.pixelOrigin, + const Offset(41032.693012741445, 92931.28022360068)); + final bounds = camera.visibleBounds; + expect(bounds.south, 45.28000510786582); + expect(bounds.west, -123.65017133870347); + expect(bounds.north, 46.31347281706326); + expect(bounds.east, -122.3186074378801); + }); + + for (final rotation in [0.0, 30.0]) { + test('visible bounds at pitch 85, rotation $rotation', () { + final camera = _camera(rotation: rotation); + final bounds = camera.visibleBounds; + for (final value in [ + bounds.south, + bounds.west, + bounds.north, + bounds.east, + ]) { + expect(value.isFinite, isTrue); + } + final farLimit = camera.farLimit; + for (final corner in [ + const Offset(0, 851), + const Offset(393, 851), + Offset(0, farLimit), + Offset(393, farLimit), + ]) { + final coordinate = camera.screenOffsetToLatLng(corner); + expect(coordinate.latitude, + inInclusiveRange(bounds.south - 1e-9, bounds.north + 1e-9), + reason: '$corner'); + expect(coordinate.longitude, + inInclusiveRange(bounds.west - 1e-9, bounds.east + 1e-9), + reason: '$corner'); + } + for (final position in _belowFarLimit(camera)) { + final pixel = + camera.projectAtZoom(camera.screenOffsetToLatLng(position)); + expect(camera.pixelBounds.inflate(1e-6).contains(pixel), isTrue, + reason: '$position'); + } + expect(camera.pixelBounds.topLeft, camera.pixelOrigin); + expect(camera.pixelBounds.size, camera.size); + }); + } + }); + + group('conversions', () { + test('the model matches MapLibre Native at the reference cameras', () { + for (final reference in referenceCameras) { + final camera = MapCamera( + crs: const Epsg3857(), + center: LatLng(reference.latitude, reference.longitude), + zoom: reference.zoom, + rotation: reference.rotation, + nonRotatedSize: Size(reference.width, reference.height), + pitch: reference.pitch, + ); + for (final point in reference.points) { + final screen = + _model(camera, LatLng(point.latitude, point.longitude)); + expect(screen.dx, closeTo(point.x, 0.01)); + expect(screen.dy, closeTo(point.y, 0.01)); + } + } + }); + + for (final pitch in [60.0, 85.0]) { + test('matches the camera model at pitch $pitch', () { + final camera = _camera(pitch: pitch); + for (final position in _belowFarLimit(camera)) { + final coordinate = camera.screenOffsetToLatLng(position); + final model = _model(camera, coordinate); + expect((model - position).distance, lessThan(0.01), + reason: '$position'); + expect( + (camera.latLngToScreenOffset(coordinate) - model).distance, + lessThan(0.01), + reason: '$position', + ); + } + }); + } + + test('round trip at pitch 85', () { + final camera = _camera(); + for (final position in _belowFarLimit(camera)) { + final coordinate = camera.maybeScreenOffsetToLatLng(position); + expect(coordinate, camera.screenOffsetToLatLng(position)); + final screen = camera.latLngToScreenOffset(coordinate!); + expect((screen - position).distance, lessThan(0.01), + reason: '$position'); + } + }); + + test('above the far limit', () { + final camera = _camera(); + for (final position in [ + Offset(100, camera.farLimit - 0.01), + const Offset(300, 314), + const Offset(200, 0), + const Offset(50, -1000), + ]) { + final onFarLimit = Offset(position.dx, camera.farLimit); + expect(camera.maybeScreenOffsetToLatLng(position), isNull); + expect(camera.groundOffsetFromCenter(position), isNull); + expect(camera.screenOffsetToLatLng(position), + camera.screenOffsetToLatLng(onFarLimit)); + expect(camera.offsetToCrs(position), camera.offsetToCrs(onFarLimit)); + } + }); + + test('above the horizon at pitch 60', () { + final camera = _camera(pitch: 60); + const above = Offset(200, -2000); + expect(camera.maybeScreenOffsetToLatLng(above), isNull); + expect(camera.screenOffsetToLatLng(above), + camera.screenOffsetToLatLng(Offset(200, camera.farLimit))); + }); + + test('focusedZoomCenter above the far limit keeps the far limit', () { + final camera = _camera(); + final onFarLimit = Offset(100, camera.farLimit); + final coordinate = camera.screenOffsetToLatLng(onFarLimit); + final zoomed = camera.withPosition( + center: camera.focusedZoomCenter(const Offset(100, 100), 10.5), + zoom: 10.5, + ); + expect( + (zoomed.latLngToScreenOffset(coordinate) - onFarLimit).distance, + lessThan(0.5), + ); + }); + }); +} diff --git a/test/map/camera/pitched_fit_and_constraint_test.dart b/test/map/camera/pitched_fit_and_constraint_test.dart index cef1ae0a4..a6e8a2f52 100644 --- a/test/map/camera/pitched_fit_and_constraint_test.dart +++ b/test/map/camera/pitched_fit_and_constraint_test.dart @@ -159,5 +159,55 @@ void main() { lessThan(0.1), ); }); + + group('at pitch 85', () { + final camera = _camera(pitch: 85, rotation: 30); + final area = Rect.fromLTRB(0, camera.farLimit, 400, 600); + + test('the far limit is in the viewport', () { + expect(area.top, greaterThan(100)); + }); + + test('fit bounds', () { + final fitted = CameraFit.bounds(bounds: _bounds).fit(camera); + expect(fitted.pitch, 85); + expect(fitted.rotation, 30); + final margin = _margin(_screen(fitted, _corners), area); + 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); + final positions = _screen(fitted, _coordinates); + final margin = _margin(positions, area.deflate(20)); + // The top padding is above the far limit, so the far limit is the + // edge there. + expect(_margin(positions, Rect.fromLTRB(20, area.top, 380, 580)), + greaterThan(-0.01)); + expect(margin, lessThan(21)); + }); + + test('fit inside bounds', () { + final fitted = CameraFit.insideBounds(bounds: _bounds).fit(camera); + final projectedBounds = Rect.fromPoints( + fitted.projectAtZoom(_bounds.southEast), + fitted.projectAtZoom(_bounds.northWest), + ); + final ground = [ + area.topLeft, + area.topRight, + area.bottomRight, + area.bottomLeft, + ].map((corner) => + fitted.projectAtZoom(fitted.screenOffsetToLatLng(corner))); + final margin = _margin(ground, projectedBounds); + expect(margin, greaterThan(-0.01)); + expect(margin, lessThan(1)); + }); + }); }); } diff --git a/test/map/camera/pitched_focal_point_test.dart b/test/map/camera/pitched_focal_point_test.dart index b72c0eb6e..d9c05b0e3 100644 --- a/test/map/camera/pitched_focal_point_test.dart +++ b/test/map/camera/pitched_focal_point_test.dart @@ -34,10 +34,16 @@ void main() { } } - test('focusedZoomCenter keeps the center above the horizon', () { + test('focusedZoomCenter above the horizon keeps the far limit', () { final camera = _camera(); - expect( - camera.focusedZoomCenter(const Offset(200, -1000), 12), camera.center); + final onFarLimit = Offset(200, camera.farLimit); + final coordinate = camera.screenOffsetToLatLng(onFarLimit); + final zoomed = camera.withPosition( + center: camera.focusedZoomCenter(const Offset(200, -1000), 10.25), + zoom: 10.25, + ); + expect((zoomed.latLngToScreenOffset(coordinate) - onFarLimit).distance, + lessThan(0.5)); }); test('offsetToCrs is the coordinate under the offset', () { diff --git a/test/map/pitch_controller_offsets_test.dart b/test/map/pitch_controller_offsets_test.dart index 6364a36e3..b7b9c4151 100644 --- a/test/map/pitch_controller_offsets_test.dart +++ b/test/map/pitch_controller_offsets_test.dart @@ -22,7 +22,7 @@ Future _pump( initialZoom: 12, initialRotation: rotation, initialPitch: pitch, - maxPitch: 60, + maxPitch: 85, ), children: const [], ), @@ -76,4 +76,36 @@ void main() { expect(controller.camera.center.latitude, closeTo(target.latitude, 1e-9)); expect(controller.camera.center.longitude, closeTo(target.longitude, 1e-9)); }); + + testWidgets('move and rotate with an offset below the far limit, pitch 85', + (tester) async { + final controller = await _pump(tester, pitch: 85, rotation: 30); + controller.move(target, 13, offset: const Offset(-50, 150)); + final screen = controller.camera.latLngToScreenOffset(target); + expect((screen - const Offset(150, 450)).distance, lessThan(0.5)); + + const point = Offset(250, 550); + final coordinate = controller.camera.screenOffsetToLatLng(point); + controller.rotateAroundPoint(90, offset: point - const Offset(200, 300)); + expect( + (controller.camera.latLngToScreenOffset(coordinate) - point).distance, + lessThan(0.5), + ); + }); + + testWidgets('offset above the far limit is ignored, pitch 85', + (tester) async { + final controller = await _pump(tester, pitch: 85, rotation: 30); + // Above the far limit, but below the horizon. + final offset = Offset(0, controller.camera.farLimit - 300 - 5); + controller.move(target, 13, offset: offset); + expect(controller.camera.center.latitude, closeTo(target.latitude, 1e-9)); + expect(controller.camera.center.longitude, closeTo(target.longitude, 1e-9)); + + final center = controller.camera.center; + controller.rotateAroundPoint(90, offset: offset); + expect(controller.camera.rotation, 90); + expect(controller.camera.center.latitude, closeTo(center.latitude, 1e-9)); + expect(controller.camera.center.longitude, closeTo(center.longitude, 1e-9)); + }); } diff --git a/test/map/pitch_options_test.dart b/test/map/pitch_options_test.dart index 0717c5ad9..27bd5c9fa 100644 --- a/test/map/pitch_options_test.dart +++ b/test/map/pitch_options_test.dart @@ -30,11 +30,17 @@ void main() { expect(controller.camera.pitch, 45); }); + testWidgets('maximum pitch above 60', (tester) async { + final controller = + await _pump(tester, const MapOptions(initialPitch: 75, maxPitch: 75)); + expect(controller.camera.pitch, 75); + }); + testWidgets('maximum pitch above the limit takes effect as the limit', (tester) async { final controller = - await _pump(tester, const MapOptions(initialPitch: 75, maxPitch: 75)); - expect(controller.camera.pitch, MapOptions.pitchLimit); + await _pump(tester, const MapOptions(initialPitch: 90, maxPitch: 90)); + expect(controller.camera.pitch, 85); }); testWidgets('negative initial pitch is limited to 0', (tester) async {