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 {