diff --git a/example/ios/Flutter/AppFrameworkInfo.plist b/example/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf765..391a902b2 100644 --- a/example/ios/Flutter/AppFrameworkInfo.plist +++ b/example/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 1c062b379..f260182e2 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -55,6 +56,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -62,6 +64,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -79,6 +82,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -142,6 +146,9 @@ productType = "com.apple.product-type.bundle.unit-test"; }; 97C146ED1CF9000F007C117D /* Runner */ = { + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( @@ -165,6 +172,9 @@ /* Begin PBXProject section */ 97C146E61CF9000F007C117D /* Project object */ = { + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; @@ -346,7 +356,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -472,7 +482,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -523,7 +533,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -611,6 +621,18 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d42e..c3fedb29c 100644 --- a/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/example/ios/Runner/Info.plist b/example/ios/Runner/Info.plist index 7680919e1..638fc8a3e 100644 --- a/example/ios/Runner/Info.plist +++ b/example/ios/Runner/Info.plist @@ -2,6 +2,8 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,6 +26,29 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + FlutterSceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +66,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/example/lib/main.dart b/example/lib/main.dart index 0e794885e..da09f81bd 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -17,6 +17,7 @@ import 'package:flutter_map_example/pages/map_controller.dart'; import 'package:flutter_map_example/pages/map_inside_listview.dart'; import 'package:flutter_map_example/pages/markers.dart'; import 'package:flutter_map_example/pages/overlay_image.dart'; +import 'package:flutter_map_example/pages/pitch.dart'; import 'package:flutter_map_example/pages/plugin_zoombuttons.dart'; import 'package:flutter_map_example/pages/polygon.dart'; import 'package:flutter_map_example/pages/polygon_perf_stress.dart'; @@ -70,6 +71,7 @@ class MyApp extends StatelessWidget { ManyCirclesPage.route: (context) => const ManyCirclesPage(), CirclePage.route: (context) => const CirclePage(), OverlayImagePage.route: (context) => const OverlayImagePage(), + PitchPage.route: (context) => const PitchPage(), PolygonPage.route: (context) => const PolygonPage(), RepeatedWorldsPage.route: (context) => const RepeatedWorldsPage(), PolygonPerfStressPage.route: (context) => const PolygonPerfStressPage(), diff --git a/example/lib/pages/pitch.dart b/example/lib/pages/pitch.dart new file mode 100644 index 000000000..3168defd0 --- /dev/null +++ b/example/lib/pages/pitch.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map_example/misc/tile_providers.dart'; +import 'package:flutter_map_example/widgets/drawer/menu_drawer.dart'; +import 'package:latlong2/latlong.dart'; + +class PitchPage extends StatefulWidget { + static const String route = '/pitch'; + + const PitchPage({super.key}); + + @override + State createState() => _PitchPageState(); +} + +class _PitchPageState extends State { + static const _center = LatLng(51.5, -0.09); + + final _mapController = MapController(); + double _pitch = 45; + double _rotation = 0; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Pitch')), + drawer: const MenuDrawer(PitchPage.route), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + children: [ + const Text( + 'Markers and labels face the viewer; tiles, polygons and ' + 'polylines lie on the ground. At 60°, the tile layer loads ' + 'up to 7.5 times as many tiles as when unpitched.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12), + ), + Row( + children: [ + SizedBox( + width: 96, + child: Text('Pitch ${_pitch.round()}°'), + ), + Expanded( + child: Slider( + value: _pitch, + max: MapOptions.pitchLimit, + onChanged: (value) { + setState(() => _pitch = value); + _mapController.pitch(value); + }, + ), + ), + ], + ), + Row( + children: [ + SizedBox( + width: 96, + child: Text('Rotation ${_rotation.round()}°'), + ), + Expanded( + child: Slider( + value: _rotation, + min: -180, + max: 180, + onChanged: (value) { + setState(() => _rotation = value); + _mapController.rotate(value); + }, + ), + ), + ], + ), + ], + ), + ), + Expanded( + child: FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: _center, + initialZoom: 14, + initialPitch: _pitch, + maxPitch: MapOptions.pitchLimit, + onMapEvent: (event) { + if (event.camera.pitch != _pitch || + event.camera.rotation != _rotation) { + setState(() { + _pitch = event.camera.pitch; + _rotation = + event.camera.rotation.remainder(360).clamp(-180, 180); + }); + } + }, + ), + children: [ + const PrimaryTileLayer(), + PolygonLayer( + polygons: [ + Polygon( + points: const [ + LatLng(51.508, -0.11), + LatLng(51.508, -0.095), + LatLng(51.500, -0.095), + LatLng(51.500, -0.11), + ], + color: Colors.purple.withAlpha(96), + borderColor: Colors.purple, + borderStrokeWidth: 3, + label: 'Polygon', + labelStyle: const TextStyle( + color: Colors.black, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + PolylineLayer( + polylines: [ + Polyline( + points: const [ + LatLng(51.490, -0.12), + LatLng(51.500, -0.09), + LatLng(51.515, -0.08), + LatLng(51.530, -0.07), + ], + strokeWidth: 6, + color: Colors.blue, + ), + ], + ), + MarkerLayer( + rotate: true, + markers: [ + for (final point in const [ + _center, + LatLng(51.52, -0.10), + LatLng(51.535, -0.075), + ]) + Marker( + point: point, + width: 40, + height: 40, + alignment: Alignment.topCenter, + child: const Icon( + Icons.location_pin, + size: 40, + color: Colors.red, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/example/lib/widgets/drawer/menu_drawer.dart b/example/lib/widgets/drawer/menu_drawer.dart index 5a44dff84..ee102cb31 100644 --- a/example/lib/widgets/drawer/menu_drawer.dart +++ b/example/lib/widgets/drawer/menu_drawer.dart @@ -21,6 +21,7 @@ import 'package:flutter_map_example/pages/map_controller.dart'; import 'package:flutter_map_example/pages/map_inside_listview.dart'; import 'package:flutter_map_example/pages/markers.dart'; import 'package:flutter_map_example/pages/overlay_image.dart'; +import 'package:flutter_map_example/pages/pitch.dart'; import 'package:flutter_map_example/pages/plugin_zoombuttons.dart'; import 'package:flutter_map_example/pages/polygon.dart'; import 'package:flutter_map_example/pages/polygon_perf_stress.dart'; @@ -255,6 +256,11 @@ class MenuDrawer extends StatelessWidget { routeName: FlingAnimationDampingPage.route, currentRoute: currentRoute, ), + MenuItemWidget( + caption: 'Pitch', + routeName: PitchPage.route, + currentRoute: currentRoute, + ), const Divider(), MenuItemWidget( caption: 'WMS Sourced Map', diff --git a/example/macos/Flutter/GeneratedPluginRegistrant.swift b/example/macos/Flutter/GeneratedPluginRegistrant.swift index b19945c53..8236f5728 100644 --- a/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,12 +5,8 @@ import FlutterMacOS import Foundation -import path_provider_foundation -import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) - SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index 21fa967b3..c10925d7f 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -35,6 +35,7 @@ export 'package:flutter_map/src/layer/polygon_layer/label/placement_calculators/ export 'package:flutter_map/src/layer/polygon_layer/polygon_layer.dart'; export 'package:flutter_map/src/layer/polyline_layer/polyline_layer.dart'; export 'package:flutter_map/src/layer/scalebar/scalebar.dart'; +export 'package:flutter_map/src/layer/shared/billboard_layer_transformer.dart'; export 'package:flutter_map/src/layer/shared/layer_interactivity/layer_hit_notifier.dart'; export 'package:flutter_map/src/layer/shared/layer_interactivity/layer_hit_result.dart'; export 'package:flutter_map/src/layer/shared/line_patterns/stroke_pattern.dart'; diff --git a/lib/src/gestures/map_events.dart b/lib/src/gestures/map_events.dart index 93ac68915..af506af23 100644 --- a/lib/src/gestures/map_events.dart +++ b/lib/src/gestures/map_events.dart @@ -327,6 +327,21 @@ class MapEventRotate extends MapEventWithMove { }); } +/// Event which is fired when map is being pitched +@immutable +class MapEventPitch extends MapEventWithMove { + /// Custom ID to identify related object(s) + final String? id; + + /// Create a new map event that represents a change of the map's pitch. + const MapEventPitch({ + required this.id, + required super.source, + required super.oldCamera, + required super.camera, + }); +} + /// Event which is fired when rotate gesture was started @immutable class MapEventRotateStart extends MapEvent { diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index 3d1e89e3b..347f8c857 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -88,6 +88,14 @@ class MapInteractiveViewerState extends State late final _flingController = AnimationController(vsync: this); late Animation _flingAnimation; + // A fling on a pitched map moves the center from [_flingCenterStart] by + // [_flingGroundStep] for each pixel that the animation advances along + // [_flingDirection] from [_flingOffset]. + late LatLng _flingCenterStart; + late Offset _flingGroundStep; + late Offset _flingDirection; + late Offset _flingOffset; + late final _doubleTapController = AnimationController( vsync: this, duration: _interactionOptions.doubleTapZoomDuration, @@ -581,11 +589,13 @@ class MapInteractiveViewerState extends State widget.controller.moveStarted(MapEventSource.dragStart); } - final localDistanceOffset = _rotateOffset( - _lastFocalLocal - details.localFocalPoint, + final groundShift = _groundShift( + _lastFocalLocal, + details.localFocalPoint, ); + if (groundShift == null) return; - widget.controller.dragUpdated(MapEventSource.onDrag, localDistanceOffset); + widget.controller.dragUpdated(MapEventSource.onDrag, groundShift); } } @@ -694,7 +704,8 @@ class MapInteractiveViewerState extends State final oldFocalPt = _camera.projectAtZoom(_focalStartLatLng, zoomAfterPinchZoom); final zoomDifference = oldFocalPt - newFocalPt; - final moveDifference = _rotateOffset(_focalStartLocal - _lastFocalLocal); + final moveDifference = + _groundShift(_focalStartLocal, _lastFocalLocal) ?? Offset.zero; final newCenterPt = oldCenterPt + zoomDifference + moveDifference; return _camera.unprojectAtZoom(newCenterPt, zoomAfterPinchZoom); @@ -803,6 +814,15 @@ class MapInteractiveViewerState extends State ); final distance = (Offset.zero & _camera.nonRotatedSize).shortestSide; + if (_camera.pitch != 0) { + _flingCenterStart = _camera.center; + _flingGroundStep = + _groundShift(_lastFocalLocal + direction, _lastFocalLocal) ?? + _rotateOffset(direction); + _flingDirection = direction; + _flingOffset = flingOffset; + } + _flingAnimation = Tween( begin: flingOffset, end: flingOffset + direction * distance, @@ -980,8 +1000,17 @@ class MapInteractiveViewerState extends State _startListeningForAnimationInterruptions(); } - final newCenterPoint = _camera.projectAtZoom(_mapCenterStart) + - _flingAnimation.value.rotate(_camera.rotationRad); + final Offset newCenterPoint; + if (_camera.pitch == 0) { + newCenterPoint = _camera.projectAtZoom(_mapCenterStart) + + _flingAnimation.value.rotate(_camera.rotationRad); + } else { + final advance = _flingAnimation.value - _flingOffset; + final pixels = + advance.dx * _flingDirection.dx + advance.dy * _flingDirection.dy; + newCenterPoint = + _camera.projectAtZoom(_flingCenterStart) + _flingGroundStep * pixels; + } final LatLng newCenter; if (!_camera.crs.replicatesWorldLongitude) { newCenter = _camera.unprojectAtZoom(newCenterPoint); @@ -1398,6 +1427,16 @@ class MapInteractiveViewerState extends State return _camera.clampZoom(resultZoom); } + /// 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) { diff --git a/lib/src/layer/marker_layer/marker_layer.dart b/lib/src/layer/marker_layer/marker_layer.dart index 8b53766a1..819ff8d92 100644 --- a/lib/src/layer/marker_layer/marker_layer.dart +++ b/lib/src/layer/marker_layer/marker_layer.dart @@ -165,8 +165,10 @@ class _MarkerLayerState extends State { final worldWidth = camera.getWorldWidthAtZoom(); final zoomScale = camera.crs.scale(camera.zoom); + final pixelToScreen = camera.pixelToScreenTransform; + final viewport = Offset.zero & camera.nonRotatedSize; - return MobileLayerTransformer( + return BillboardLayerTransformer( child: Stack( children: () sync* { for (int i = 0; i < widget.markers.length; i++) { @@ -215,36 +217,50 @@ class _MarkerLayerState extends State { final bottom = height - top; Positioned? getPositioned(double worldShift) { - final shiftedX = pxPoint.dx + worldShift; + final shifted = Offset(pxPoint.dx + worldShift, pxPoint.dy); // Cull if out of bounds - if (!camera.pixelBounds.overlaps( - Rect.fromPoints( - Offset(shiftedX + left, pxPoint.dy - bottom), - Offset(shiftedX - right, pxPoint.dy + top), - ), - )) { - return null; + if (camera.pitch == 0) { + if (!camera.pixelBounds.overlaps( + Rect.fromPoints( + Offset(shifted.dx + left, shifted.dy - bottom), + Offset(shifted.dx - right, shifted.dy + top), + ), + )) { + return null; + } + } else { + // A point behind the camera has no screen position + if (_perspectiveDivisor(pixelToScreen, shifted) <= 0) { + return null; + } } - // Shift original coordinate along worlds, then move into relative - // to origin space - final shiftedLocalPoint = - Offset(shiftedX, pxPoint.dy) - camera.pixelOrigin; + final screen = MatrixUtils.transformPoint(pixelToScreen, shifted); + + // Without rotation, the marker's corners are within the larger + // of its sizes from its point + if (camera.pitch != 0 && + !viewport.overlaps(Rect.fromCircle( + center: screen, + radius: width > height ? width : height, + ))) { + return null; + } return Positioned( key: m.key, width: width, height: height, - left: shiftedLocalPoint.dx - right, - top: shiftedLocalPoint.dy - bottom, + left: screen.dx - right, + top: screen.dy - bottom, child: (m.rotate ?? widget.rotate) - ? Transform.rotate( - angle: -camera.rotationRad, + ? m.child + : Transform.rotate( + angle: camera.rotationRad, alignment: Alignment(-alignment.x, -alignment.y), child: m.child, - ) - : m.child, + ), ); } @@ -280,3 +296,10 @@ class _MarkerLayerState extends State { ); } } + +/// The divisor of the perspective division of [matrix] at [point]; not +/// positive for a point behind the camera. +double _perspectiveDivisor(Matrix4 matrix, Offset point) => + matrix.storage[3] * point.dx + + matrix.storage[7] * point.dy + + matrix.storage[15]; diff --git a/lib/src/layer/polygon_layer/label/label_painter.dart b/lib/src/layer/polygon_layer/label/label_painter.dart new file mode 100644 index 000000000..8dd11c055 --- /dev/null +++ b/lib/src/layer/polygon_layer/label/label_painter.dart @@ -0,0 +1,71 @@ +part of '../polygon_layer.dart'; + +/// Draws the labels of a pitched map's polygons in screen space, so that they +/// face the viewer instead of lying on the ground +class _PolygonLabelPainter extends CustomPainter + with FeatureLayerUtils { + _PolygonLabelPainter({required this.polygons, required this.camera}) + : _helper = OffsetHelper(camera: camera); + + final List<_ProjectedPolygon> polygons; + + @override + final MapCamera camera; + + final OffsetHelper _helper; + + @override + void paint(Canvas canvas, Size size) { + super.paint(canvas, size); + + final origin = camera.pixelOrigin; + final canvasToScreen = camera.pixelToScreenTransform + ..translateByDouble(origin.dx, origin.dy, 0, 1); + + for (final projectedPolygon in polygons) { + final polygon = projectedPolygon.polygon; + final textPainter = polygon.textPainter; + if (projectedPolygon.points.isEmpty || textPainter == null) continue; + + final bBox = polygon.boundingBox; + final bounds = ( + min: _helper.getOffset(bBox.southWest), + max: _helper.getOffset(bBox.northEast), + ); + + workAcrossWorlds((shift) { + final point = _helper.getOffset(polygon.labelPosition, shift: shift); + // A point behind the camera has no screen position + if (canvasToScreen.storage[3] * point.dx + + canvasToScreen.storage[7] * point.dy + + canvasToScreen.storage[15] <= + 0) { + 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), + bounds: bounds, + textPainter: textPainter, + rotationRad: -camera.rotationRad, + rotate: !polygon.rotateLabel, + padding: 20, + ); + if (painter == null) return WorldWorkControl.invisible; + painter(canvas); + return WorldWorkControl.visible; + }); + } + } + + // Labels don't take part in hit testing, so taps reach the polygons below. + @override + bool? hitTest(Offset position) => false; + + @override + bool shouldRepaint(_PolygonLabelPainter oldDelegate) => + polygons != oldDelegate.polygons || camera != oldDelegate.camera; +} diff --git a/lib/src/layer/polygon_layer/polygon_layer.dart b/lib/src/layer/polygon_layer/polygon_layer.dart index 63e61e922..9baec9ad5 100644 --- a/lib/src/layer/polygon_layer/polygon_layer.dart +++ b/lib/src/layer/polygon_layer/polygon_layer.dart @@ -16,6 +16,7 @@ import 'package:flutter_map/src/misc/simplify.dart'; import 'package:latlong2/latlong.dart' hide Path; part 'label/build_text_painter.dart'; +part 'label/label_painter.dart'; part 'painter.dart'; part 'polygon.dart'; part 'projected_polygon.dart'; @@ -259,13 +260,17 @@ class _PolygonLayerState extends State> growable: false, ); - return MobileLayerTransformer( + // When pitched, labels face the viewer, so they are drawn by a separate + // painter in screen space instead of on the ground with their polygons. + final pitchedLabels = camera.pitch != 0 && widget.polygonLabels; + + final polygons = MobileLayerTransformer( child: CustomPaint( painter: _PolygonPainter( polygons: culled, triangles: triangles, camera: camera, - polygonLabels: widget.polygonLabels, + polygonLabels: widget.polygonLabels && !pitchedLabels, drawLabelsLast: widget.drawLabelsLast, painterFillMethod: widget.painterFillMethod, invertedFill: widget.invertedFill, @@ -275,6 +280,19 @@ class _PolygonLayerState extends State> size: camera.size, ), ); + if (!pitchedLabels) return polygons; + + return Stack( + children: [ + polygons, + BillboardLayerTransformer( + child: CustomPaint( + painter: _PolygonLabelPainter(polygons: culled, camera: camera), + size: camera.nonRotatedSize, + ), + ), + ], + ); } Iterable _generateHolesIndices(_ProjectedPolygon polygon) sync* { diff --git a/lib/src/layer/shared/billboard_layer_transformer.dart b/lib/src/layer/shared/billboard_layer_transformer.dart new file mode 100644 index 000000000..c1160ec57 --- /dev/null +++ b/lib/src/layer/shared/billboard_layer_transformer.dart @@ -0,0 +1,32 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/src/layer/shared/mobile_layer_transformer.dart'; +import 'package:flutter_map/src/map/camera/camera.dart'; + +/// Places a [child] widget tree in screen space, unaffected by the map's +/// rotation and pitch, so that it can draw upright widgets at the screen +/// positions of coordinates (see [MapCamera.latLngToScreenOffset]) +/// +/// Use it for content that should face the viewer, such as markers and +/// labels. Content that lies on the ground, such as tiles and polygons, +/// belongs in a [MobileLayerTransformer]. +class BillboardLayerTransformer extends StatelessWidget { + /// Places a [child] widget tree in screen space. + const BillboardLayerTransformer({super.key, required this.child}); + + /// The layer content, laid out in the map's view size. + final Widget child; + + @override + Widget build(BuildContext context) { + final camera = MapCamera.of(context); + + return OverflowBox( + alignment: Alignment.topLeft, + minWidth: camera.nonRotatedSize.width, + maxWidth: camera.nonRotatedSize.width, + minHeight: camera.nonRotatedSize.height, + maxHeight: camera.nonRotatedSize.height, + child: child, + ); + } +} diff --git a/lib/src/layer/shared/mobile_layer_transformer.dart b/lib/src/layer/shared/mobile_layer_transformer.dart index 092bafad2..36b760d29 100644 --- a/lib/src/layer/shared/mobile_layer_transformer.dart +++ b/lib/src/layer/shared/mobile_layer_transformer.dart @@ -16,12 +16,30 @@ class MobileLayerTransformer extends StatelessWidget { Widget build(BuildContext context) { final camera = MapCamera.of(context); + if (camera.pitch == 0) { + return OverflowBox( + minWidth: camera.size.width, + maxWidth: camera.size.width, + minHeight: camera.size.height, + maxHeight: camera.size.height, + child: Transform.rotate(angle: camera.rotationRad, child: child), + ); + } + + // 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; return OverflowBox( + alignment: Alignment.topLeft, minWidth: camera.size.width, maxWidth: camera.size.width, minHeight: camera.size.height, maxHeight: camera.size.height, - child: Transform.rotate(angle: camera.rotationRad, child: child), + child: Transform( + transform: camera.pixelToScreenTransform + ..translateByDouble(origin.dx, origin.dy, 0, 1), + child: child, + ), ); } } diff --git a/lib/src/layer/tile_layer/tile_range_calculator.dart b/lib/src/layer/tile_layer/tile_range_calculator.dart index d85a6d572..27b5fe814 100644 --- a/lib/src/layer/tile_layer/tile_range_calculator.dart +++ b/lib/src/layer/tile_layer/tile_range_calculator.dart @@ -49,6 +49,20 @@ class TileRangeCalculator { final tileZoomDouble = tileZoom.toDouble(); final scale = camera.getZoomScale(viewingZoom, tileZoomDouble); final pixelCenter = camera.projectAtZoom(center, tileZoomDouble).floor(); + + if (camera.pitch != 0) { + // The visible ground relative to the center, in pixels at the viewing + // zoom, is the same at any center and zoom. + final fromCenter = + camera.pixelBounds.shift(-camera.projectAtZoom(camera.center)); + return Rect.fromLTRB( + fromCenter.left / scale, + fromCenter.top / scale, + fromCenter.right / scale, + fromCenter.bottom / scale, + ).shift(pixelCenter); + } + final halfSize = camera.size / (scale * 2); return Rect.fromPoints( diff --git a/lib/src/map/camera/camera.dart b/lib/src/map/camera/camera.dart index dddad5856..5dd15ffe3 100644 --- a/lib/src/map/camera/camera.dart +++ b/lib/src/map/camera/camera.dart @@ -6,6 +6,7 @@ import 'package:flutter_map/src/map/inherited_model.dart'; import 'package:flutter_map/src/misc/deg_rad_conversions.dart'; import 'package:flutter_map/src/misc/extensions.dart'; import 'package:latlong2/latlong.dart'; +import 'package:meta/meta.dart'; /// Describes the view of a map. This includes the size/zoom/position/crs as /// well as the minimum/maximum zoom. This class is mostly immutable but has @@ -44,6 +45,23 @@ class MapCamera { /// FlutterMap widget. final Size nonRotatedSize; + /// The pitch, in degrees: the angle between the camera's view direction and + /// straight down. See [pitchRad] for the same value in radians. + /// + /// [zoom] is the scale at [center], so a change of pitch doesn't change it. + final double pitch; + + /// The vertical field of view of a pitched camera, in radians. This is + /// MapLibre Native's default, so that a pitched map rendered by MapLibre and + /// this camera's projection agree. + static const fieldOfView = 0.6435011087932844; + + /// Lazily calculated field + Matrix4? _pixelToScreenTransform; + + /// Lazily calculated field + Rect? _footprintFromCenter; + /// Lazily calculated field Size? _cameraSize; @@ -80,10 +98,12 @@ class MapCamera { /// rotation. When the rotation is zero this will equal [nonRotatedSize], /// otherwise it will be the size of the rectangle which contains this /// camera. - Size get size => _cameraSize ??= calculateRotatedSize( - rotation, - nonRotatedSize, - ); + /// + /// When the map is pitched, this is the size of the rectangle, in projected + /// pixels at [zoom], which contains the ground visible in the map view. + Size get size => _cameraSize ??= pitch == 0 + ? calculateRotatedSize(rotation, nonRotatedSize) + : _footprint.size; /// The offset of the top-left corner of the bounding rectangle of this /// camera. This will not equal the offset of the top-left visible pixel when @@ -110,8 +130,12 @@ class MapCamera { been removed, because this exists, so I'm not sure why it needed to be duplicated. See [HitDetectablePainter.hitTest] for an easy usage example. */ - Offset get pixelOrigin => - _pixelOrigin ??= projectAtZoom(center, zoom) - size.center(Offset.zero); + /// + /// When the map is pitched, this is the top-left corner of the rectangle + /// described by [size], which is not centered on [center]. + Offset get pixelOrigin => _pixelOrigin ??= pitch == 0 + ? projectAtZoom(center, zoom) - size.center(Offset.zero) + : _footprint.topLeft; /// The camera of the closest [FlutterMap] ancestor. If this is called from a /// context with no [FlutterMap] ancestor null, is returned. @@ -134,6 +158,7 @@ class MapCamera { required this.zoom, required this.rotation, required this.nonRotatedSize, + this.pitch = 0, this.minZoom, this.maxZoom, Size? size, @@ -161,6 +186,7 @@ class MapCamera { center: options.initialCenter, zoom: options.initialZoom, rotation: options.initialRotation, + pitch: options.clampPitch(options.initialPitch), nonRotatedSize: kImpossibleSize, ); @@ -174,6 +200,7 @@ class MapCamera { zoom: zoom, rotation: rotation, nonRotatedSize: nonRotatedSize, + pitch: pitch, minZoom: minZoom, maxZoom: maxZoom, ); @@ -189,6 +216,23 @@ class MapCamera { zoom: zoom, nonRotatedSize: nonRotatedSize, rotation: rotation, + pitch: pitch, + minZoom: minZoom, + maxZoom: maxZoom, + ); + } + + /// Returns a new instance of [MapCamera] with the given [pitch]. + MapCamera withPitch(double pitch) { + if (pitch == this.pitch) return this; + + return MapCamera( + crs: crs, + center: center, + zoom: zoom, + nonRotatedSize: nonRotatedSize, + rotation: rotation, + pitch: pitch, minZoom: minZoom, maxZoom: maxZoom, ); @@ -196,9 +240,11 @@ class MapCamera { /// Returns a new instance of [MapCamera] with the given [options]. MapCamera withOptions(MapOptions options) { + final pitch = options.clampPitch(this.pitch); if (options.crs == crs && options.minZoom == minZoom && - options.maxZoom == maxZoom) { + options.maxZoom == maxZoom && + pitch == this.pitch) { return this; } @@ -210,7 +256,8 @@ class MapCamera { zoom: zoom, rotation: rotation, nonRotatedSize: nonRotatedSize, - size: _cameraSize, + pitch: pitch, + size: pitch == this.pitch ? _cameraSize : null, ); } @@ -227,6 +274,7 @@ class MapCamera { zoom: zoom ?? this.zoom, rotation: rotation, nonRotatedSize: nonRotatedSize, + pitch: pitch, size: _cameraSize, ); @@ -272,6 +320,35 @@ class MapCamera { /// The current rotation value in radians double get rotationRad => rotation * degrees2Radians; + /// The current pitch value in radians + double get pitchRad => pitch * degrees2Radians; + + /// The distance, in pixels, from the camera to [center]. + double get _cameraDistance => + 0.5 * nonRotatedSize.height / math.tan(fieldOfView / 2); + + /// The transform from projected pixels at [zoom] (see [projectAtZoom]) to + /// positions in the map view, including rotation and pitch. + /// + /// The translation of a point projected at high zoom is large, so combine + /// this with other transforms in 64-bit arithmetic before passing it to a + /// widget, which may use 32-bit floats. + Matrix4 get pixelToScreenTransform => _pixelToScreen.clone(); + + Matrix4 get _pixelToScreen => + _pixelToScreenTransform ??= _computePixelToScreenTransform(); + + Matrix4 _computePixelToScreenTransform() { + final projectedCenter = projectAtZoom(center); + return Matrix4.identity() + ..translateByDouble( + nonRotatedSize.width / 2, nonRotatedSize.height / 2, 0, 1) + ..multiply(Matrix4.identity()..setEntry(3, 2, -1 / _cameraDistance)) + ..rotateX(pitchRad) + ..rotateZ(rotationRad) + ..translateByDouble(-projectedCenter.dx, -projectedCenter.dy, 0, 1); + } + /// Calculates point value for the given [latlng] using this camera's /// [crs] and [zoom] (or the provided [zoom]). Offset projectAtZoom(LatLng latlng, [double? zoom]) => @@ -310,7 +387,11 @@ class MapCamera { /// Calculates the pixel origin of this [MapCamera] at the given /// [center]/[zoom]. Offset getNewPixelOrigin(LatLng center, [double? zoom]) { - return (projectAtZoom(center, zoom) - size.center(Offset.zero)).round(); + if (pitch == 0) { + return (projectAtZoom(center, zoom) - size.center(Offset.zero)).round(); + } + return (projectAtZoom(center, zoom) + _visibleGroundFromCenter.topLeft) + .round(); } /// Calculates the pixel bounds of this [MapCamera]. This value is cached. @@ -319,6 +400,13 @@ class MapCamera { /// Calculates the pixel bounds of this [MapCamera] at the given [zoom]. Rect pixelBoundsAtZoom(double zoom) { + if (pitch != 0) { + final scale = getZoomScale(zoom, this.zoom); + final fromCenter = _visibleGroundFromCenter; + return Rect.fromPoints( + fromCenter.topLeft * scale, fromCenter.bottomRight * scale) + .shift(projectAtZoom(center, zoom)); + } Size cameraSize = size; if (zoom != this.zoom) { final scale = getZoomScale(this.zoom, zoom); @@ -334,40 +422,97 @@ class MapCamera { /// This will convert a latLng to a position that we could use with a widget /// outside of FlutterMap layer space. Eg using a Positioned Widget. - Offset latLngToScreenOffset(LatLng latLng) { - final nonRotatedPixelOrigin = - projectAtZoom(center, zoom) - nonRotatedSize.center(Offset.zero); + Offset latLngToScreenOffset(LatLng latLng) => + MatrixUtils.transformPoint(_pixelToScreen, projectAtZoom(latLng)); - var point = crs.latLngToOffset(latLng, zoom); + /// 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. + LatLng screenOffsetToLatLng(Offset offset) => + unprojectAtZoom(_screenToPixel(offset, aboveHorizon: _distantGround)!); + + /// Calculate the [LatLng] coordinates for a [offset], or null when the map + /// is pitched and [offset] is above the horizon. + LatLng? maybeScreenOffsetToLatLng(Offset offset) { + final pixel = _screenToPixel(offset, aboveHorizon: null); + return pixel == null ? null : unprojectAtZoom(pixel); + } - final mapCenter = crs.latLngToOffset(center, zoom); + /// The rectangle, in projected pixels at [zoom], which contains the ground + /// visible in the map view. + Rect get _footprint => + _visibleGroundFromCenter.shift(projectAtZoom(center, zoom)); + + /// [_footprint] relative to the projected [center]. It depends only on + /// [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(); + return Rect.fromLTRB( + corners.map((c) => c.dx).reduce(math.min), + corners.map((c) => c.dy).reduce(math.min), + corners.map((c) => c.dx).reduce(math.max), + corners.map((c) => c.dy).reduce(math.max), + ); + }(); - if (rotation != 0.0) { - point = rotatePoint(mapCenter, point, counterRotation: false); - } + /// The smallest divisor of [_screenToPixel] that [screenOffsetToLatLng] + /// uses above the horizon, where the divisor is not positive. + static const _distantGround = 1e-3; - return point - nonRotatedPixelOrigin; + /// 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); + return fromCenter == null ? null : projectAtZoom(center) + fromCenter; } - /// Calculate the [LatLng] coordinates for a [offset]. - LatLng screenOffsetToLatLng(Offset offset) { - final localPointCenterDistance = - nonRotatedSize.center(Offset.zero) - offset; - final mapCenter = crs.latLngToOffset(center, zoom); - - var point = mapCenter - localPointCenterDistance; - - if (rotation != 0.0) { - point = rotatePoint(mapCenter, point); + /// The offset, in projected pixels at [zoom], from [center] to the ground + /// point under the screen position [offset], or null above the horizon. + @internal + Offset? groundOffsetFromCenter(Offset offset) => + _groundFromCenter(offset, aboveHorizon: null); + + /// 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. + /// + /// 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); + final sinPitch = math.sin(pitchRad); + var divisor = + math.cos(pitchRad) + fromCenter.dy * sinPitch / _cameraDistance; + if (divisor <= 0) { + if (aboveHorizon == null) return null; + divisor = aboveHorizon; } - - return crs.offsetToLatLng(point, zoom); + final y = fromCenter.dy / divisor; + final x = fromCenter.dx * (1 - y * sinPitch / _cameraDistance); + return Offset(x, y).rotate(rotationRad); } /// Sometimes we need to make allowances that a rotation already exists, so /// it needs to be reversed (pointToLatLng), and sometimes we want to use /// the same rotation to create a new position (latLngToScreenpoint). /// counterRotation just makes allowances this for this. + /// + /// This ignores [pitch]; [latLngToScreenOffset] and [screenOffsetToLatLng] + /// convert positions on a pitched map. Offset rotatePoint( Offset mapCenter, Offset point, { @@ -396,10 +541,12 @@ class MapCamera { /// Calculate the [LatLng] coordinates for a given [Offset] and an optional /// 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. LatLng offsetToCrs(Offset offset, [double? zoom]) { final focalStartPt = projectAtZoom(center, zoom ?? this.zoom); - final point = - (offset - nonRotatedSize.center(Offset.zero)).rotate(rotationRad); + final point = _groundFromCenter(offset, aboveHorizon: _distantGround)!; final newCenterPt = focalStartPt + point; final worldWidth = getWorldWidthAtZoom(zoom ?? this.zoom); @@ -417,10 +564,12 @@ 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]. LatLng focusedZoomCenter(Offset cursorPos, double zoom) { - // Calculate offset of mouse cursor from viewport center - final offset = - (cursorPos - nonRotatedSize.center(Offset.zero)).rotate(rotationRad); + final offset = _groundFromCenter(cursorPos, aboveHorizon: null); + if (offset == null) return center; // Match new center coordinate to mouse cursor position final scale = getZoomScale(zoom, this.zoom); final newOffset = offset * (1.0 - 1.0 / scale); @@ -431,7 +580,7 @@ class MapCamera { @override int get hashCode => Object.hash( - crs, minZoom, maxZoom, center, zoom, rotation, nonRotatedSize); + crs, minZoom, maxZoom, center, zoom, rotation, nonRotatedSize, pitch); @override bool operator ==(Object other) => @@ -443,5 +592,6 @@ class MapCamera { other.center == center && other.zoom == zoom && other.rotation == rotation && - other.nonRotatedSize == nonRotatedSize); + other.nonRotatedSize == nonRotatedSize && + other.pitch == pitch); } diff --git a/lib/src/map/controller/map_controller.dart b/lib/src/map/controller/map_controller.dart index 89de1e570..b0d65caca 100644 --- a/lib/src/map/controller/map_controller.dart +++ b/lib/src/map/controller/map_controller.dart @@ -78,6 +78,22 @@ abstract class MapController { /// equal to the current value. bool rotate(double degree, {String? id}); + /// Pitches the map to a decimal [degree], the angle between the camera's + /// view direction and straight down, limited to the range from 0 to + /// [MapOptions.maxPitch] + /// + /// [id] is an internally meaningless attribute, but it is passed through to + /// the emitted [MapEventPitch]. + /// + /// The emitted [MapEventPitch.source] property will be + /// [MapEventSource.mapController]. + /// + /// Returns `true` and emits a [MapEventPitch] event (which can be listed to + /// through [MapEventCallback]s, such as [MapOptions.onMapEvent]), unless + /// the pitch failed because [degree] (after adjustment when necessary) was + /// equal to the current value. + bool pitch(double degree, {String? id}); + /// Rotates the map to a decimal [degree] around a custom screen point, where /// 0° is North /// diff --git a/lib/src/map/controller/map_controller_impl.dart b/lib/src/map/controller/map_controller_impl.dart index 0ed0ed9b9..e6ff00a9e 100644 --- a/lib/src/map/controller/map_controller_impl.dart +++ b/lib/src/map/controller/map_controller_impl.dart @@ -26,6 +26,11 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> late Offset _animationOffset; late Offset _flingMapCenterStartPoint; + /// The ground offsets, in projected pixels, of a one-pixel screen step along + /// x and along y at the viewport center, when the fling started on a pitched + /// map. + (Offset, Offset)? _flingGroundAxes; + /// Constructor of the [MapController] implementation for internal usage. MapControllerImpl({MapOptions? options, TickerProvider? vsync}) : super( @@ -104,6 +109,14 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> id: id, ); + @override + bool pitch(double degree, {String? id}) => pitchRaw( + degree, + hasGesture: false, + source: MapEventSource.mapController, + id: id, + ); + @override MoveAndRotateResult rotateAroundPoint( double degree, { @@ -150,14 +163,13 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> // Algorithm thanks to https://github.com/tlserver/flutter_map_location_marker LatLng center = newCenter; if (offset != Offset.zero) { - final newPoint = camera.projectAtZoom(newCenter, newZoom); - center = camera.unprojectAtZoom( - camera.rotatePoint( - newPoint, - newPoint - offset, - ), - newZoom, + final ground = camera.groundOffsetFromCenter( + camera.nonRotatedSize.center(Offset.zero) + offset, ); + if (ground != null) { + final newPoint = camera.projectAtZoom(newCenter, newZoom); + center = camera.unprojectAtZoom(newPoint - ground, newZoom); + } } MapCamera? newCamera = camera.withPosition( @@ -219,6 +231,37 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> return true; } + /// Internal endpoint to change the pitch of the [MapCamera]. + bool pitchRaw( + double newPitch, { + required bool hasGesture, + required MapEventSource source, + String? id, + }) { + final pitch = options.clampPitch(newPitch); + if (pitch == camera.pitch) return false; + + final newCamera = options.cameraConstraint.constrain( + camera.withPitch(pitch), + ); + if (newCamera == null) return false; + + final oldCamera = camera; + + // Update camera then emit events and callbacks + value = value.withMapCamera(newCamera); + + _emitMapEvent( + MapEventPitch( + id: id, + source: source, + oldCamera: oldCamera, + camera: camera, + ), + ); + return true; + } + /// Internal endpoint to rotate around a point that is not in the center of /// the map. MoveAndRotateResult rotateAroundPointRaw( @@ -244,9 +287,23 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> ); } + final ground = camera.groundOffsetFromCenter( + camera.nonRotatedSize.center(Offset.zero) + offset!, + ); + if (ground == null) { + return ( + moveSuccess: true, + rotateSuccess: rotateRaw( + degree, + hasGesture: hasGesture, + source: source, + id: id, + ), + ); + } + final rotationDiff = degree - camera.rotation; - final rotationCenter = camera.projectAtZoom(camera.center) + - offset!.rotate(camera.rotationRad); + final rotationCenter = camera.projectAtZoom(camera.center) + ground; return ( moveSuccess: moveRaw( @@ -648,6 +705,16 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> _animationHasGesture = hasGesture; _animationOffset = offset; _flingMapCenterStartPoint = camera.projectAtZoom(camera.center); + if (camera.pitch == 0) { + _flingGroundAxes = null; + } else { + final center = camera.nonRotatedSize.center(Offset.zero); + final ground = camera.groundOffsetFromCenter(center)!; + _flingGroundAxes = ( + camera.groundOffsetFromCenter(center + const Offset(1, 0))! - ground, + camera.groundOffsetFromCenter(center + const Offset(0, 1))! - ground, + ); + } final distance = (Offset.zero & camera.nonRotatedSize).shortestSide; @@ -713,8 +780,12 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> void _handleAnimation() { // fling animation if (_flingAnimation != null) { + final value = _flingAnimation!.value; final newCenterPoint = _flingMapCenterStartPoint + - _flingAnimation!.value.rotate(camera.rotationRad); + switch (_flingGroundAxes) { + null => value.rotate(camera.rotationRad), + (final x, final y) => x * value.dx + y * value.dy, + }; moveRaw( camera.unprojectAtZoom(newCenterPoint), camera.zoom, diff --git a/lib/src/map/options/options.dart b/lib/src/map/options/options.dart index 9f8388a34..d92ad8b2c 100644 --- a/lib/src/map/options/options.dart +++ b/lib/src/map/options/options.dart @@ -1,11 +1,14 @@ // ignore_for_file: deprecated_member_use_from_same_package +import 'dart:math' as math; + import 'package:flutter/gestures.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/map/inherited_model.dart'; import 'package:latlong2/latlong.dart'; +import 'package:meta/meta.dart'; /// Callback to notify when the map emits a [MapEvent]. typedef MapEventCallback = void Function(MapEvent); @@ -68,6 +71,36 @@ class MapOptions { /// The rotation when the map is first loaded. final double initialRotation; + /// The pitch, in degrees, when the map is first loaded: the angle between the + /// camera's view direction and straight down. Limited to the range from 0 to + /// [maxPitch]. + final double initialPitch; + + /// The largest pitch, in degrees, that the map allows. Defaults to 0, which + /// keeps the map unpitched. A value above [pitchLimit] takes effect as + /// [pitchLimit]. + /// + /// When the map is pitched: + /// + /// * layers in a [MobileLayerTransformer] lie on the ground, drawn in + /// perspective; [MapCamera.size], [MapCamera.pixelOrigin] and + /// [MapCamera.pixelBounds] describe the rectangle that contains the + /// visible ground, which is not centered on [MapCamera.center] + /// * markers and polygon labels face the viewer, in a + /// [BillboardLayerTransformer] + /// * a layer that computes screen positions itself, instead of drawing at + /// `projectAtZoom(coordinate) - pixelOrigin`, must use + /// [MapCamera.latLngToScreenOffset] and [MapCamera.screenOffsetToLatLng] + /// * [CameraFit] and [CameraConstraint] compute as if the map were not + /// pitched + /// * the tile layer loads up to 7.5 times as many tiles at 60° as when + /// unpitched + final double maxPitch; + + /// The largest [maxPitch] that takes effect. Above it, the visible ground + /// grows without bound. + static const pitchLimit = 60.0; + /// Defines the visible bounds when the map is first loaded. Takes precedence /// over [initialCenter]/[initialZoom]. final CameraFit? initialCameraFit; @@ -161,6 +194,8 @@ class MapOptions { this.initialCenter = const LatLng(50.5, 30.51), this.initialZoom = 13.0, this.initialRotation = 0.0, + this.initialPitch = 0.0, + this.maxPitch = 0.0, this.initialCameraFit, this.cameraConstraint = const CameraConstraint.unconstrained(), this.interactionOptions = const InteractionOptions(), @@ -193,6 +228,11 @@ class MapOptions { (throw StateError( '`MapOptions.of()` should not be called outside a `FlutterMap` and its descendants')); + /// Limits [pitch] to the range from 0 to [maxPitch], and to [pitchLimit]. + @internal + double clampPitch(double pitch) => + pitch.clamp(0, math.min(maxPitch, pitchLimit)); + @override bool operator ==(Object other) => other is MapOptions && @@ -200,6 +240,8 @@ class MapOptions { initialCenter == other.initialCenter && initialZoom == other.initialZoom && initialRotation == other.initialRotation && + initialPitch == other.initialPitch && + maxPitch == other.maxPitch && initialCameraFit == other.initialCameraFit && minZoom == other.minZoom && maxZoom == other.maxZoom && @@ -224,6 +266,8 @@ class MapOptions { initialCenter, initialZoom, initialRotation, + initialPitch, + maxPitch, initialCameraFit, minZoom, maxZoom, diff --git a/test/gestures/drag_fling_test.dart b/test/gestures/drag_fling_test.dart new file mode 100644 index 000000000..49c2d924c --- /dev/null +++ b/test/gestures/drag_fling_test.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +// Camera positions after a drag, a fling and a pinch, recorded before pitch +// support was added, so that changes to gestures for pitch can be shown to +// leave the unpitched map's gestures unchanged. + +Future _pump(WidgetTester tester) async { + final controller = MapController(); + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + height: 600, + child: FlutterMap( + mapController: controller, + options: const MapOptions( + initialCenter: LatLng(45.5, -122.6), + initialZoom: 12, + initialRotation: 30, + ), + children: const [], + ), + ), + ), + )); + return controller; +} + +void _expectCenter(MapController controller, (double, double) expected) { + expect(controller.camera.center.latitude, closeTo(expected.$1, 1e-9)); + expect(controller.camera.center.longitude, closeTo(expected.$2, 1e-9)); +} + +void main() { + testWidgets('drag', (tester) async { + final controller = await _pump(tester); + final gesture = await tester.startGesture(const Offset(200, 100)); + for (int i = 1; i <= 20; i++) { + await gesture.moveTo(Offset(200 + i * 3.0, 100 + i * 20.0)); + await tester.pump(const Duration(milliseconds: 100)); + } + _expectCenter(controller, (45.5760888583345, -122.6865041243764)); + await gesture.up(); + await tester.pumpAndSettle(); + _expectCenter(controller, (45.5760888583345, -122.6865041243764)); + }); + + testWidgets('fling', (tester) async { + final controller = await _pump(tester); + await tester.flingFrom(const Offset(200, 100), const Offset(60, 400), 1500); + await tester.pumpAndSettle(); + _expectCenter(controller, (45.648197134706926, -122.76859103266955)); + }); + + testWidgets('pinch zoom and move', (tester) async { + final controller = await _pump(tester); + final a = await tester.startGesture(const Offset(150, 300), pointer: 1); + final b = await tester.startGesture(const Offset(250, 300), pointer: 2); + for (int i = 1; i <= 10; i++) { + await a.moveTo(Offset(150 - i * 5.0, 300 + i * 10.0)); + await b.moveTo(Offset(250 + i * 5.0, 300 + i * 10.0)); + await tester.pump(const Duration(milliseconds: 100)); + } + _expectCenter(controller, (45.5087058730637, -122.60766726630374)); + expect(controller.camera.zoom, 13); + await a.up(); + await b.up(); + await tester.pumpAndSettle(); + }); +} diff --git a/test/gestures/pitched_gestures_test.dart b/test/gestures/pitched_gestures_test.dart new file mode 100644 index 000000000..2a4db5953 --- /dev/null +++ b/test/gestures/pitched_gestures_test.dart @@ -0,0 +1,158 @@ +import 'dart:math'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +Future _pump( + WidgetTester tester, { + required double pitch, + double rotation = 30, +}) async { + final controller = MapController(); + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + height: 600, + child: FlutterMap( + mapController: controller, + options: MapOptions( + initialCenter: const LatLng(45.5, -122.6), + initialZoom: 12, + initialRotation: rotation, + initialPitch: pitch, + maxPitch: 60, + ), + children: const [], + ), + ), + ), + )); + return controller; +} + +/// The distance, in projected pixels at the camera zoom, between the +/// coordinate under a focal point near the bottom of the map before a 45 +/// degree two-finger rotation and the ground point under it afterwards. +Future _rotationDrift( + WidgetTester tester, + double pitch, + double degreesPerFrame, +) async { + final controller = await _pump(tester, pitch: pitch, rotation: 0); + const focal = Offset(200, 520); + final coordinate = controller.camera.screenOffsetToLatLng(focal); + final a = await tester.startGesture(focal + const Offset(-60, 0), pointer: 1); + final b = await tester.startGesture(focal + const Offset(60, 0), pointer: 2); + final frames = (45 / degreesPerFrame).round(); + for (int i = 1; i <= frames; i++) { + final angle = i * degreesPerFrame * pi / 180; + final arm = Offset(cos(angle), sin(angle)) * 60; + await a.moveTo(focal - arm); + await b.moveTo(focal + arm); + await tester.pump(const Duration(milliseconds: 16)); + } + final camera = controller.camera; + expect(camera.rotation.abs(), greaterThan(10)); + final drift = (camera.projectAtZoom(coordinate) - + camera.projectAtZoom(camera.screenOffsetToLatLng(focal))) + .distance; + await a.up(); + await b.up(); + await tester.pumpAndSettle(); + return drift; +} + +double _distanceOnScreen( + MapController controller, LatLng coordinate, Offset position) => + (controller.camera.latLngToScreenOffset(coordinate) - position).distance; + +void main() { + testWidgets('drag keeps the coordinate under the pointer', (tester) async { + final controller = await _pump(tester, pitch: 60); + final gesture = await tester.startGesture(const Offset(200, 40)); + // Past the touch slop, so that the drag has started. + await gesture.moveTo(const Offset(200, 70)); + await tester.pump(const Duration(milliseconds: 100)); + await gesture.moveTo(const Offset(201, 72)); + await tester.pump(const Duration(milliseconds: 100)); + final coordinate = + controller.camera.screenOffsetToLatLng(const Offset(201, 72)); + for (int i = 1; i <= 20; i++) { + await gesture.moveTo(Offset(201 + i * 2.0, 72 + i * 22.0)); + await tester.pump(const Duration(milliseconds: 100)); + } + expect( + _distanceOnScreen(controller, coordinate, const Offset(241, 512)), + lessThan(0.5), + ); + await gesture.up(); + await tester.pumpAndSettle(); + }); + + testWidgets('fling continues in the direction of the ground movement', + (tester) async { + final controller = await _pump(tester, pitch: 50); + final initial = controller.camera; + const start = Offset(150, 100); + const drag = Offset(40, 300); + // A straight screen line is a straight ground line, so the ground under + // the drag moves along the ground shift from its start to its end. + final dragShift = + initial.projectAtZoom(initial.screenOffsetToLatLng(start)) - + initial.projectAtZoom(initial.screenOffsetToLatLng(start + drag)); + await tester.flingFrom(start, drag, 1500); + await tester.pumpAndSettle(); + final moved = controller.camera.projectAtZoom(controller.camera.center) - + initial.projectAtZoom(initial.center); + final cosine = (dragShift.dx * moved.dx + dragShift.dy * moved.dy) / + (dragShift.distance * moved.distance); + expect(cosine, greaterThan(0.999)); + expect(moved.distance, greaterThan(dragShift.distance * 1.1)); + }); + + testWidgets('scroll-wheel zoom keeps the coordinate under the cursor', + (tester) async { + final controller = await _pump(tester, pitch: 50); + const cursor = Offset(300, 40); + final coordinate = controller.camera.screenOffsetToLatLng(cursor); + await tester.sendEventToBinding( + const PointerScrollEvent(position: cursor, scrollDelta: Offset(0, -100)), + ); + await tester.pumpAndSettle(); + expect(controller.camera.zoom, greaterThan(12)); + expect(_distanceOnScreen(controller, coordinate, cursor), 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]) { + testWidgets( + 'two-finger rotation at $degreesPerFrame degrees per frame drifts ' + 'as much as unpitched', (tester) async { + final unpitched = await _rotationDrift(tester, 0, degreesPerFrame); + final pitched = await _rotationDrift(tester, 50, degreesPerFrame); + expect(pitched, closeTo(unpitched, 0.05)); + }); + } + + testWidgets('flingAnimatedRaw moves along the screen direction', + (tester) async { + final controller = await _pump(tester, pitch: 50); + final initial = controller.camera; + (controller as MapControllerImpl).flingAnimatedRaw( + velocity: 1, + direction: const Offset(0, 1), + begin: Offset.zero, + hasGesture: false, + ); + await tester.pumpAndSettle(); + final screen = initial.latLngToScreenOffset(controller.camera.center); + expect(screen.dx, closeTo(200, 0.5)); + expect(screen.dy, lessThan(300)); + }); +} diff --git a/test/layer/pitched_layers_test.dart b/test/layer/pitched_layers_test.dart new file mode 100644 index 000000000..f4f7c37a1 --- /dev/null +++ b/test/layer/pitched_layers_test.dart @@ -0,0 +1,464 @@ +import 'dart:math'; +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_map/src/layer/tile_layer/tile.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +import '../test_utils/test_tile_provider.dart'; + +Future _pump( + WidgetTester tester, { + required double pitch, + required double rotation, + required List children, + LatLng center = const LatLng(45.5, -122.6), + double zoom = 12, + TapCallback? onTap, +}) async { + final controller = MapController(); + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + height: 300, + child: FlutterMap( + mapController: controller, + options: MapOptions( + initialCenter: center, + initialZoom: zoom, + initialRotation: rotation, + initialPitch: pitch, + maxPitch: 60, + onTap: onTap, + ), + children: children, + ), + ), + ), + )); + return controller; +} + +/// The screen positions of the start and end of each path that the +/// [CustomPaint] under [layer] draws. +List<(Offset, Offset)> _pathEnds(WidgetTester tester, Finder layer) { + final box = tester.renderObject( + find.descendant(of: layer, matching: find.byType(CustomPaint)), + ); + final canvas = TestRecordingCanvas(); + box.painter!.paint(canvas, box.size); + final toScreen = box.getTransformTo(null); + final saved = []; + var transform = Matrix4.identity(); + final ends = <(Offset, Offset)>[]; + 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.translateByDouble(args[0] as double, args[1] as double, 0, 1); + case #transform: + transform.multiply(Matrix4.fromFloat64List(args[0] as Float64List)); + case #drawPath: + final matrix = toScreen.multiplied(transform); + for (final metric in (args[0] as ui.Path).computeMetrics()) { + ends.add(( + MatrixUtils.transformPoint( + matrix, metric.getTangentForOffset(0)!.position), + MatrixUtils.transformPoint( + matrix, metric.getTangentForOffset(metric.length)!.position), + )); + } + } + } + return ends; +} + +/// The screen corners of each [Tile] that the map shows, with its tile +/// coordinates. +Future)>> _tileQuads( + WidgetTester tester) async { + await tester.runAsync(() => Future.delayed( + const Duration(milliseconds: 100), + )); + await tester.pumpAndSettle(); + return [ + for (final element in find.byType(Tile).evaluate()) + ( + (element.widget as Tile).tileImage.coordinates, + () { + final box = tester.renderObject(find.descendant( + of: find.byWidget(element.widget), + matching: find.byType(RawImage), + )); + final toScreen = box.getTransformTo(null); + return [ + Offset.zero, + Offset(box.size.width, 0), + Offset(box.size.width, box.size.height), + Offset(0, box.size.height), + ].map((c) => MatrixUtils.transformPoint(toScreen, c)).toList(); + }(), + ), + ]; +} + +/// The screen center, angle and screen width, and the width, of each +/// paragraph that the [CustomPaint] [paint] draws. +List<(Offset, double, double, double)> _paragraphs( + WidgetTester tester, Finder paint) { + final box = tester.renderObject(paint); + final canvas = TestRecordingCanvas(); + box.painter!.paint(canvas, box.size); + final toScreen = box.getTransformTo(null); + final saved = []; + var transform = Matrix4.identity(); + final paragraphs = <(Offset, double, double, double)>[]; + 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.translateByDouble(args[0] as double, args[1] as double, 0, 1); + case #rotate: + transform.rotateZ(args[0] as double); + case #drawParagraph: + final paragraph = args[0] as ui.Paragraph; + final offset = args[1] as Offset; + final matrix = toScreen.multiplied(transform); + final center = MatrixUtils.transformPoint( + matrix, offset + Offset(paragraph.width / 2, paragraph.height / 2)); + final right = MatrixUtils.transformPoint( + matrix, offset + Offset(paragraph.width, paragraph.height / 2)); + final half = right - center; + paragraphs.add(( + center, + atan2(half.dy, half.dx), + 2 * half.distance, + paragraph.width, + )); + } + } + return paragraphs; +} + +bool _insideConvex(List quad, Offset point) { + var sign = 0; + for (int i = 0; i < quad.length; i++) { + final a = quad[i]; + final b = quad[(i + 1) % quad.length]; + final cross = + (b.dx - a.dx) * (point.dy - a.dy) - (b.dy - a.dy) * (point.dx - a.dx); + if (cross == 0) continue; + final s = cross.sign.toInt(); + if (sign == 0) { + sign = s; + } else if (s != sign) { + return false; + } + } + return true; +} + +void main() { + for (final (pitch, rotation) in [(50.0, 20.0), (0.0, 20.0)]) { + testWidgets('polyline vertices, pitch $pitch, rotation $rotation', + (tester) async { + const points = [ + LatLng(45.53, -122.64), + LatLng(45.50, -122.60), + LatLng(45.47, -122.55), + ]; + final controller = await _pump( + tester, + pitch: pitch, + rotation: rotation, + children: [ + PolylineLayer(polylines: [ + Polyline(points: points, strokeWidth: 2, color: Colors.red), + ]), + ], + ); + final ends = _pathEnds(tester, find.byType(PolylineLayer)); + expect(ends, hasLength(1)); + final (start, end) = ends.single; + final camera = controller.camera; + expect( + (start - camera.latLngToScreenOffset(points.first)).distance, + lessThan(0.01), + ); + expect( + (end - camera.latLngToScreenOffset(points.last)).distance, + lessThan(0.01), + ); + }); + } + + for (final rotation in [0.0, 45.0]) { + testWidgets('tiles cover the viewport, rotation $rotation', (tester) async { + await _pump( + tester, + pitch: 60, + rotation: rotation, + children: [ + TileLayer( + tileProvider: TestTileProvider(), + tileDisplay: const TileDisplay.instantaneous(), + ), + ], + ); + final quads = await _tileQuads(tester); + expect(quads, isNotEmpty); + for (double x = 0.5; x <= 400; x += 399 / 8) { + for (double y = 0.5; y <= 300; y += 299 / 6) { + expect( + quads.any((q) => _insideConvex(q.$2, Offset(x, y))), + isTrue, + reason: 'no tile at ($x, $y)', + ); + } + } + }); + } + + testWidgets('tile under the top edge is loaded', (tester) async { + final controller = await _pump( + tester, + pitch: 60, + rotation: 0, + children: [ + TileLayer( + tileProvider: TestTileProvider(), + tileDisplay: const TileDisplay.instantaneous(), + ), + ], + ); + final camera = controller.camera; + final top = camera.projectAtZoom( + camera.screenOffsetToLatLng(const Offset(200, 1)), 12); + final expected = + TileCoordinates((top.dx / 256).floor(), (top.dy / 256).floor(), 12); + final quads = await _tileQuads(tester); + expect(quads.map((q) => q.$1), contains(expected)); + }); + + for (final rotate in [true, false]) { + testWidgets('marker position, size and rotation, rotate $rotate', + (tester) async { + const point = LatLng(45.535, -122.6); + final controller = await _pump( + tester, + pitch: 60, + rotation: 30, + children: [ + MarkerLayer( + rotate: rotate, + markers: const [ + Marker( + point: point, + width: 48, + height: 48, + alignment: Alignment.topCenter, + child: SizedBox.expand(key: ValueKey('marker')), + ), + ], + ), + ], + ); + final camera = controller.camera; + final anchor = camera.latLngToScreenOffset(point); + expect(anchor.dy, lessThan(100), reason: 'near the top of the view'); + + // With topCenter alignment, the point is at the middle of the bottom + // edge, and the marker rotates with the map about it unless upright. + final angle = rotate ? 0.0 : camera.rotationRad; + Offset corner(double x, double y) => + anchor + + Offset( + x * cos(angle) - y * sin(angle), x * sin(angle) + y * cos(angle)); + final marker = find.byKey(const ValueKey('marker')); + for (final (actual, expected) in [ + (tester.getTopLeft(marker), corner(-24, -48)), + (tester.getTopRight(marker), corner(24, -48)), + (tester.getBottomRight(marker), corner(24, 0)), + (tester.getBottomLeft(marker), corner(-24, 0)), + ]) { + expect((actual - expected).distance, lessThan(0.01)); + } + }); + } + + testWidgets('marker behind the camera is not built', (tester) async { + // In a 300 px high view at pitch 60, ground points more than 520 px south + // of the center are behind the camera. Projected anyway, a point far + // behind lands just above the view, where a tall marker reaches into it. + const center = LatLng(45.5, -122.6); + const crs = Epsg3857(); + final behind = crs.offsetToLatLng( + crs.latLngToOffset(center, 12) + const Offset(0, 10000), 12); + await _pump( + tester, + pitch: 60, + rotation: 0, + children: [ + MarkerLayer( + markers: [ + Marker( + point: behind, + width: 20, + height: 400, + child: const SizedBox.expand(key: ValueKey('behind')), + ), + ], + ), + ], + ); + expect(find.byKey(const ValueKey('behind')), findsNothing); + }); + + testWidgets('polygon labels face the viewer', (tester) async { + Polygon square(double lat, double lng, String label, bool rotateLabel) => + Polygon( + points: [ + LatLng(lat + 0.02, lng - 0.03), + LatLng(lat + 0.02, lng + 0.03), + LatLng(lat - 0.02, lng + 0.03), + LatLng(lat - 0.02, lng - 0.03), + ], + color: Colors.green, + label: label, + rotateLabel: rotateLabel, + ); + final polygons = [ + square(45.51, -122.62, 'A', false), + square(45.49, -122.57, 'B', true), + ]; + final controller = await _pump( + tester, + pitch: 45, + rotation: 30, + children: [PolygonLayer(polygons: polygons)], + ); + final camera = controller.camera; + final paints = find.descendant( + of: find.byType(PolygonLayer), matching: find.byType(CustomPaint)); + final ground = find.descendant( + of: find.byType(MobileLayerTransformer), matching: paints); + final billboard = find.descendant( + of: find.byType(BillboardLayerTransformer), matching: paints); + expect(_paragraphs(tester, ground), isEmpty); + + final labels = _paragraphs(tester, billboard); + expect(labels, hasLength(2)); + for (final (i, (center, angle, screenWidth, width)) in labels.indexed) { + final expected = camera.latLngToScreenOffset(polygons[i].labelPosition); + expect((center - expected).distance, lessThan(0.01)); + expect(screenWidth, closeTo(width, 0.01)); + expect(angle, + closeTo(polygons[i].rotateLabel ? 0 : camera.rotationRad, 1e-9)); + } + }); + + testWidgets('tiles on both sides of the antimeridian', (tester) async { + // With rotation -90, the far edge of the view is east of the center, + // across the antimeridian. + await _pump( + tester, + pitch: 60, + rotation: -90, + center: const LatLng(0, 179), + zoom: 9, + children: [ + TileLayer( + tileProvider: TestTileProvider(), + tileDisplay: const TileDisplay.instantaneous(), + ), + ], + ); + final quads = await _tileQuads(tester); + final columns = quads.map((q) => q.$1.x).toSet(); + expect(columns, contains(511), reason: 'west of the antimeridian'); + expect(columns.any((x) => x > 511 || x <= 1), isTrue, + reason: 'east of the antimeridian'); + for (double x = 0.5; x <= 400; x += 399 / 8) { + for (double y = 0.5; y <= 300; y += 299 / 6) { + expect( + quads.any((q) => _insideConvex(q.$2, Offset(x, y))), + isTrue, + reason: 'no tile at ($x, $y)', + ); + } + } + }); + + testWidgets('tap coordinate', (tester) async { + final taps = []; + final controller = await _pump( + tester, + pitch: 60, + rotation: 30, + children: const [], + onTap: (_, point) => taps.add(point), + ); + for (final position in const [Offset(350, 10), Offset(40, 280)]) { + await tester.tapAt(position); + await tester.pump(const Duration(milliseconds: 500)); + final expected = controller.camera.screenOffsetToLatLng(position); + expect(taps.last.latitude, closeTo(expected.latitude, 1e-9)); + expect(taps.last.longitude, closeTo(expected.longitude, 1e-9)); + } + }); + + testWidgets('polygon hit near the top of the view', (tester) async { + final hitNotifier = LayerHitNotifier(null); + final hits = ?>[]; + final controller = await _pump( + tester, + pitch: 60, + rotation: 30, + children: [ + GestureDetector( + onTap: () => hits.add(hitNotifier.value), + child: PolygonLayer( + hitNotifier: hitNotifier, + polygons: [ + for (final (value, lng) in [('west', -122.63), ('east', -122.6)]) + Polygon( + points: [ + LatLng(45.552, lng - 0.012), + LatLng(45.552, lng + 0.012), + LatLng(45.544, lng + 0.012), + LatLng(45.544, lng - 0.012), + ], + label: value, + hitValue: value, + ), + ], + ), + ), + ], + ); + for (final (value, lng) in [('west', -122.63), ('east', -122.6)]) { + final position = + controller.camera.latLngToScreenOffset(LatLng(45.548, lng)); + expect(position.dy, lessThan(100), reason: 'near the top of the view'); + await tester.tapAt(position); + await tester.pump(const Duration(milliseconds: 500)); + expect(hits.last?.hitValues, [value]); + expect(hits.last!.coordinate.latitude, closeTo(45.548, 1e-7)); + expect(hits.last!.coordinate.longitude, closeTo(lng, 1e-7)); + } + }); +} diff --git a/test/layer/screen_geometry_test.dart b/test/layer/screen_geometry_test.dart new file mode 100644 index 000000000..f5875c6fc --- /dev/null +++ b/test/layer/screen_geometry_test.dart @@ -0,0 +1,311 @@ +import 'dart:math'; +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'; + +// Screen positions of markers and polygon labels, recorded before pitch +// support was added, so that layer changes for pitch can be shown to draw at +// the same positions when the map is not pitched. + +const _center = LatLng(45.5, -122.6); + +Widget _map({ + required double rotation, + required List children, + double zoom = 10, + LatLng center = _center, +}) => + MaterialApp( + home: Center( + child: SizedBox( + width: 400, + height: 300, + child: FlutterMap( + options: MapOptions( + initialCenter: center, + initialZoom: zoom, + initialRotation: rotation, + ), + children: children, + ), + ), + ), + ); + +const _markers = [ + Marker( + point: _center, + width: 40, + height: 20, + child: SizedBox.expand(key: ValueKey('center')), + ), + Marker( + point: LatLng(45.52, -122.55), + alignment: Alignment.topCenter, + child: SizedBox.expand(key: ValueKey('topCenter')), + ), + Marker( + point: LatLng(45.47, -122.66), + width: 50, + height: 16, + alignment: Alignment.bottomRight, + rotate: true, + child: SizedBox.expand(key: ValueKey('bottomRightRotate')), + ), + Marker( + point: LatLng(45.49, -122.58), + width: 2000, + height: 1000, + useDimensionsInMeters: BoxConstraints(), + child: SizedBox.expand(key: ValueKey('meters')), + ), +]; + +double _round(double v) => (v * 100).round() / 100; + +/// The screen corners (top left, top right, bottom right, bottom left) of each +/// widget with [key], as a flat list per widget. +List> _corners(WidgetTester tester, String key) { + final finder = find.byKey(ValueKey(key)); + return [ + for (int i = 0; i < finder.evaluate().length; i++) + [ + for (final corner in [ + tester.getTopLeft(finder.at(i)), + tester.getTopRight(finder.at(i)), + tester.getBottomRight(finder.at(i)), + tester.getBottomLeft(finder.at(i)), + ]) ...[_round(corner.dx), _round(corner.dy)], + ], + ]; +} + +/// The order in which the polygon layer draws polygons (`P`) and labels +/// (`L`), and the screen position and angle of the center of each label. +({String order, List> labels}) _polygonPaint( + WidgetTester tester, +) { + final box = tester.renderObject( + find.descendant( + of: find.byType(PolygonLayer), + matching: find.byType(CustomPaint), + ), + ); + final canvas = TestRecordingCanvas(); + box.painter!.paint(canvas, box.size); + + final toScreen = box.getTransformTo(null); + final saved = []; + var transform = Matrix4.identity(); + final order = StringBuffer(); + final labels = >[]; + 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.translateByDouble(args[0] as double, args[1] as double, 0, 1); + case #rotate: + transform.rotateZ(args[0] as double); + case #transform: + transform.multiply(Matrix4.fromFloat64List(args[0] as Float64List)); + case #drawPath || #drawVertices: + order.write('P'); + case #drawParagraph: + order.write('L'); + final paragraph = args[0] as ui.Paragraph; + final offset = args[1] as Offset; + final matrix = toScreen.multiplied(transform); + final center = MatrixUtils.transformPoint( + matrix, + offset + Offset(paragraph.width / 2, paragraph.height / 2), + ); + final right = MatrixUtils.transformPoint( + matrix, + offset + Offset(paragraph.width, paragraph.height / 2), + ); + final direction = right - center; + labels.add([ + _round(center.dx), + _round(center.dy), + _round(atan2(direction.dy, direction.dx)), + ]); + } + } + return (order: order.toString(), labels: labels); +} + +List _polygons() => [ + Polygon( + points: const [ + LatLng(45.56, -122.70), + LatLng(45.56, -122.58), + LatLng(45.50, -122.58), + LatLng(45.50, -122.70), + ], + color: const Color(0xFFFF0000), + label: 'A', + ), + Polygon( + points: const [ + LatLng(45.53, -122.62), + LatLng(45.53, -122.50), + LatLng(45.46, -122.50), + LatLng(45.46, -122.62), + ], + color: const Color(0xFF00FF00), + label: 'B', + rotateLabel: true, + ), + Polygon( + points: const [ + LatLng(45.48, -122.72), + LatLng(45.48, -122.60), + LatLng(45.42, -122.60), + LatLng(45.42, -122.72), + ], + color: const Color(0xFF0000FF), + label: 'C', + ), + ]; + +void _expectClose(List> actual, List> expected) { + expect(actual.length, expected.length); + for (int i = 0; i < actual.length; i++) { + expect(actual[i].length, expected[i].length); + for (int j = 0; j < actual[i].length; j++) { + expect(actual[i][j], closeTo(expected[i][j], 0.01), reason: '[$i][$j]'); + } + } +} + +const _uprightMarkers = { + 'center': [ + [380.0, 290.0, 420.0, 290.0, 420.0, 310.0, 380.0, 310.0], + ], + 'topCenter': [ + [421.41, 249.22, 451.41, 249.22, 451.41, 279.22, 421.41, 279.22], + ], + 'bottomRightRotate': [ + [356.31, 331.16, 406.31, 331.16, 406.31, 347.16, 356.31, 347.16], + ], + 'meters': [ + [405.25, 305.72, 423.88, 305.72, 423.88, 315.06, 405.25, 315.06], + ], +}; + +final _expectedMarkers = { + (0.0, false): _uprightMarkers, + (0.0, true): _uprightMarkers, + (30.0, false): { + 'center': [ + [387.68, 281.34, 422.32, 301.34, 412.32, 318.66, 377.68, 298.66], + ], + 'topCenter': [ + [443.93, 266.73, 469.91, 281.73, 454.91, 307.71, 428.93, 292.71], + ], + 'bottomRightRotate': [ + [346.58, 305.14, 396.58, 305.14, 396.58, 321.14, 346.58, 321.14], + ], + 'meters': [ + [401.69, 307.57, 417.82, 316.89, 413.15, 324.98, 397.01, 315.67], + ], + }, + (30.0, true): { + 'center': [ + [380.0, 290.0, 420.0, 290.0, 420.0, 310.0, 380.0, 310.0], + ], + 'topCenter': [ + [426.92, 270.21, 456.92, 270.21, 456.92, 300.21, 426.92, 300.21], + ], + 'bottomRightRotate': [ + [346.58, 305.14, 396.58, 305.14, 396.58, 321.14, 346.58, 321.14], + ], + 'meters': [ + [398.1, 311.61, 416.73, 311.61, 416.73, 320.95, 398.1, 320.95], + ], + }, +}; + +final _expectedLabels = { + 0.0: [ + [370.87, 268.82, 0.0], + [429.13, 305.19, 0.0], + [356.31, 351.92, 0.0], + ], + 30.0: [ + [390.36, 258.44, 0.52], + [422.63, 319.06, 0.0], + [336.2, 323.12, 0.52], + ], +}; + +void main() { + for (final MapEntry(key: (rotation, layerRotate), value: expected) + in _expectedMarkers.entries) { + testWidgets('marker corners, rotation $rotation, layer rotate $layerRotate', + (tester) async { + await tester.pumpWidget(_map( + rotation: rotation, + children: [MarkerLayer(markers: _markers, rotate: layerRotate)], + )); + for (final MapEntry(:key, :value) in expected.entries) { + _expectClose(_corners(tester, key), value); + } + }); + } + + testWidgets('marker corners in repeated worlds', (tester) async { + await tester.pumpWidget(_map( + rotation: 0, + zoom: 0, + center: const LatLng(0, 0), + children: const [ + MarkerLayer( + markers: [ + Marker( + point: LatLng(10, -170), + width: 20, + height: 10, + child: SizedBox.expand(key: ValueKey('wrapped')), + ), + ], + ), + ], + )); + _expectClose(_corners(tester, 'wrapped'), [ + [269.11, 287.85, 289.11, 287.85, 289.11, 297.85, 269.11, 297.85], + [525.11, 287.85, 545.11, 287.85, 545.11, 297.85, 525.11, 297.85], + ]); + }); + + for (final MapEntry(key: rotation, value: expected) + in _expectedLabels.entries) { + for (final (drawLabelsLast, order) in [ + (false, 'PLPLPL'), + (true, 'PPPLLL') + ]) { + testWidgets( + 'polygon labels, rotation $rotation, drawLabelsLast $drawLabelsLast', + (tester) async { + await tester.pumpWidget(_map( + rotation: rotation, + children: [ + PolygonLayer(polygons: _polygons(), drawLabelsLast: drawLabelsLast), + ], + )); + final actual = _polygonPaint(tester); + expect(actual.order, order); + _expectClose(actual.labels, expected); + }); + } + } +} diff --git a/test/map/api_compatibility_test.dart b/test/map/api_compatibility_test.dart new file mode 100644 index 000000000..6b1b0f42b --- /dev/null +++ b/test/map/api_compatibility_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; + +// Application code that must keep compiling as the public API grows: adding a +// MapEventSource value or an abstract member to CameraConstraint or CameraFit +// breaks it. + +String _describe(MapEventSource source) => switch (source) { + MapEventSource.mapController => 'mapController', + MapEventSource.tap => 'tap', + MapEventSource.secondaryTap => 'secondaryTap', + MapEventSource.longPress => 'longPress', + MapEventSource.doubleTap => 'doubleTap', + MapEventSource.doubleTapHold => 'doubleTapHold', + MapEventSource.dragStart => 'dragStart', + MapEventSource.onDrag => 'onDrag', + MapEventSource.dragEnd => 'dragEnd', + MapEventSource.multiFingerGestureStart => 'multiFingerGestureStart', + MapEventSource.onMultiFinger => 'onMultiFinger', + MapEventSource.multiFingerEnd => 'multiFingerEnd', + MapEventSource.flingAnimationController => 'flingAnimationController', + MapEventSource.doubleTapZoomAnimationController => + 'doubleTapZoomAnimationController', + MapEventSource.interactiveFlagsChanged => 'interactiveFlagsChanged', + MapEventSource.fitCamera => 'fitCamera', + MapEventSource.custom => 'custom', + MapEventSource.scrollWheel => 'scrollWheel', + MapEventSource.nonRotatedSizeChange => 'nonRotatedSizeChange', + MapEventSource.cursorKeyboardRotation => 'cursorKeyboardRotation', + MapEventSource.keyboard => 'keyboard', + }; + +class _CustomConstraint extends CameraConstraint { + const _CustomConstraint(); + + @override + MapCamera? constrain(MapCamera camera) => camera; +} + +class _CustomFit extends CameraFit { + const _CustomFit(); + + @override + MapCamera fit(MapCamera camera) => camera; +} + +void main() { + test('exhaustive switch on MapEventSource', () { + for (final source in MapEventSource.values) { + expect(_describe(source), source.name); + } + }); + + test('custom CameraConstraint and CameraFit', () { + const options = MapOptions( + cameraConstraint: _CustomConstraint(), + initialCameraFit: _CustomFit(), + ); + final camera = MapCamera.initialCamera(options); + expect(options.cameraConstraint.constrain(camera), camera); + expect(options.initialCameraFit!.fit(camera), camera); + }); +} diff --git a/test/map/camera/pitch_reference_data.dart b/test/map/camera/pitch_reference_data.dart new file mode 100644 index 000000000..6f9c426c0 --- /dev/null +++ b/test/map/camera/pitch_reference_data.dart @@ -0,0 +1,3889 @@ +// Generated from MapLibre Native through maplibre-native-ffi 7698f75d9. +// Each camera's points are a 5 x 5 grid of viewport positions and the +// coordinates MapLibre Native shows there, with the MapLibre zoom one less +// than the flutter_map zoom and the bearing the negated rotation. + +typedef ReferencePoint = ({ + double latitude, + double longitude, + double x, + double y, +}); + +typedef ReferenceCamera = ({ + double width, + double height, + double latitude, + double longitude, + double zoom, + double rotation, + double pitch, + List points, +}); + +const referenceCameras = [ + ( + width: 400, + height: 300, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 0, + pitch: 0, + points: [ + ( + latitude: 45.644197741350069, + longitude: -122.87465820043801, + x: 3.0630164676242405e-10, + y: 2.2848212211101782e-09, + ), + ( + latitude: 45.644197741350069, + longitude: -122.73732910021968, + x: 99.999999999652303, + y: 2.2848212211101782e-09, + ), + ( + latitude: 45.644197741350069, + longitude: -122.5999999999991, + x: 200.00000000065398, + y: 2.2848212211101782e-09, + ), + ( + latitude: 45.644197741350069, + longitude: -122.46267089978076, + x: 300.00000000002484, + y: 2.2848212211101782e-09, + ), + ( + latitude: 45.644197741350069, + longitude: -122.32534179956019, + x: 400.00000000101824, + y: 2.2848212211101782e-09, + ), + ( + latitude: 45.572145149061072, + longitude: -122.87465820043801, + x: 3.0630164676242405e-10, + y: 75.000000001407329, + ), + ( + latitude: 45.572145149061072, + longitude: -122.73732910021968, + x: 99.999999999652303, + y: 75.000000001407329, + ), + ( + latitude: 45.572145149061072, + longitude: -122.5999999999991, + x: 200.00000000065398, + y: 75.000000001407329, + ), + ( + latitude: 45.572145149061072, + longitude: -122.46267089978076, + x: 300.00000000002484, + y: 75.000000001407329, + ), + ( + latitude: 45.572145149061072, + longitude: -122.32534179956019, + x: 400.00000000101824, + y: 75.000000001407329, + ), + ( + latitude: 45.499999999996362, + longitude: -122.87465820043801, + x: 3.0630164676242405e-10, + y: 150.00000000380808, + ), + ( + latitude: 45.499999999996362, + longitude: -122.73732910021968, + x: 99.999999999652303, + y: 150.00000000380808, + ), + ( + latitude: 45.499999999996362, + longitude: -122.5999999999991, + x: 200.00000000065398, + y: 150.00000000380808, + ), + ( + latitude: 45.499999999996362, + longitude: -122.46267089978076, + x: 300.00000000002484, + y: 150.00000000380808, + ), + ( + latitude: 45.499999999996362, + longitude: -122.32534179956019, + x: 400.00000000101824, + y: 150.00000000380808, + ), + ( + latitude: 45.427762289800114, + longitude: -122.87465820043801, + x: 3.0630164676242405e-10, + y: 225.00000000293056, + ), + ( + latitude: 45.427762289800114, + longitude: -122.73732910021968, + x: 99.999999999652303, + y: 225.00000000293056, + ), + ( + latitude: 45.427762289800114, + longitude: -122.5999999999991, + x: 200.00000000065398, + y: 225.00000000293056, + ), + ( + latitude: 45.427762289800114, + longitude: -122.46267089978076, + x: 300.00000000002484, + y: 225.00000000293056, + ), + ( + latitude: 45.427762289800114, + longitude: -122.32534179956019, + x: 400.00000000101824, + y: 225.00000000293056, + ), + ( + latitude: 45.355432014689683, + longitude: -122.87465820043801, + x: 3.0630164676242405e-10, + y: 300.00000000201993, + ), + ( + latitude: 45.355432014689683, + longitude: -122.73732910021968, + x: 99.999999999652303, + y: 300.00000000201993, + ), + ( + latitude: 45.355432014689683, + longitude: -122.5999999999991, + x: 200.00000000065398, + y: 300.00000000201993, + ), + ( + latitude: 45.355432014689683, + longitude: -122.46267089978076, + x: 300.00000000002484, + y: 300.00000000201993, + ), + ( + latitude: 45.355432014689683, + longitude: -122.32534179956019, + x: 400.00000000101824, + y: 300.00000000201993, + ), + ], + ), + ( + width: 400, + height: 300, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 30, + pitch: 0, + points: [ + ( + latitude: 45.528776626771304, + longitude: -122.94085780410029, + x: 1.3245476616753473e-09, + y: -9.936229616869241e-11, + ), + ( + latitude: 45.576859073888187, + longitude: -122.82192731463087, + x: 100.0000000020634, + y: -3.1457147997571155e-10, + ), + ( + latitude: 45.624900384785462, + longitude: -122.70299682516371, + x: 200.00000000132661, + y: -1.258342763321707e-09, + ), + ( + latitude: 45.672900560910279, + longitude: -122.5840663356943, + x: 300.00000000203028, + y: -1.4404690773517359e-09, + ), + ( + latitude: 45.720859603820827, + longitude: -122.46513584622491, + x: 400.00000000109276, + y: 1.225203050125856e-09, + ), + ( + latitude: 45.466254266135394, + longitude: -122.88935939151868, + x: 9.6029705471462672e-10, + y: 74.999999999668859, + ), + ( + latitude: 45.51439015274164, + longitude: -122.77042890204929, + x: 100.00000000168259, + y: 74.999999999453621, + ), + ( + latitude: 45.56248490141968, + longitude: -122.65149841257988, + x: 200.00000000238211, + y: 74.999999999288036, + ), + ( + latitude: 45.610538513467645, + longitude: -122.53256792311272, + x: 300.00000000163914, + y: 74.999999998327752, + ), + ( + latitude: 45.658550990295282, + longitude: -122.41363743364332, + x: 400.00000000070986, + y: 75.000000000993396, + ), + ( + latitude: 45.403662483260234, + longitude: -122.83786097893484, + x: 2.0282136069403753e-09, + y: 150.00000000021524, + ), + ( + latitude: 45.451851811352384, + longitude: -122.71893048946768, + x: 100.0000000012935, + y: 149.99999999925495, + ), + ( + latitude: 45.500000000001734, + longitude: -122.59999999999829, + x: 200.00000000198267, + y: 149.99999999907283, + ), + ( + latitude: 45.548107050357089, + longitude: -122.48106951052887, + x: 300.0000000027029, + y: 149.99999999890724, + ), + ( + latitude: 45.596172963678953, + longitude: -122.36213902106172, + x: 400.00000000033322, + y: 150.00000000076162, + ), + ( + latitude: 45.3410012758427, + longitude: -122.78636256635325, + x: 1.6122228569454616e-09, + y: 225.00000000004968, + ), + ( + latitude: 45.389244047164595, + longitude: -122.66743207688384, + x: 100.00000000233659, + y: 224.99999999983444, + ), + ( + latitude: 45.437445677723758, + longitude: -122.54850158741668, + x: 200.00000000162672, + y: 224.99999999882448, + ), + ( + latitude: 45.485606168518984, + longitude: -122.42957109794729, + x: 300.00000000231381, + y: 224.99999999867546, + ), + ( + latitude: 45.533725520661449, + longitude: -122.31064060847788, + x: 400.00000000137629, + y: 225.00000000132454, + ), + ( + latitude: 45.278270641909273, + longitude: -122.73486415377167, + x: 1.2624594900343154e-09, + y: 299.99999999975165, + ), + ( + latitude: 45.326566857950922, + longitude: -122.61593366430225, + x: 100.00000000198474, + y: 299.99999999958607, + ), + ( + latitude: 45.374821932105306, + longitude: -122.4970031748351, + x: 200.00000000123762, + y: 299.99999999860921, + ), + ( + latitude: 45.423035865220783, + longitude: -122.37807268536569, + x: 300.00000000195371, + y: 299.99999999841054, + ), + ( + latitude: 45.471208658258405, + longitude: -122.25914219589629, + x: 400.00000000100789, + y: 300.00000000107622, + ), + ], + ), + ( + width: 400, + height: 300, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 200, + pitch: 0, + points: [ + ( + latitude: 45.430123648123924, + longitude: -122.27145173800065, + x: -2.9181440671284995e-10, + y: 2.0861534721916541e-09, + ), + ( + latitude: 45.397151960385912, + longitude: -122.40049888009668, + x: 100.00000000035391, + y: 2.1523760551644955e-09, + ), + ( + latitude: 45.364161022498308, + longitude: -122.52954602219046, + x: 199.9999999995002, + y: 1.5729142432974186e-09, + ), + ( + latitude: 45.331150834166607, + longitude: -122.6585931642865, + x: 300.00000000016456, + y: 1.6391368262702599e-09, + ), + ( + latitude: 45.298121395121512, + longitude: -122.78764030638028, + x: 399.99999999929531, + y: 1.0927578841801733e-09, + ), + ( + latitude: 45.498004672581629, + longitude: -122.30667872690458, + x: -3.8080745273166235e-10, + y: 75.000000000430475, + ), + ( + latitude: 45.465072650823203, + longitude: -122.43572586900059, + x: 99.999999999176296, + y: 75.000000003476941, + ), + ( + latitude: 45.432121379598897, + longitude: -122.56477301109662, + x: 199.99999999983444, + y: 75.000000003526623, + ), + ( + latitude: 45.39915085856515, + longitude: -122.6938201531904, + x: 299.99999999896522, + y: 75.000000002980244, + ), + ( + latitude: 45.366161087400513, + longitude: -122.82286729528644, + x: 399.99999999963677, + y: 75.000000003029896, + ), + ( + latitude: 45.565803963399532, + longitude: -122.34190571581073, + x: -4.5531325870090063e-11, + y: 150.0000000023345, + ), + ( + latitude: 45.532911606034986, + longitude: -122.47095285790451, + x: 99.999999999076962, + y: 150.00000000182126, + ), + ( + latitude: 45.500000000001734, + longitude: -122.60000000000053, + x: 199.9999999997475, + y: 150.00000000182126, + ), + ( + latitude: 45.467069144901188, + longitude: -122.72904714209656, + x: 300.00000000040359, + y: 150.00000000188749, + ), + ( + latitude: 45.434119040359661, + longitude: -122.85809428419034, + x: 399.99999999953639, + y: 150.00000000132454, + ), + ( + latitude: 45.633521524163001, + longitude: -122.37713270471463, + x: -1.3969838619232177e-10, + y: 225.00000000064571, + ), + ( + latitude: 45.600668829399012, + longitude: -122.50617984681065, + x: 99.999999999396707, + y: 225.00000000377497, + ), + ( + latitude: 45.567796886863988, + longitude: -122.63522698890444, + x: 199.99999999853162, + y: 225.00000000321202, + ), + ( + latitude: 45.534905696110627, + longitude: -122.76427413100048, + x: 299.99999999921147, + y: 225.00000000324513, + ), + ( + latitude: 45.50199525671357, + longitude: -122.89332127309649, + x: 399.99999999986755, + y: 225.0000000032617, + ), + ( + latitude: 45.701157358922615, + longitude: -122.41235969362077, + x: 1.8109050061967638e-10, + y: 300.00000000256631, + ), + ( + latitude: 45.668344324733795, + longitude: -122.54140683571455, + x: 99.999999999310816, + y: 300.00000000203647, + ), + ( + latitude: 45.635512043784246, + longitude: -122.6704539778106, + x: 199.99999999999585, + y: 300.00000000205307, + ), + ( + latitude: 45.60266051557204, + longitude: -122.79950111990438, + x: 299.99999999913075, + y: 300.00000000149009, + ), + ( + latitude: 45.569789739620091, + longitude: -122.92854826200039, + x: 399.99999999976512, + y: 300.00000000157291, + ), + ], + ), + ( + width: 400, + height: 300, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 0, + pitch: 30, + points: [ + ( + latitude: 45.706071875758511, + longitude: -122.94011297172315, + x: 1.6646246788019004e-09, + y: -1.9587673705245834e-09, + ), + ( + latitude: 45.706071875758511, + longitude: -122.77005648586234, + x: 100.00000000037929, + y: -1.9587673705245834e-09, + ), + ( + latitude: 45.706071875758511, + longitude: -122.59999999999872, + x: 200.00000000075858, + y: -1.9587673705245834e-09, + ), + ( + latitude: 45.706071875758511, + longitude: -122.4299435141379, + x: 299.99999999947988, + y: -1.9587673705245834e-09, + ), + ( + latitude: 45.706071875758511, + longitude: -122.25988702827431, + x: 399.99999999983243, + y: -1.9587673705245834e-09, + ), + ( + latitude: 45.592159231897199, + longitude: -122.90390109682772, + x: 1.6946347848457843e-09, + y: 74.9999999976298, + ), + ( + latitude: 45.592159231897199, + longitude: -122.75195054841205, + x: 100.00000000203833, + y: 74.9999999976298, + ), + ( + latitude: 45.592159231897199, + longitude: -122.59999999999886, + x: 200.00000000075474, + y: 74.9999999976298, + ), + ( + latitude: 45.592159231897199, + longitude: -122.44804945158569, + x: 299.99999999944868, + y: 74.9999999976298, + ), + ( + latitude: 45.592159231897199, + longitude: -122.29609890316999, + x: 399.99999999981111, + y: 74.9999999976298, + ), + ( + latitude: 45.500000000003155, + longitude: -122.87465820043704, + x: 1.7260511716144354e-09, + y: 149.9999999971719, + ), + ( + latitude: 45.500000000003155, + longitude: -122.7373291002169, + x: 100.00000000204685, + y: 149.9999999971719, + ), + ( + latitude: 45.500000000003155, + longitude: -122.59999999999897, + x: 200.0000000007492, + y: 149.9999999971719, + ), + ( + latitude: 45.500000000003155, + longitude: -122.46267089978107, + x: 299.99999999943498, + y: 149.9999999971719, + ), + ( + latitude: 45.500000000003155, + longitude: -122.3253417995609, + x: 399.99999999977649, + y: 149.9999999971719, + ), + ( + latitude: 45.423906342008451, + longitude: -122.85054910208396, + x: 1.7741653330314374e-09, + y: 224.99999999670334, + ), + ( + latitude: 45.423906342008451, + longitude: -122.72527455104048, + x: 100.00000000208767, + y: 224.99999999670334, + ), + ( + latitude: 45.423906342008451, + longitude: -122.59999999999906, + x: 200.0000000007586, + y: 224.99999999670334, + ), + ( + latitude: 45.423906342008451, + longitude: -122.47472544895558, + x: 300.00000000106303, + y: 224.99999999670334, + ), + ( + latitude: 45.423906342008451, + longitude: -122.34945089791415, + x: 399.99999999973397, + y: 224.99999999670334, + ), + ( + latitude: 45.360014297655283, + longitude: -122.83033098260142, + x: 1.80650391574692e-09, + y: 299.99999999621372, + ), + ( + latitude: 45.360014297655283, + longitude: -122.71516549129933, + x: 100.00000000210412, + y: 299.99999999621372, + ), + ( + latitude: 45.360014297655283, + longitude: -122.59999999999914, + x: 200.00000000074331, + y: 299.99999999621372, + ), + ( + latitude: 45.360014297655283, + longitude: -122.48483450869705, + x: 300.00000000104586, + y: 299.99999999621372, + ), + ( + latitude: 45.360014297655283, + longitude: -122.36966901739684, + x: 399.99999999969492, + y: 299.99999999621372, + ), + ], + ), + ( + width: 400, + height: 300, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 30, + pitch: 30, + points: [ + ( + latitude: 45.559565542436957, + longitude: -123.04181971050937, + x: 8.7242377744292278e-10, + y: -2.9816078495059628e-09, + ), + ( + latitude: 45.619068005002333, + longitude: -122.89454647367307, + x: 100.00000000127093, + y: -3.1220110940921586e-09, + ), + ( + latitude: 45.67850739039514, + longitude: -122.74727323683956, + x: 200.00000000019992, + y: -3.8106122701719869e-09, + ), + ( + latitude: 45.737883701575925, + longitude: -122.60000000000323, + x: 299.99999999913331, + y: -1.9654748939501587e-09, + ), + ( + latitude: 45.797196941784534, + longitude: -122.45272676316689, + x: 399.99999999965485, + y: -2.0924630916852038e-09, + ), + ( + latitude: 45.473367817614701, + longitude: -122.92898258761848, + x: 7.3321946540701037e-10, + y: 74.999999996806707, + ), + ( + latitude: 45.526619591233072, + longitude: -122.79738955257145, + x: 100.00000000116337, + y: 74.999999996663064, + ), + ( + latitude: 45.579821001059713, + longitude: -122.66579651752687, + x: 200.00000000011281, + y: 74.999999995901106, + ), + ( + latitude: 45.632972048901024, + longitude: -122.5342034824798, + x: 300.00000000040274, + y: 74.999999995742513, + ), + ( + latitude: 45.686072736731489, + longitude: -122.40261044743272, + x: 399.99999999963154, + y: 74.999999997826876, + ), + ( + latitude: 45.403662483262536, + longitude: -122.83786097894038, + x: 5.7121117909503672e-10, + y: 149.99999999667264, + ), + ( + latitude: 45.4518518113548, + longitude: -122.71893048947078, + x: 100.00000000104728, + y: 149.99999999650041, + ), + ( + latitude: 45.500000000004235, + longitude: -122.60000000000343, + x: 200.00000000003271, + y: 149.99999999564835, + ), + ( + latitude: 45.548107050359647, + longitude: -122.4810695105338, + x: 300.00000000034476, + y: 149.99999999549507, + ), + ( + latitude: 45.596172963681653, + longitude: -122.36213902106417, + x: 399.99999999962876, + y: 149.99999999776946, + ), + ( + latitude: 45.346129652415073, + longitude: -122.7627364154786, + x: 4.3106318833235477e-10, + y: 224.99999999654656, + ), + ( + latitude: 45.390135402377012, + longitude: -122.65424547182923, + x: 99.999999999577085, + y: 224.99999999559654, + ), + ( + latitude: 45.434106917149705, + longitude: -122.54575452817778, + x: 199.99999999995364, + y: 224.99999999543869, + ), + ( + latitude: 45.478044197489112, + longitude: -122.43726358452633, + x: 300.00000000028484, + y: 224.99999999527685, + ), + ( + latitude: 45.5219472442312, + longitude: -122.32877264087487, + x: 400.00000000059327, + y: 224.999999995119, + ), + ( + latitude: 45.297836947620027, + longitude: -122.69973624110868, + x: 3.0108398595788386e-10, + y: 299.99999999645502, + ), + ( + latitude: 45.338327424120507, + longitude: -122.60000000000355, + x: 99.999999999461451, + y: 299.99999999549692, + ), + ( + latitude: 45.378788967069113, + longitude: -122.50026375889654, + x: 199.99999999986767, + y: 299.99999999530496, + ), + ( + latitude: 45.419221576977293, + longitude: -122.40052751778953, + x: 300.00000000021748, + y: 299.9999999951188, + ), + ( + latitude: 45.459625254413822, + longitude: -122.30079127668249, + x: 400.00000000056821, + y: 299.99999999494247, + ), + ], + ), + ( + width: 400, + height: 300, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 200, + pitch: 30, + points: [ + ( + latitude: 45.387421412950033, + longitude: -122.17965752309925, + x: 1.5710313233740658e-09, + y: 7.688072400924284e-10, + ), + ( + latitude: 45.346558414645102, + longitude: -122.33945834798229, + x: 100.00000000249172, + y: 7.8887296695029363e-10, + ), + ( + latitude: 45.305665897092211, + longitude: -122.49925917286255, + x: 200.00000000188138, + y: 4.4121861719759181e-10, + ), + ( + latitude: 45.264743859811546, + longitude: -122.65905999774557, + x: 300.00000000274912, + y: 4.4792614062316716e-10, + ), + ( + latitude: 45.223792302383401, + longitude: -122.81886082262861, + x: 400.00000000363491, + y: 4.7464254748774692e-10, + ), + ( + latitude: 45.486178681221844, + longitude: -122.26941891315693, + x: 1.1185337763172254e-09, + y: 75.000000001532783, + ), + ( + latitude: 45.449731598811411, + longitude: -122.41220572222767, + x: 100.00000000211128, + y: 75.000000001582066, + ), + ( + latitude: 45.413260949541638, + longitude: -122.55499253129594, + x: 200.00000000160088, + y: 75.000000001146987, + ), + ( + latitude: 45.376766732960618, + longitude: -122.69777934036669, + x: 300.00000000255852, + y: 75.000000001172708, + ), + ( + latitude: 45.340248948654363, + longitude: -122.84056614943742, + x: 400.00000000350616, + y: 75.000000001213408, + ), + ( + latitude: 45.565803963396405, + longitude: -122.34190571581269, + x: 2.4586915969842273e-09, + y: 149.99999999978735, + ), + ( + latitude: 45.532911606034958, + longitude: -122.47095285790886, + x: 100.00000000175528, + y: 150.00000000246217, + ), + ( + latitude: 45.500000000001705, + longitude: -122.60000000000278, + x: 200.00000000131007, + y: 150.00000000201061, + ), + ( + latitude: 45.467069144901046, + longitude: -122.72904714209898, + x: 300.00000000237384, + y: 150.00000000201061, + ), + ( + latitude: 45.434119040359548, + longitude: -122.85809428419512, + x: 400.00000000338537, + y: 150.00000000207484, + ), + ( + latitude: 45.631365699452971, + longitude: -122.40166694572567, + x: 2.0214594779259587e-09, + y: 225.00000000052555, + ), + ( + latitude: 45.601396384691498, + longitude: -122.51938651691358, + x: 100.00000000285601, + y: 225.00000000053464, + ), + ( + latitude: 45.571411052947241, + longitude: -122.63710608809947, + x: 200.00000000213802, + y: 225.00000000009229, + ), + ( + latitude: 45.541409703876923, + longitude: -122.75482565928741, + x: 300.00000000293551, + y: 225.00000000006614, + ), + ( + latitude: 45.511392337157673, + longitude: -122.87254523047532, + x: 400.00000000327492, + y: 225.00000000308714, + ), + ( + latitude: 45.686287339815266, + longitude: -122.45178328226841, + x: 1.5794569755252639e-09, + y: 300.0000000014054, + ), + ( + latitude: 45.65876400372926, + longitude: -122.56000344461425, + x: 100.00000000250422, + y: 300.00000000143672, + ), + ( + latitude: 45.631227131946815, + longitude: -122.66822360695819, + x: 200.00000000188163, + y: 300.00000000086663, + ), + ( + latitude: 45.603676724175045, + longitude: -122.77644376930404, + x: 300.0000000027423, + y: 300.00000000092348, + ), + ( + latitude: 45.576112780133286, + longitude: -122.88466393164987, + x: 400.0000000036062, + y: 300.00000000093331, + ), + ], + ), + ( + width: 400, + height: 300, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 0, + pitch: 45, + points: [ + ( + latitude: 45.805449301248927, + longitude: -123.01198729864203, + x: 1.7660635575391479e-10, + y: 2.0969537217752077e-10, + ), + ( + latitude: 45.805449301248927, + longitude: -122.80599364932252, + x: 99.999999999359019, + y: 2.0969537217752077e-10, + ), + ( + latitude: 45.805449301248927, + longitude: -122.60000000000301, + x: 199.99999999854006, + y: 2.0969537217752077e-10, + ), + ( + latitude: 45.805449301248927, + longitude: -122.39400635068012, + x: 299.99999999936711, + y: 2.0969537217752077e-10, + ), + ( + latitude: 45.805449301248927, + longitude: -122.18801270136061, + x: 399.99999999854953, + y: 2.0969537217752077e-10, + ), + ( + latitude: 45.622379632667901, + longitude: -122.92958983988092, + x: 6.4330244631568968e-10, + y: 74.99999999924583, + ), + ( + latitude: 45.622379632667901, + longitude: -122.76479491994166, + x: 99.99999999958888, + y: 74.99999999924583, + ), + ( + latitude: 45.622379632667901, + longitude: -122.60000000000241, + x: 199.99999999853964, + y: 74.99999999924583, + ), + ( + latitude: 45.622379632667901, + longitude: -122.43520508006046, + x: 299.99999999912882, + y: 74.99999999924583, + ), + ( + latitude: 45.622379632667901, + longitude: -122.2704101601212, + x: 399.99999999807443, + y: 74.99999999924583, + ), + ( + latitude: 45.499999999999631, + longitude: -122.87465820043816, + x: 5.5879354476966656e-11, + y: 150.00000000029226, + ), + ( + latitude: 45.499999999999631, + longitude: -122.73732910022008, + x: 99.999999999306297, + y: 150.00000000029226, + ), + ( + latitude: 45.499999999999631, + longitude: -122.60000000000201, + x: 199.9999999985443, + y: 150.00000000029226, + ), + ( + latitude: 45.499999999999631, + longitude: -122.46267089978167, + x: 299.99999999944004, + y: 150.00000000029226, + ), + ( + latitude: 45.499999999999631, + longitude: -122.3253417995636, + x: 399.99999999867595, + y: 150.00000000029226, + ), + ( + latitude: 45.412422767807783, + longitude: -122.8354213149903, + x: 5.0946794103052841e-10, + y: 224.9999999990458, + ), + ( + latitude: 45.412422767807783, + longitude: -122.717710657496, + x: 99.999999999534595, + y: 224.9999999990458, + ), + ( + latitude: 45.412422767807783, + longitude: -122.60000000000173, + x: 199.99999999853557, + y: 224.9999999990458, + ), + ( + latitude: 45.412422767807783, + longitude: -122.48228934250551, + x: 299.99999999919294, + y: 224.9999999990458, + ), + ( + latitude: 45.412422767807783, + longitude: -122.36457868501122, + x: 399.99999999821085, + y: 224.9999999990458, + ), + ( + latitude: 45.346650584111842, + longitude: -122.80599365083242, + x: -4.4151588614580839e-11, + y: 300.00000000050085, + ), + ( + latitude: 45.346650584111842, + longitude: -122.70299682541696, + x: 99.999999999250804, + y: 300.00000000050085, + ), + ( + latitude: 45.346650584111842, + longitude: -122.6000000000015, + x: 199.99999999854575, + y: 300.00000000050085, + ), + ( + latitude: 45.346650584111842, + longitude: -122.49700317458436, + x: 299.99999999947158, + y: 300.00000000050085, + ), + ( + latitude: 45.346650584111842, + longitude: -122.3940063491689, + x: 399.9999999987665, + y: 300.00000000050085, + ), + ], + ), + ( + width: 400, + height: 300, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 30, + pitch: 45, + points: [ + ( + latitude: 45.620735664630644, + longitude: -123.17528072613851, + x: -3.4824565775321923e-09, + y: 1.0430767360958271e-09, + ), + ( + latitude: 45.692725854344417, + longitude: -122.996884992807, + x: 99.999999997592795, + y: 9.2717300503863953e-10, + ), + ( + latitude: 45.76462349588769, + longitude: -122.81848925947872, + x: 199.99999999726467, + y: 4.0841996451490559e-10, + ), + ( + latitude: 45.836428595169139, + longitude: -122.64009352614701, + x: 299.99999999817391, + y: 2.7597479856922291e-10, + ), + ( + latitude: 45.908141158671782, + longitude: -122.46169779281522, + x: 399.99999999904765, + y: 1.1590373105718754e-10, + ), + ( + latitude: 45.490592226701807, + longitude: -122.97282887821626, + x: -3.2285849316543164e-09, + y: 75.000000000535294, + ), + ( + latitude: 45.548325399415575, + longitude: -122.8301122911322, + x: 99.999999997697671, + y: 75.000000000380794, + ), + ( + latitude: 45.605999334820666, + longitude: -122.68739570404803, + x: 199.99999999858144, + y: 75.000000000251049, + ), + ( + latitude: 45.663614035320023, + longitude: -122.54467911696383, + x: 299.99999999943299, + y: 75.000000000087653, + ), + ( + latitude: 45.721169503549902, + longitude: -122.40196252988227, + x: 399.99999999782864, + y: 75.000000001139483, + ), + ( + latitude: 45.403662483255061, + longitude: -122.83786097893905, + x: -4.3710072835515439e-09, + y: 150.00000000236699, + ), + ( + latitude: 45.451851811347325, + longitude: -122.71893048946961, + x: 99.999999996966736, + y: 150.00000000218461, + ), + ( + latitude: 45.499999999996817, + longitude: -122.60000000000237, + x: 199.9999999968405, + y: 150.0000000014405, + ), + ( + latitude: 45.548107050352201, + longitude: -122.48106951053285, + x: 299.99999999794113, + y: 150.00000000130095, + ), + ( + latitude: 45.596172963677361, + longitude: -122.36213902106329, + x: 399.99999999902934, + y: 150.0000000011137, + ), + ( + latitude: 45.341487787989934, + longitude: -122.74145533627677, + x: -4.0757435282646785e-09, + y: 225.00000000169112, + ), + ( + latitude: 45.382840898263993, + longitude: -122.63951491658904, + x: 99.99999999705939, + y: 225.00000000155444, + ), + ( + latitude: 45.424163782578603, + longitude: -122.5375744969013, + x: 199.9999999981556, + y: 225.0000000013718, + ), + ( + latitude: 45.465456441547843, + longitude: -122.43563407721354, + x: 299.9999999992225, + y: 225.00000000119883, + ), + ( + latitude: 45.506718875848208, + longitude: -122.33369365752768, + x: 399.99999999853981, + y: 225.00000000035089, + ), + ( + latitude: 45.294811909734705, + longitude: -122.66915110410153, + x: -2.6601332140333128e-09, + y: 300.00000000159599, + ), + ( + latitude: 45.331027363602317, + longitude: -122.57995323678128, + x: 99.999999998448146, + y: 300.00000000138215, + ), + ( + latitude: 45.367219675197219, + longitude: -122.4907553694627, + x: 199.99999999805871, + y: 300.00000000040495, + ), + ( + latitude: 45.403388844873319, + longitude: -122.40155750214242, + x: 299.9999999977191, + y: 300.00000000291794, + ), + ( + latitude: 45.439534873028379, + longitude: -122.31235963482212, + x: 399.9999999990273, + y: 300.00000000272809, + ), + ], + ), + ( + width: 400, + height: 300, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 200, + pitch: 45, + points: [ + ( + latitude: 45.310635097389422, + longitude: -122.06340311992881, + x: -4.0343514392988044e-09, + y: 3.5763036976277363e-09, + ), + ( + latitude: 45.261065781815574, + longitude: -122.25697383212361, + x: 99.99999999674408, + y: 3.3830929169198498e-09, + ), + ( + latitude: 45.211453151047806, + longitude: -122.4505445443217, + x: 199.99999999890207, + y: 3.432774065004196e-09, + ), + ( + latitude: 45.161797204467547, + longitude: -122.64411525651636, + x: 299.99999999943441, + y: 3.234106316085672e-09, + ), + ( + latitude: 45.11209794158566, + longitude: -122.83768596871425, + x: 400.0000000014777, + y: 3.2837874641700182e-09, + ), + ( + latitude: 45.463874887113036, + longitude: -122.23050467713182, + x: -4.8635734548000218e-09, + y: 75.000000005473709, + ), + ( + latitude: 45.42433021185775, + longitude: -122.38536124734196, + x: 99.999999996132431, + y: 75.000000005205266, + ), + ( + latitude: 45.384757816765671, + longitude: -122.54021781755476, + x: 199.99999999849567, + y: 75.000000005258812, + ), + ( + latitude: 45.34515770129596, + longitude: -122.69507438776481, + x: 299.99999999922056, + y: 75.000000004990369, + ), + ( + latitude: 45.305529864960477, + longitude: -122.84993095797751, + x: 400.00000000149703, + y: 75.000000005034963, + ), + ( + latitude: 45.565803963404818, + longitude: -122.34190571581189, + x: -3.5762786865671526e-09, + y: 150.00000000548434, + ), + ( + latitude: 45.532911606040244, + longitude: -122.47095285790607, + x: 99.999999997127006, + y: 150.00000000516724, + ), + ( + latitude: 45.500000000006992, + longitude: -122.60000000000248, + x: 199.99999999921977, + y: 150.00000000523144, + ), + ( + latitude: 45.467069144909516, + longitude: -122.72904714209659, + x: 299.99999999902894, + y: 150.00000000707269, + ), + ( + latitude: 45.434119040367989, + longitude: -122.8580942841929, + x: 400.00000000149066, + y: 150.00000000710722, + ), + ( + latitude: 45.638497417090321, + longitude: -122.42147788656445, + x: -4.4089500489905789e-09, + y: 225.00000000765436, + ), + ( + latitude: 45.610341642433212, + longitude: -122.5320897227997, + x: 99.999999996524423, + y: 225.00000000722616, + ), + ( + latitude: 45.582171726659425, + longitude: -122.64270155903688, + x: 199.99999999882351, + y: 225.00000000731688, + ), + ( + latitude: 45.553987669479454, + longitude: -122.75331339527209, + x: 299.99999999947693, + y: 225.00000000692498, + ), + ( + latitude: 45.525789470617383, + longitude: -122.8639252315092, + x: 400.00000000168353, + y: 225.00000000697941, + ), + ( + latitude: 45.692955697802574, + longitude: -122.48115701477479, + x: -3.1623575345550766e-09, + y: 300.0000000072564, + ), + ( + latitude: 45.668344117656574, + longitude: -122.57794237158205, + x: 99.999999995891656, + y: 300.00000000966247, + ), + ( + latitude: 45.643721711204819, + longitude: -122.67472772839099, + x: 199.99999999840674, + y: 300.00000000972148, + ), + ( + latitude: 45.619088478236449, + longitude: -122.77151308519821, + x: 299.999999999306, + y: 300.00000000917385, + ), + ( + latitude: 45.594444418546431, + longitude: -122.86829844200709, + x: 400.00000000169291, + y: 300.00000000932874, + ), + ], + ), + ( + width: 400, + height: 300, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 0, + pitch: 60, + points: [ + ( + latitude: 46.179082462360867, + longitude: -123.24984827091258, + x: 1.377681342511842e-10, + y: -2.0293100533308461e-10, + ), + ( + latitude: 46.179082462360867, + longitude: -122.92492413545477, + x: 100.00000000053517, + y: -2.0293100533308461e-10, + ), + ( + latitude: 46.179082462360867, + longitude: -122.60000000000232, + x: 199.99999999928764, + y: -2.0293100533308461e-10, + ), + ( + latitude: 46.179082462360867, + longitude: -122.27507586454453, + x: 299.99999999967895, + y: -2.0293100533308461e-10, + ), + ( + latitude: 46.179082462360867, + longitude: -121.95015172909206, + x: 399.99999999844016, + y: -2.0293100533308461e-10, + ), + ( + latitude: 45.702611529622459, + longitude: -122.98612202765077, + x: -2.841272692881137e-10, + y: 74.999999999903423, + ), + ( + latitude: 45.702611529622459, + longitude: -122.7930610138245, + x: 100.00000000031605, + y: 74.999999999903423, + ), + ( + latitude: 45.702611529622459, + longitude: -122.60000000000139, + x: 199.99999999928067, + y: 74.999999999903423, + ), + ( + latitude: 45.702611529622459, + longitude: -122.40693898617509, + x: 299.99999999989558, + y: 74.999999999903423, + ), + ( + latitude: 45.702611529622459, + longitude: -122.21387797235198, + x: 399.99999999886018, + y: 74.999999999903423, + ), + ( + latitude: 45.500000000002558, + longitude: -122.87465820043907, + x: 5.3602788183153796e-10, + y: 149.99999999868865, + ), + ( + latitude: 45.500000000002558, + longitude: -122.73732910021889, + x: 100.00000000074351, + y: 149.99999999868865, + ), + ( + latitude: 45.500000000002558, + longitude: -122.60000000000099, + x: 199.99999999928392, + y: 149.99999999868865, + ), + ( + latitude: 45.500000000002558, + longitude: -122.46267089978082, + x: 299.99999999948312, + y: 149.99999999868865, + ), + ( + latitude: 45.500000000002558, + longitude: -122.32534179956289, + x: 399.99999999804629, + y: 149.99999999868865, + ), + ( + latitude: 45.387848759806587, + longitude: -122.81313222678676, + x: 1.2801837243309665e-10, + y: 224.99999999895684, + ), + ( + latitude: 45.387848759806587, + longitude: -122.7065661133929, + x: 100.00000000052408, + y: 224.99999999895684, + ), + ( + latitude: 45.387848759806587, + longitude: -122.60000000000076, + x: 199.99999999928923, + y: 224.99999999895684, + ), + ( + latitude: 45.387848759806587, + longitude: -122.49343388660688, + x: 299.99999999969464, + y: 224.99999999895684, + ), + ( + latitude: 45.387848759806587, + longitude: -122.38686777321476, + x: 399.99999999844908, + y: 224.99999999895684, + ), + ( + latitude: 45.316632240872877, + longitude: -122.77412632234412, + x: -3.2318481987243332e-10, + y: 299.99999999958379, + ), + ( + latitude: 45.316632240872877, + longitude: -122.68706316117166, + x: 100.00000000028891, + y: 299.99999999958379, + ), + ( + latitude: 45.316632240872877, + longitude: -122.60000000000062, + x: 199.99999999928343, + y: 299.99999999958379, + ), + ( + latitude: 45.316632240872877, + longitude: -122.51293683882815, + x: 299.99999999991837, + y: 299.99999999958379, + ), + ( + latitude: 45.316632240872877, + longitude: -122.42587367765711, + x: 399.99999999891293, + y: 299.99999999958379, + ), + ], + ), + ( + width: 400, + height: 300, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 30, + pitch: 60, + points: [ + ( + latitude: 45.86277343475453, + longitude: -123.65017131440615, + x: -4.6500025503726319e-09, + y: 4.5309889173950069e-10, + ), + ( + latitude: 45.975793536932201, + longitude: -123.36877875879998, + x: 99.999999996168512, + y: 2.1168489183764905e-10, + ), + ( + latitude: 46.088583442562907, + longitude: -123.08738620319357, + x: 199.99999999497115, + y: 5.773017619503662e-10, + ), + ( + latitude: 46.201143184326043, + longitude: -122.80599364758706, + x: 299.99999999558469, + y: 3.3764990803319961e-10, + ), + ( + latitude: 46.313472798368764, + longitude: -122.52460109197504, + x: 399.9999999975451, + y: 2.7642954592010938e-10, + ), + ( + latitude: 45.540450773233431, + longitude: -123.07918724527977, + x: -5.4646654072687244e-09, + y: 75.000000000839918, + ), + ( + latitude: 45.608020624420902, + longitude: -122.91199150282463, + x: 99.999999996610256, + y: 75.000000000727368, + ), + ( + latitude: 45.675509177858686, + longitude: -122.74479576037257, + x: 199.99999999713074, + y: 75.000000000359393, + ), + ( + latitude: 45.742916437833372, + longitude: -122.57760001792047, + x: 299.9999999962169, + y: 75.000000000988649, + ), + ( + latitude: 45.810242409089767, + longitude: -122.41040427546831, + x: 399.99999999654409, + y: 75.000000000609617, + ), + ( + latitude: 45.403662483254976, + longitude: -122.83786097893997, + x: -5.1243437661368243e-09, + y: 150.00000000152926, + ), + ( + latitude: 45.451851811347581, + longitude: -122.71893048947192, + x: 99.999999995735578, + y: 150.00000000100428, + ), + ( + latitude: 45.499999999997357, + longitude: -122.6000000000038, + x: 199.99999999624058, + y: 150.00000000049801, + ), + ( + latitude: 45.548107050350012, + longitude: -122.48106951053566, + x: 299.99999999528427, + y: 150.00000000137985, + ), + ( + latitude: 45.596172963675485, + longitude: -122.36213902106526, + x: 399.99999999730176, + y: 150.00000000127611, + ), + ( + latitude: 45.328015665836716, + longitude: -122.70465333772006, + x: -5.8675087365675445e-09, + y: 225.00000000183655, + ), + ( + latitude: 45.365463775349127, + longitude: -122.61236437633781, + x: 99.999999996152951, + y: 225.00000000175362, + ), + ( + latitude: 45.402887111198169, + longitude: -122.52007541495726, + x: 199.99999999675521, + y: 225.00000000110896, + ), + ( + latitude: 45.440285673815424, + longitude: -122.42778645357672, + x: 299.99999999588408, + y: 225.00000000224182, + ), + ( + latitude: 45.477659463681618, + longitude: -122.33549749219614, + x: 399.99999999628079, + y: 225.00000000159716, + ), + ( + latitude: 45.280005109229563, + longitude: -122.62020307686109, + x: -5.6149281836863824e-09, + y: 300.00000000278908, + ), + ( + latitude: 45.310627520602935, + longitude: -122.54480416755291, + x: 99.999999995350137, + y: 300.00000000194848, + ), + ( + latitude: 45.341233395968317, + longitude: -122.46940525824471, + x: 199.99999999588837, + y: 300.00000000116461, + ), + ( + latitude: 45.371822735526763, + longitude: -122.3940063489365, + x: 299.99999999606729, + y: 300.00000000038295, + ), + ( + latitude: 45.402395539495984, + longitude: -122.31860743962829, + x: 399.99999999530809, + y: 300.00000000180876, + ), + ], + ), + ( + width: 400, + height: 300, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 200, + pitch: 60, + points: [ + ( + latitude: 45.01166184639581, + longitude: -121.65595057706629, + x: 2.7833536710352193e-09, + y: -8.7538865045644343e-12, + ), + ( + latitude: 44.933042754768991, + longitude: -121.96127938947113, + x: 100.000000003251, + y: 2.0975221559638157e-11, + ), + ( + latitude: 44.854315889334856, + longitude: -122.2666082018705, + x: 200.00000000217477, + y: -6.4744654082460329e-11, + ), + ( + latitude: 44.77548125119273, + longitude: -122.57193701427501, + x: 300.0000000025376, + y: -3.8482994568767026e-11, + ), + ( + latitude: 44.69653884225923, + longitude: -122.87726582667408, + x: 400.0000000011529, + y: -1.2772716218023561e-10, + ), + ( + latitude: 45.401741200980695, + longitude: -122.13811784647058, + x: 3.1209834761156991e-09, + y: 74.99999999975347, + ), + ( + latitude: 45.355359910519752, + longitude: -122.3195358565242, + x: 100.00000000338386, + y: 74.999999999787889, + ), + ( + latitude: 45.308940574287021, + longitude: -122.50095386657459, + x: 200.00000000211716, + y: 74.99999999961878, + ), + ( + latitude: 45.262483191572187, + longitude: -122.68237187662811, + x: 300.00000000176806, + y: 75.000000000737941, + ), + ( + latitude: 45.215987761750966, + longitude: -122.86378988668159, + x: 400.00000000254613, + y: 75.000000000763009, + ), + ( + latitude: 45.565803963395098, + longitude: -122.34190571581266, + x: 3.452102343231215e-09, + y: 149.99999999923159, + ), + ( + latitude: 45.532911606033821, + longitude: -122.47095285790834, + x: 100.00000000182862, + y: 150.00000000080993, + ), + ( + latitude: 45.500000000000739, + longitude: -122.600000000004, + x: 200.00000000249699, + y: 150.00000000083691, + ), + ( + latitude: 45.46706914490025, + longitude: -122.72904714209739, + x: 300.00000000153034, + y: 150.00000000057889, + ), + ( + latitude: 45.434119040358894, + longitude: -122.858094284193, + x: 400.00000000211151, + y: 150.00000000061073, + ), + ( + latitude: 45.656158934484239, + longitude: -122.45439285530557, + x: 2.7630632050296506e-09, + y: 225.00000000081198, + ), + ( + latitude: 45.630677516353131, + longitude: -122.55453224568556, + x: 100.00000000195178, + y: 225.00000000047496, + ), + ( + latitude: 45.605184508214506, + longitude: -122.65467163606728, + x: 200.00000000245802, + y: 225.00000000047496, + ), + ( + latitude: 45.579679909845879, + longitude: -122.75481102644724, + x: 300.00000000131655, + y: 225.0000000001526, + ), + ( + latitude: 45.554163721033831, + longitude: -122.85495041682893, + x: 400.00000000169689, + y: 225.00000000019674, + ), + ( + latitude: 45.713366299855494, + longitude: -122.52570684439891, + x: 3.1077974597883135e-09, + y: 300.00000000022789, + ), + ( + latitude: 45.692570444541786, + longitude: -122.60751945449326, + x: 100.00000000208499, + y: 299.99999999981088, + ), + ( + latitude: 45.671766853651974, + longitude: -122.68933206458902, + x: 200.00000000241081, + y: 299.99999999983271, + ), + ( + latitude: 45.650955527052247, + longitude: -122.77114467468478, + x: 300.00000000270768, + y: 299.99999999985454, + ), + ( + latitude: 45.630136464612889, + longitude: -122.85295728477911, + x: 400.00000000129927, + y: 299.99999999940911, + ), + ], + ), + ( + width: 390, + height: 844, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 0, + pitch: 0, + points: [ + ( + latitude: 45.904732037267223, + longitude: -122.86779174543744, + x: -7.2681414195899899e-09, + y: -2.6012685339082964e-09, + ), + ( + latitude: 45.904732037267223, + longitude: -122.73389587272568, + x: 97.499999991307661, + y: -2.6012685339082964e-09, + ), + ( + latitude: 45.904732037267223, + longitude: -122.60000000000758, + x: 194.99999999448562, + y: -2.6012685339082964e-09, + ), + ( + latitude: 45.904732037267223, + longitude: -122.46610412729581, + x: 292.49999999306732, + y: -2.6012685339082964e-09, + ), + ( + latitude: 45.904732037267223, + longitude: -122.332208254584, + x: 389.99999999166664, + y: -2.6012685339082964e-09, + ), + ( + latitude: 45.702732266397874, + longitude: -122.86779174543744, + x: -7.2681414195899899e-09, + y: 210.99999999482111, + ), + ( + latitude: 45.702732266397874, + longitude: -122.73389587272568, + x: 97.499999991307661, + y: 210.99999999482111, + ), + ( + latitude: 45.702732266397874, + longitude: -122.60000000000758, + x: 194.99999999448562, + y: 210.99999999482111, + ), + ( + latitude: 45.702732266397874, + longitude: -122.46610412729581, + x: 292.49999999306732, + y: 210.99999999482111, + ), + ( + latitude: 45.702732266397874, + longitude: -122.332208254584, + x: 389.99999999166664, + y: 210.99999999482111, + ), + ( + latitude: 45.499999999998607, + longitude: -122.86779174543744, + x: -7.2681414195899899e-09, + y: 422.00000000149481, + ), + ( + latitude: 45.499999999998607, + longitude: -122.73389587272568, + x: 97.499999991307661, + y: 422.00000000149481, + ), + ( + latitude: 45.499999999998607, + longitude: -122.60000000000758, + x: 194.99999999448562, + y: 422.00000000149481, + ), + ( + latitude: 45.499999999998607, + longitude: -122.46610412729581, + x: 292.49999999306732, + y: 422.00000000149481, + ), + ( + latitude: 45.499999999998607, + longitude: -122.332208254584, + x: 389.99999999166664, + y: 422.00000000149481, + ), + ( + latitude: 45.296535129282489, + longitude: -122.86779174543744, + x: -7.2681414195899899e-09, + y: 632.99999999895249, + ), + ( + latitude: 45.296535129282489, + longitude: -122.73389587272568, + x: 97.499999991307661, + y: 632.99999999895249, + ), + ( + latitude: 45.296535129282489, + longitude: -122.60000000000758, + x: 194.99999999448562, + y: 632.99999999895249, + ), + ( + latitude: 45.296535129282489, + longitude: -122.46610412729581, + x: 292.49999999306732, + y: 632.99999999895249, + ), + ( + latitude: 45.296535129282489, + longitude: -122.332208254584, + x: 389.99999999166664, + y: 632.99999999895249, + ), + ( + latitude: 45.092337581930479, + longitude: -122.86779174543744, + x: -7.2681414195899899e-09, + y: 843.99999999639829, + ), + ( + latitude: 45.092337581930479, + longitude: -122.73389587272568, + x: 97.499999991307661, + y: 843.99999999639829, + ), + ( + latitude: 45.092337581930479, + longitude: -122.60000000000758, + x: 194.99999999448562, + y: 843.99999999639829, + ), + ( + latitude: 45.092337581930479, + longitude: -122.46610412729581, + x: 292.49999999306732, + y: 843.99999999639829, + ), + ( + latitude: 45.092337581930479, + longitude: -122.332208254584, + x: 389.99999999166664, + y: 843.99999999639829, + ), + ], + ), + ( + width: 390, + height: 844, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 30, + pitch: 0, + points: [ + ( + latitude: 45.757337411664196, + longitude: -123.12167885591703, + x: 7.1636802777294866e-09, + y: 1.3653789210366085e-09, + ), + ( + latitude: 45.804027351961622, + longitude: -123.00572162868349, + x: 97.500000007786028, + y: 2.5659119273768738e-09, + ), + ( + latitude: 45.85067819461409, + longitude: -122.88976440144998, + x: 195.00000000843193, + y: 3.6841356632066891e-09, + ), + ( + latitude: 45.897289941460713, + longitude: -122.77380717422278, + x: 292.50000000505383, + y: 2.5894451027852483e-09, + ), + ( + latitude: 45.943862594442663, + longitude: -122.65784994698927, + x: 390.00000000566445, + y: 3.7429117583087645e-09, + ), + ( + latitude: 45.581979881267955, + longitude: -122.97679665518748, + x: 6.1205401428126232e-09, + y: 211.00000000067087, + ), + ( + latitude: 45.628816387279869, + longitude: -122.86083942795396, + x: 97.500000006731128, + y: 211.00000000185969, + ), + ( + latitude: 45.675613789665249, + longitude: -122.74488220072044, + x: 195.00000000736526, + y: 211.00000000302498, + ), + ( + latitude: 45.722372089878547, + longitude: -122.6289249734869, + x: 292.50000000799349, + y: 211.00000000420198, + ), + ( + latitude: 45.769091289477217, + longitude: -122.51296774625972, + x: 390.00000000462717, + y: 211.00000000304851, + ), + ( + latitude: 45.406072926744628, + longitude: -122.83191445445161, + x: 9.0307392780249722e-09, + y: 422.00000000235406, + ), + ( + latitude: 45.453056017451559, + longitude: -122.71595722722441, + x: 97.500000005711527, + y: 422.00000000112993, + ), + ( + latitude: 45.500000000001108, + longitude: -122.5999999999909, + x: 195.00000000631621, + y: 422.00000000231876, + ), + ( + latitude: 45.546904875459461, + longitude: -122.48404277275736, + x: 292.50000000695621, + y: 422.00000000349576, + ), + ( + latitude: 45.593770644996539, + longitude: -122.36808554552385, + x: 390.00000000754915, + y: 422.00000000469635, + ), + ( + latitude: 45.22961648992387, + longitude: -122.68703225372207, + x: 7.999369412613518e-09, + y: 633.00000000162424, + ), + ( + latitude: 45.276746178834799, + longitude: -122.57107502648854, + x: 97.500000008609959, + y: 633.00000000282489, + ), + ( + latitude: 45.323836756522525, + longitude: -122.45511779926134, + x: 195.0000000052672, + y: 633.0000000016596, + ), + ( + latitude: 45.37088822366087, + longitude: -122.33916057202784, + x: 292.50000000587187, + y: 633.00000000284842, + ), + ( + latitude: 45.417900581028761, + longitude: -122.22320334479431, + x: 390.00000000651193, + y: 633.00000000399007, + ), + ( + latitude: 45.052610533267966, + longitude: -122.54215005299253, + x: 6.9503441429439499e-09, + y: 844.00000000094167, + ), + ( + latitude: 45.099886828368341, + longitude: -122.42619282575902, + x: 97.500000007553567, + y: 844.00000000211867, + ), + ( + latitude: 45.147124010658359, + longitude: -122.31023559852548, + x: 195.00000000822448, + y: 844.00000000323678, + ), + ( + latitude: 45.19432208041593, + longitude: -122.19427837129828, + x: 292.5000000048405, + y: 844.0000000021422, + ), + ( + latitude: 45.241481038025086, + longitude: -122.07832114406477, + x: 390.00000000546288, + y: 844.00000000329567, + ), + ], + ), + ( + width: 390, + height: 844, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 200, + pitch: 0, + points: [ + ( + latitude: 45.181600714511632, + longitude: -122.15014754867205, + x: -1.1078766171966118e-09, + y: -6.8032477429369465e-09, + ), + ( + latitude: 45.149312346661333, + longitude: -122.27596851221314, + x: 97.499999999287894, + y: -1.1487827578093857e-08, + ), + ( + latitude: 45.117005677636485, + longitude: -122.40178947576054, + x: 195.00000000081951, + y: -5.8734030972118489e-09, + ), + ( + latitude: 45.084680707323116, + longitude: -122.52761043930163, + x: 292.50000000121526, + y: -1.0569692676654086e-08, + ), + ( + latitude: 45.052337435666544, + longitude: -122.65343140284271, + x: 389.9999999984625, + y: -6.5796257331385277e-09, + ), + ( + latitude: 45.373203733507154, + longitude: -122.24925281078836, + x: -1.3550522768101986e-09, + y: 210.99999998839451, + ), + ( + latitude: 45.341024178015601, + longitude: -122.37507377433576, + x: 97.500000000195683, + y: 210.99999999395004, + ), + ( + latitude: 45.308826322379673, + longitude: -122.50089473787686, + x: 195.00000000059882, + y: 210.99999998928911, + ), + ( + latitude: 45.276610166383932, + longitude: -122.62671570141795, + x: 292.49999999781517, + y: 210.99999999333806, + ), + ( + latitude: 45.24437570980021, + longitude: -122.75253666495904, + x: 389.99999999819329, + y: 210.99999998870055, + ), + ( + latitude: 45.564159802673203, + longitude: -122.34835807291101, + x: -4.3255740432377677e-10, + y: 421.99999999385591, + ), + ( + latitude: 45.532089050467306, + longitude: -122.47417903645209, + x: 97.499999999955861, + y: 421.99999998917133, + ), + ( + latitude: 45.499999999994799, + longitude: -122.59999999999319, + x: 194.99999999719279, + y: 421.99999999319681, + ), + ( + latitude: 45.467892650868578, + longitude: -122.7258209635406, + x: 292.50000000190386, + y: 421.99999999010123, + ), + ( + latitude: 45.435767002760002, + longitude: -122.85164192708169, + x: 389.99999999914519, + y: 421.9999999940913, + ), + ( + latitude: 45.754468992520316, + longitude: -122.44746333502732, + x: -6.7384792918465902e-10, + y: 632.99999998904184, + ), + ( + latitude: 45.722507029764472, + longitude: -122.57328429856841, + x: 97.499999996555729, + y: 632.99999999304373, + ), + ( + latitude: 45.690526771387681, + longitude: -122.69910526211584, + x: 195.00000000128003, + y: 632.99999998994815, + ), + ( + latitude: 45.658528216903846, + longitude: -122.82492622565692, + x: 292.49999999852133, + y: 632.99999999395004, + ), + ( + latitude: 45.626511365813997, + longitude: -122.95074718919798, + x: 389.99999999887888, + y: 632.99999998927728, + ), + ( + latitude: 45.944131401950898, + longitude: -122.54656859714999, + x: 2.7218748231258059e-10, + y: 843.99999999445618, + ), + ( + latitude: 45.912278210025562, + longitude: -122.67238956069106, + x: 97.500000000642956, + y: 843.99999998980695, + ), + ( + latitude: 45.880406725955538, + longitude: -122.79821052423216, + x: 194.99999999787988, + y: 843.99999999383238, + ), + ( + latitude: 45.848516949086246, + longitude: -122.92403148777323, + x: 292.49999999824473, + y: 843.99999998920669, + ), + ( + latitude: 45.816608878820631, + longitude: -123.04985245132065, + x: 389.99999999981901, + y: 843.99999999473869, + ), + ], + ), + ( + width: 390, + height: 844, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 0, + pitch: 30, + points: [ + ( + latitude: 46.07781839155669, + longitude: -122.93161014743475, + x: -6.6535560735373254e-10, + y: -9.6666781246312894e-09, + ), + ( + latitude: 46.07781839155669, + longitude: -122.76580507372103, + x: 97.499999997522011, + y: -9.6666781246312894e-09, + ), + ( + latitude: 46.07781839155669, + longitude: -122.59999999999948, + x: 195.00000000030983, + y: -9.6666781246312894e-09, + ), + ( + latitude: 46.07781839155669, + longitude: -122.43419492628576, + x: 292.49999999849723, + y: -9.6666781246312894e-09, + ), + ( + latitude: 46.07781839155669, + longitude: -122.26838985257203, + x: 389.99999999668933, + y: -9.6666781246312894e-09, + ), + ( + latitude: 45.75888926677419, + longitude: -122.89630356941112, + x: -6.542170005198243e-10, + y: 210.99999998944622, + ), + ( + latitude: 45.75888926677419, + longitude: -122.74815178470882, + x: 97.499999997528192, + y: 210.99999998944622, + ), + ( + latitude: 45.75888926677419, + longitude: -122.59999999999954, + x: 195.00000000030607, + y: 210.99999998944622, + ), + ( + latitude: 45.75888926677419, + longitude: -122.45184821529723, + x: 292.49999999849376, + y: 210.99999998944622, + ), + ( + latitude: 45.75888926677419, + longitude: -122.30369643059493, + x: 389.99999999668154, + y: 210.99999998944622, + ), + ( + latitude: 45.50000000001279, + longitude: -122.86779174542974, + x: -6.355945532887487e-10, + y: 421.99999998851314, + ), + ( + latitude: 45.50000000001279, + longitude: -122.73389587271782, + x: 97.499999997535099, + y: 421.99999998851314, + ), + ( + latitude: 45.50000000001279, + longitude: -122.59999999999958, + x: 195.00000000030795, + y: 421.99999998851314, + ), + ( + latitude: 45.50000000001279, + longitude: -122.46610412728765, + x: 292.49999999849632, + y: 421.99999998851314, + ), + ( + latitude: 45.50000000001279, + longitude: -122.33220825457572, + x: 389.9999999966729, + y: 421.99999998851314, + ), + ( + latitude: 45.285661200645052, + longitude: -122.84428537453516, + x: -6.4514321027470186e-10, + y: 632.99999998758756, + ), + ( + latitude: 45.285661200645052, + longitude: -122.72214268727026, + x: 97.499999997545572, + y: 632.99999998758756, + ), + ( + latitude: 45.285661200645052, + longitude: -122.59999999999961, + x: 195.00000000031682, + y: 632.99999998758756, + ), + ( + latitude: 45.285661200645052, + longitude: -122.47785731273473, + x: 292.49999999848819, + y: 632.99999998758756, + ), + ( + latitude: 45.285661200645052, + longitude: -122.35571462546984, + x: 389.99999999665954, + y: 632.99999998758756, + ), + ( + latitude: 45.105287926002148, + longitude: -122.8245727080394, + x: -6.3861338020665235e-10, + y: 843.99999998661929, + ), + ( + latitude: 45.105287926002148, + longitude: -122.71228635402217, + x: 97.499999997539177, + y: 843.99999998661929, + ), + ( + latitude: 45.105287926002148, + longitude: -122.59999999999964, + x: 195.00000000031358, + y: 843.99999998661929, + ), + ( + latitude: 45.105287926002148, + longitude: -122.48771364598242, + x: 292.49999999849138, + y: 843.99999998661929, + ), + ( + latitude: 45.105287926002148, + longitude: -122.3754272919652, + x: 389.9999999966621, + y: 843.99999998661929, + ), + ], + ), + ( + width: 390, + height: 844, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 30, + pitch: 30, + points: [ + ( + latitude: 45.885456677510717, + longitude: -123.30151151813797, + x: -7.3901997816833138e-10, + y: -8.6211002781055868e-09, + ), + ( + latitude: 45.943134750067486, + longitude: -123.15792011222166, + x: 97.49999999942311, + y: -7.8131279224180616e-09, + ), + ( + latitude: 46.000752878382059, + longitude: -123.01432870630546, + x: 194.99999999965573, + y: -7.0622263592667878e-09, + ), + ( + latitude: 46.058311066546025, + longitude: -122.87073730039697, + x: 292.49999999592194, + y: -7.851213013054803e-09, + ), + ( + latitude: 46.115809318889148, + longitude: -122.7271458944807, + x: 389.99999999627505, + y: -7.1097474574344233e-09, + ), + ( + latitude: 45.62075176735533, + longitude: -123.04171395431624, + x: -1.0212167812998478e-09, + y: 210.99999999062538, + ), + ( + latitude: 45.672536844795928, + longitude: -122.91341074514455, + x: 97.499999999153189, + y: 210.99999999155375, + ), + ( + latitude: 45.724274050260732, + longitude: -122.78510753597291, + x: 194.99999999942762, + y: 210.99999999240003, + ), + ( + latitude: 45.775963385857523, + longitude: -122.65680432680823, + x: 292.4999999956907, + y: 210.99999999153033, + ), + ( + latitude: 45.827604853848442, + longitude: -122.52850111763655, + x: 389.99999999607809, + y: 210.99999999237548, + ), + ( + latitude: 45.406072926750852, + longitude: -122.8319144544745, + x: -1.2976722129654012e-09, + y: 421.99999998998572, + ), + ( + latitude: 45.453056017457726, + longitude: -122.71595722724091, + x: 97.499999998896641, + y: 421.9999999909943, + ), + ( + latitude: 45.500000000007361, + longitude: -122.60000000000736, + x: 194.99999999915187, + y: 421.99999999198383, + ), + ( + latitude: 45.546904875465714, + longitude: -122.48404277278007, + x: 292.49999999547884, + y: 421.99999999099185, + ), + ( + latitude: 45.593770645002877, + longitude: -122.36808554554646, + x: 389.99999999586657, + y: 421.99999999199804, + ), + ( + latitude: 45.228466561526659, + longitude: -122.65894674605977, + x: -1.5741494330714824e-09, + y: 632.99999998941598, + ), + ( + latitude: 45.271461710772627, + longitude: -122.55316807599897, + x: 97.499999998630457, + y: 632.99999999054978, + ), + ( + latitude: 45.314424313634731, + longitude: -122.44738940593818, + x: 194.9999999989183, + y: 632.99999999165379, + ), + ( + latitude: 45.357354370612057, + longitude: -122.34161073588314, + x: 292.49999999526881, + y: 632.99999999053694, + ), + ( + latitude: 45.400251882276422, + longitude: -122.23583206582232, + x: 389.99999999566893, + y: 632.99999999165379, + ), + ( + latitude: 45.079094996565658, + longitude: -122.51389437852043, + x: -1.8316273871877447e-09, + y: 843.99999998894316, + ), + ( + latitude: 45.118725623058879, + longitude: -122.41665154344051, + x: 97.499999998357708, + y: 843.99999999022407, + ), + ( + latitude: 45.158328742912232, + longitude: -122.31940870836057, + x: 194.99999999871244, + y: 843.99999999139175, + ), + ( + latitude: 45.197904356309465, + longitude: -122.22216587328595, + x: 292.49999999504155, + y: 843.99999999022407, + ), + ( + latitude: 45.237452463486875, + longitude: -122.12492303820599, + x: 389.99999999547748, + y: 843.99999999140584, + ), + ], + ), + ( + width: 390, + height: 844, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 200, + pitch: 30, + points: [ + ( + latitude: 45.03177800322527, + longitude: -122.0049708644467, + x: 8.0645406600052323e-09, + y: -2.3781694835633971e-08, + ), + ( + latitude: 44.991687128983443, + longitude: -122.16077666870473, + x: 97.500000007632593, + y: -2.7127498469781131e-08, + ), + ( + latitude: 44.951568190965276, + longitude: -122.31658247297068, + x: 195.00000000801944, + y: -2.3201891963253729e-08, + ), + ( + latitude: 44.911421189187251, + longitude: -122.47238827722876, + x: 292.5000000072763, + y: -2.6519160201132763e-08, + ), + ( + latitude: 44.871246123765786, + longitude: -122.62819408148685, + x: 390.00000000369619, + y: -2.3781694835633971e-08, + ), + ( + latitude: 45.326928022961255, + longitude: -122.19494471034189, + x: 7.0413927594831065e-09, + y: 210.99999997542238, + ), + ( + latitude: 45.291292112982575, + longitude: -122.33416184918335, + x: 97.500000006738986, + y: 210.99999997175473, + ), + ( + latitude: 45.255633798165263, + longitude: -122.47337898803175, + x: 195.00000000722764, + y: 210.9999999760754, + ), + ( + latitude: 45.219953078223568, + longitude: -122.6125961268732, + x: 292.50000000657775, + y: 210.99999997238638, + ), + ( + latitude: 45.184249952945493, + longitude: -122.75181326571466, + x: 390.00000000308717, + y: 210.99999997554073, + ), + ( + latitude: 45.564159802642564, + longitude: -122.3483580729059, + x: 9.859071994322615e-09, + y: 421.99999996763, + ), + ( + latitude: 45.532089050445393, + longitude: -122.47417903644737, + x: 97.500000005810989, + y: 421.9999999710675, + ), + ( + latitude: 45.499999999963819, + longitude: -122.59999999999519, + x: 195.00000000958227, + y: 421.99999996831417, + ), + ( + latitude: 45.46789265084638, + longitude: -122.72582096353671, + x: 292.50000000588744, + y: 421.99999997181084, + ), + ( + latitude: 45.435767002728738, + longitude: -122.85164192707822, + x: 390.00000000500836, + y: 421.99999996771521, + ), + ( + latitude: 45.758993601141924, + longitude: -122.47483863069036, + x: 8.8387643915676645e-09, + y: 632.99999996702786, + ), + ( + latitude: 45.729840308950514, + longitude: -122.58961521259654, + x: 97.500000004914142, + y: 632.99999997082875, + ), + ( + latitude: 45.700671792192963, + longitude: -122.70439179450848, + x: 195.00000000878296, + y: 632.99999996784959, + ), + ( + latitude: 45.671488050498397, + longitude: -122.81916837641467, + x: 292.50000000520714, + y: 632.9999999716506, + ), + ( + latitude: 45.642289083479142, + longitude: -122.93394495832086, + x: 390.00000000443191, + y: 632.99999996722158, + ), + ( + latitude: 45.921860415651025, + longitude: -122.58090643098951, + x: 7.829264072766549e-09, + y: 843.99999996695442, + ), + ( + latitude: 45.895138537213626, + longitude: -122.68642108927483, + x: 97.500000007495018, + y: 843.99999996212819, + ), + ( + latitude: 45.868403794291908, + longitude: -122.79193574756545, + x: 195.00000000799474, + y: 843.99999996782151, + ), + ( + latitude: 45.841656186501524, + longitude: -122.89745040585076, + x: 292.50000000733007, + y: 843.99999996302097, + ), + ( + latitude: 45.814895713498629, + longitude: -123.00296506413611, + x: 390.00000000384222, + y: 843.99999996715837, + ), + ], + ), + ( + width: 390, + height: 844, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 0, + pitch: 45, + points: [ + ( + latitude: 46.355082357881315, + longitude: -123.00168761618913, + x: -3.978351112242783e-09, + y: -9.0512912720441818e-09, + ), + ( + latitude: 46.355082357881315, + longitude: -122.80084380810203, + x: 97.499999994385789, + y: -9.0512912720441818e-09, + ), + ( + latitude: 46.355082357881315, + longitude: -122.60000000000545, + x: 194.99999999735994, + y: -9.0512912720441818e-09, + ), + ( + latitude: 46.355082357881315, + longitude: -122.39915619191834, + x: 292.49999999573583, + y: -9.0512912720441818e-09, + ), + ( + latitude: 46.355082357881315, + longitude: -122.19831238383122, + x: 389.99999999411176, + y: -9.0512912720441818e-09, + ), + ( + latitude: 45.843614805704192, + longitude: -122.92135009389416, + x: -3.643879274800962e-09, + y: 210.99999998732369, + ), + ( + latitude: 45.843614805704192, + longitude: -122.76067504695305, + x: 97.499999994555452, + y: 210.99999998732369, + ), + ( + latitude: 45.843614805704192, + longitude: -122.60000000000434, + x: 194.9999999973746, + y: 210.99999998732369, + ), + ( + latitude: 45.843614805704192, + longitude: -122.43932495306325, + x: 292.49999999556411, + y: 210.99999998732369, + ), + ( + latitude: 45.843614805704192, + longitude: -122.27864990612215, + x: 389.99999999375854, + y: 210.99999998732369, + ), + ( + latitude: 45.50000000001441, + longitude: -122.86779174543562, + x: -4.3138037736963606e-09, + y: 421.99999998942803, + ), + ( + latitude: 45.50000000001441, + longitude: -122.7338958727228, + x: 97.499999994213425, + y: 421.99999998942803, + ), + ( + latitude: 45.50000000001441, + longitude: -122.60000000000363, + x: 194.99999999736639, + y: 421.99999998942803, + ), + ( + latitude: 45.50000000001441, + longitude: -122.46610412729079, + x: 292.49999999589949, + y: 421.99999998942803, + ), + ( + latitude: 45.50000000001441, + longitude: -122.33220825457795, + x: 389.99999999444441, + y: 421.99999998942803, + ), + ( + latitude: 45.253269109226949, + longitude: -122.82953578212266, + x: -3.9754085198575347e-09, + y: 632.99999998543001, + ), + ( + latitude: 45.253269109226949, + longitude: -122.71476789106561, + x: 97.499999994385604, + y: 632.99999998543001, + ), + ( + latitude: 45.253269109226949, + longitude: -122.60000000000312, + x: 194.99999999736056, + y: 632.99999998543001, + ), + ( + latitude: 45.253269109226949, + longitude: -122.48523210894605, + x: 292.49999999572844, + y: 632.99999998543001, + ), + ( + latitude: 45.253269109226949, + longitude: -122.37046421788898, + x: 389.99999999410318, + y: 632.99999998543001, + ), + ( + latitude: 45.067514425893393, + longitude: -122.8008438095648, + x: -1.4516665687726282e-09, + y: 843.99999998691817, + ), + ( + latitude: 45.067514425893393, + longitude: -122.7004219047846, + x: 97.499999997134893, + y: 843.99999998691817, + ), + ( + latitude: 45.067514425893393, + longitude: -122.59999999999971, + x: 195.00000000028834, + y: 843.99999998691817, + ), + ( + latitude: 45.067514425893393, + longitude: -122.49957809521953, + x: 292.49999999886705, + y: 843.99999998691817, + ), + ( + latitude: 45.067514425893393, + longitude: -122.39915619043936, + x: 389.99999999744574, + y: 843.99999998691817, + ), + ], + ), + ( + width: 390, + height: 844, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 30, + pitch: 45, + points: [ + ( + latitude: 46.102203625726361, + longitude: -123.56255479666302, + x: 3.2544795341347097e-09, + y: -7.2034254117170349e-09, + ), + ( + latitude: 46.171789606334841, + longitude: -123.38861895666281, + x: 97.500000003314867, + y: -6.6423808675608598e-09, + ), + ( + latitude: 46.241287656216656, + longitude: -123.21468311666263, + x: 195.00000000352722, + y: -6.1165792430983856e-09, + ), + ( + latitude: 46.310697784360173, + longitude: -123.04074727666227, + x: 292.50000000383375, + y: -5.469246389111504e-09, + ), + ( + latitude: 46.380020000259606, + longitude: -122.8668114366716, + x: 390.00000000002058, + y: -5.998913366056513e-09, + ), + ( + latitude: 45.685567270239886, + longitude: -123.12417059220849, + x: 3.3251011417503296e-09, + y: 210.99999998965711, + ), + ( + latitude: 45.741662553368542, + longitude: -122.98502191980009, + x: 97.500000003191587, + y: 210.99999999035572, + ), + ( + latitude: 45.797701532586785, + longitude: -122.84587324739169, + x: 195.00000000321265, + y: 210.99999999103056, + ), + ( + latitude: 45.853684210854226, + longitude: -122.7067245749833, + x: 292.50000000334484, + y: 210.99999999172417, + ), + ( + latitude: 45.909610591342613, + longitude: -122.56757590257492, + x: 390.00000000358136, + y: 210.99999999241777, + ), + ( + latitude: 45.406072926759776, + longitude: -122.83191445447018, + x: 7.1298407528296448e-09, + y: 421.99999998725269, + ), + ( + latitude: 45.453056017466764, + longitude: -122.71595722723639, + x: 97.500000006958274, + y: 421.99999998805987, + ), + ( + latitude: 45.500000000016428, + longitude: -122.6000000000089, + x: 195.00000000290507, + y: 421.99999998728003, + ), + ( + latitude: 45.54690487547478, + longitude: -122.48404277277504, + x: 292.500000002852, + y: 421.99999998818106, + ), + ( + latitude: 45.593770645012029, + longitude: -122.36808554554128, + x: 390.00000000291243, + y: 421.99999998898932, + ), + ( + latitude: 45.205583259386486, + longitude: -122.6231600696768, + x: 1.7302296148648449e-09, + y: 632.99999998933367, + ), + ( + latitude: 45.245999614861717, + longitude: -122.5237681604804, + x: 97.500000001791634, + y: 632.99999999028353, + ), + ( + latitude: 45.286387235277516, + longitude: -122.424376251284, + x: 195.0000000019632, + y: 632.99999999127783, + ), + ( + latitude: 45.326746121017948, + longitude: -122.3249843420876, + x: 292.50000000646537, + y: 632.99999998565386, + ), + ( + latitude: 45.367076272500981, + longitude: -122.2255924328912, + x: 390.00000000649408, + y: 632.99999998657461, + ), + ( + latitude: 45.054750659595669, + longitude: -122.46659428069901, + x: 1.7890809604311544e-09, + y: 843.99999998491262, + ), + ( + latitude: 45.090210220429014, + longitude: -122.37962636006104, + x: 97.500000001669079, + y: 843.99999998601811, + ), + ( + latitude: 45.125647780279593, + longitude: -122.29265843942306, + x: 195.00000000169609, + y: 843.9999999870613, + ), + ( + latitude: 45.161063339249807, + longitude: -122.20569051878506, + x: 292.50000000180285, + y: 843.99999998824478, + ), + ( + latitude: 45.196456897475912, + longitude: -122.1187225981471, + x: 390.00000000207024, + y: 843.99999998932947, + ), + ], + ), + ( + width: 390, + height: 844, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 200, + pitch: 45, + points: [ + ( + latitude: 44.78206559003371, + longitude: -121.80206909591175, + x: -3.8738899699572842e-09, + y: 9.4240704129333608e-09, + ), + ( + latitude: 44.73328757444466, + longitude: -121.99080054030024, + x: 97.499999994112883, + y: 1.1389715837140102e-08, + ), + ( + latitude: 44.684468382410273, + longitude: -122.17953198468855, + x: 194.99999999566856, + y: 9.1964693638146855e-09, + ), + ( + latitude: 44.635608014671021, + longitude: -122.36826342907699, + x: 292.4999999940535, + y: 1.1158249435538892e-08, + ), + ( + latitude: 44.586706472033171, + longitude: -122.55699487347482, + x: 389.99999999954287, + y: 9.7967358669848181e-09, + ), + ( + latitude: 45.252610995267133, + longitude: -122.12984248147586, + x: -5.4449757267122012e-09, + y: 211.00000001325793, + ), + ( + latitude: 45.213911099849156, + longitude: -122.28082763742981, + x: 97.499999996362973, + y: 211.00000001059993, + ), + ( + latitude: 45.175184851115603, + longitude: -122.43181279338373, + x: 194.9999999945278, + y: 211.00000001303192, + ), + ( + latitude: 45.136432248803004, + longitude: -122.58279794933756, + x: 292.49999999578745, + y: 211.00000001030389, + ), + ( + latitude: 45.097653292739125, + longitude: -122.73378310529901, + x: 389.99999999883823, + y: 211.00000001365061, + ), + ( + latitude: 45.564159802694348, + longitude: -122.34835807292103, + x: -2.9352109579392102e-09, + y: 422.00000001202216, + ), + ( + latitude: 45.532089050497348, + longitude: -122.47417903646212, + x: 97.499999995008608, + y: 422.00000001485233, + ), + ( + latitude: 45.50000000001603, + longitude: -122.60000000000326, + x: 194.99999999654352, + y: 422.00000001162152, + ), + ( + latitude: 45.467892650898818, + longitude: -122.72582096354436, + x: 292.49999999487375, + y: 422.00000001451713, + ), + ( + latitude: 45.435767002781375, + longitude: -122.85164192709175, + x: 390.0000000003468, + y: 422.00000001233593, + ), + ( + latitude: 45.785642668623098, + longitude: -122.50444063876253, + x: -4.5255460115831626e-09, + y: 633.00000001675699, + ), + ( + latitude: 45.758263124000877, + longitude: -122.6122871790914, + x: 97.499999997264666, + y: 633.00000001297292, + ), + ( + latitude: 45.730870138145235, + longitude: -122.7201337194202, + x: 194.99999999539082, + y: 633.00000001625642, + ), + ( + latitude: 45.703463710707069, + longitude: -122.82798025974905, + x: 292.49999999663049, + y: 633.00000001249452, + ), + ( + latitude: 45.676043841379396, + longitude: -122.93582680007788, + x: 389.99999999516876, + y: 633.00000001579178, + ), + ( + latitude: 45.951179611646666, + longitude: -122.62150256343003, + x: -1.9921181089357594e-09, + y: 844.00000001426247, + ), + ( + latitude: 45.92729446915169, + longitude: -122.71586828631669, + x: 97.499999995905625, + y: 844.00000001811225, + ), + ( + latitude: 45.903399037486452, + longitude: -122.81023400920336, + x: 194.99999999741692, + y: 844.00000001373689, + ), + ( + latitude: 45.879493316389869, + longitude: -122.90459973208999, + x: 292.49999999571918, + y: 844.00000001754518, + ), + ( + latitude: 45.855577305581477, + longitude: -122.99896545497663, + x: 389.99999999666102, + y: 844.00000001314129, + ), + ], + ), + ( + width: 390, + height: 844, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 0, + pitch: 60, + points: [ + ( + latitude: 47.389360540422445, + longitude: -123.23360206414459, + x: -3.2708661077335823e-09, + y: 2.4786004360066727e-09, + ), + ( + latitude: 47.389360540422445, + longitude: -122.91680103207946, + x: 97.499999996162259, + y: 2.4786004360066727e-09, + ), + ( + latitude: 47.389360540422445, + longitude: -122.60000000001435, + x: 194.99999999558793, + y: 2.4786004360066727e-09, + ), + ( + latitude: 47.389360540422445, + longitude: -122.2831989679492, + x: 292.49999999502847, + y: 2.4786004360066727e-09, + ), + ( + latitude: 47.389360540422445, + longitude: -121.96639793588406, + x: 389.99999999446413, + y: 2.4786004360066727e-09, + ), + ( + latitude: 46.068147680433412, + longitude: -122.97646897696221, + x: -3.7341284906466876e-09, + y: 211.00000000522311, + ), + ( + latitude: 46.068147680433412, + longitude: -122.78823448848536, + x: 97.499999995936875, + y: 211.00000000522311, + ), + ( + latitude: 46.068147680433412, + longitude: -122.60000000000852, + x: 194.99999999559114, + y: 211.00000000522311, + ), + ( + latitude: 46.068147680433412, + longitude: -122.41176551153168, + x: 292.49999999525375, + y: 211.00000000522311, + ), + ( + latitude: 46.068147680433412, + longitude: -122.22353102305482, + x: 389.99999999492894, + y: 211.00000000522311, + ), + ( + latitude: 45.499999999991587, + longitude: -122.86779174542994, + x: -2.9719930501335772e-09, + y: 422.00000000439763, + ), + ( + latitude: 45.499999999991587, + longitude: -122.73389587271799, + x: 97.49999999631828, + y: 422.00000000439763, + ), + ( + latitude: 45.499999999991587, + longitude: -122.60000000000606, + x: 194.99999999560268, + y: 422.00000000439763, + ), + ( + latitude: 45.499999999991587, + longitude: -122.46610412729413, + x: 292.49999999487528, + y: 422.00000000439763, + ), + ( + latitude: 45.499999999991587, + longitude: -122.33220825458218, + x: 389.99999999416559, + y: 422.00000000439763, + ), + ( + latitude: 45.183912452909794, + longitude: -122.80780392111544, + x: -1.4030449585602983e-09, + y: 633.0000000119918, + ), + ( + latitude: 45.183912452909794, + longitude: -122.70390196055851, + x: 97.499999998567461, + y: 633.0000000119918, + ), + ( + latitude: 45.183912452909794, + longitude: -122.60000000000159, + x: 194.99999999853037, + y: 633.0000000119918, + ), + ( + latitude: 45.183912452909794, + longitude: -122.49609803944466, + x: 292.49999999848569, + y: 633.0000000119918, + ), + ( + latitude: 45.183912452909794, + longitude: -122.39219607888772, + x: 389.99999999846381, + y: 633.0000000119918, + ), + ( + latitude: 44.982606902673581, + longitude: -122.76977316428415, + x: -6.4052140087366983e-10, + y: 844.00000001015553, + ), + ( + latitude: 44.982606902673581, + longitude: -122.68488658214272, + x: 97.499999998939003, + y: 844.00000001015553, + ), + ( + latitude: 44.982606902673581, + longitude: -122.6000000000013, + x: 194.99999999852781, + y: 844.00000001015553, + ), + ( + latitude: 44.982606902673581, + longitude: -122.51511341785988, + x: 292.49999999810734, + y: 844.00000001015553, + ), + ( + latitude: 44.982606902673581, + longitude: -122.43022683571844, + x: 389.99999999769614, + y: 844.00000001015553, + ), + ], + ), + ( + width: 390, + height: 844, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 30, + pitch: 60, + points: [ + ( + latitude: 46.924106945287207, + longitude: -124.51989533505096, + x: 4.0058782254419445e-09, + y: -3.8426151149906218e-10, + ), + ( + latitude: 47.032179968702707, + longitude: -124.24553759333024, + x: 97.500000004334055, + y: -1.3426415534922853e-10, + ), + ( + latitude: 47.140034585163704, + longitude: -123.97117985162448, + x: 195.00000000084242, + y: -3.7687186704715714e-10, + ), + ( + latitude: 47.247670854642962, + longitude: -123.69682210990378, + x: 292.50000000131979, + y: -1.2937562132719904e-10, + ), + ( + latitude: 47.355088840077457, + longitude: -123.42246436818306, + x: 390.00000000196866, + y: 1.1687006917782128e-10, + ), + ( + latitude: 45.861434987034812, + longitude: -123.333390436949, + x: 5.3039695040255465e-09, + y: 210.99999999878082, + ), + ( + latitude: 45.926939227269344, + longitude: -123.17037458806406, + x: 97.500000001882256, + y: 210.99999999837064, + ), + ( + latitude: 45.992366206879439, + longitude: -123.00735873917021, + x: 195.00000000230486, + y: 210.99999999878673, + ), + ( + latitude: 46.05771593177775, + longitude: -122.84434289027638, + x: 292.50000000286281, + y: 210.99999999920487, + ), + ( + latitude: 46.122988408272221, + longitude: -122.68132704139143, + x: 389.99999999929327, + y: 210.99999999879435, + ), + ( + latitude: 45.406072926748067, + longitude: -122.83191445446531, + x: 2.9307971068353265e-09, + y: 421.99999999713947, + ), + ( + latitude: 45.453056017455026, + longitude: -122.7159572272316, + x: 97.500000003282864, + y: 421.99999999770284, + ), + ( + latitude: 45.50000000000469, + longitude: -122.59999999999786, + x: 195.00000000378006, + y: 421.99999999828663, + ), + ( + latitude: 45.546904875462985, + longitude: -122.48404277277038, + x: 292.50000000027262, + y: 421.99999999780221, + ), + ( + latitude: 45.593770645000177, + longitude: -122.36808554553664, + x: 390.00000000092837, + y: 421.99999999838894, + ), + ( + latitude: 45.153136483186131, + longitude: -122.55510897543292, + x: 4.2356789694229189e-09, + y: 632.99999999673048, + ), + ( + latitude: 45.189761283885929, + longitude: -122.46512723808996, + x: 97.500000000818147, + y: 632.99999999599083, + ), + ( + latitude: 45.22636253272205, + longitude: -122.37514550074208, + x: 195.00000000123865, + y: 632.99999999674355, + ), + ( + latitude: 45.262940229913028, + longitude: -122.28516376339419, + x: 292.50000000184059, + y: 632.99999999747001, + ), + ( + latitude: 45.299494375715653, + longitude: -122.19518202605124, + x: 389.99999999825496, + y: 632.99999999676061, + ), + ( + latitude: 44.992197168980084, + longitude: -122.37962133300876, + x: 1.8705081489032543e-09, + y: 843.99999999477745, + ), + ( + latitude: 45.022205334578615, + longitude: -122.30610739643173, + x: 97.500000002207543, + y: 843.99999999571673, + ), + ( + latitude: 45.052197779715129, + longitude: -122.2325934598547, + x: 195.00000000270944, + y: 843.99999999663601, + ), + ( + latitude: 45.082174504411029, + longitude: -122.15907952328169, + x: 292.49999999922881, + y: 843.9999999957214, + ), + ( + latitude: 45.112135508704796, + longitude: -122.08556558670466, + x: 389.99999999987716, + y: 843.99999999667591, + ), + ], + ), + ( + width: 390, + height: 844, + latitude: 45.5, + longitude: -122.59999999999999, + zoom: 10, + rotation: 200, + pitch: 60, + points: [ + ( + latitude: 43.821359349268192, + longitude: -121.06666655706283, + x: 4.2356472419320847e-09, + y: -1.191438059322536e-09, + ), + ( + latitude: 43.743131725573932, + longitude: -121.36436214914949, + x: 97.500000003757648, + y: -2.1739197109127417e-09, + ), + ( + latitude: 43.664801747732099, + longitude: -121.66205774125063, + x: 195.00000000384239, + y: -1.0882104106713086e-09, + ), + ( + latitude: 43.586369428370347, + longitude: -121.9597533333374, + x: 292.50000000281369, + y: -2.0719426174764521e-09, + ), + ( + latitude: 43.507834780983245, + longitude: -122.25744892543914, + x: 390.00000000370511, + y: -8.9175955508835614e-10, + ), + ( + latitude: 45.051874759922185, + longitude: -121.96758509168565, + x: 4.5896918429021231e-09, + y: 210.99999999708189, + ), + ( + latitude: 45.006374384716395, + longitude: -122.14446765149211, + x: 97.500000004365404, + y: 210.99999999907186, + ), + ( + latitude: 44.960837839453887, + longitude: -122.32135021128951, + x: 195.0000000037094, + y: 210.99999999734575, + ), + ( + latitude: 44.915265124193269, + longitude: -122.49823277109601, + x: 292.50000000432169, + y: 210.99999999932641, + ), + ( + latitude: 44.86965623903356, + longitude: -122.6751153308936, + x: 390.00000000312014, + y: 210.99999999767158, + ), + ( + latitude: 45.564159802676215, + longitude: -122.34835807291989, + x: 4.7110503695367135e-09, + y: 421.99999999950785, + ), + ( + latitude: 45.532089050470688, + longitude: -122.47417903645984, + x: 97.500000004442668, + y: 421.99999999719699, + ), + ( + latitude: 45.499999999998408, + longitude: -122.60000000000613, + x: 195.00000000477999, + y: 421.99999999995617, + ), + ( + latitude: 45.467892650872358, + longitude: -122.72582096354604, + x: 292.50000000398103, + y: 421.99999999756341, + ), + ( + latitude: 45.435767002764095, + longitude: -122.85164192708601, + x: 390.00000000054519, + y: 421.99999999956532, + ), + ( + latitude: 45.844948717542366, + longitude: -122.55853772488763, + x: 5.0187297368259957e-09, + y: 632.99999999730824, + ), + ( + latitude: 45.820188351824186, + longitude: -122.65617363050725, + x: 97.500000000837147, + y: 632.99999999988847, + ), + ( + latitude: 45.795416970194111, + longitude: -122.75380953613177, + x: 195.00000000465135, + y: 632.99999999790487, + ), + ( + latitude: 45.770634572397057, + longitude: -122.85144544175135, + x: 292.50000000104279, + y: 633.00000000044179, + ), + ( + latitude: 45.745841158158925, + longitude: -122.94908134737588, + x: 390.0000000045186, + y: 632.99999999842237, + ), + ( + latitude: 46.022231351383624, + longitude: -122.69178628535366, + x: 5.3562441782460104e-09, + y: 843.99999999413603, + ), + ( + latitude: 46.002067819153609, + longitude: -122.77155358019894, + x: 97.500000005179402, + y: 843.99999999851912, + ), + ( + latitude: 45.98189693563603, + longitude: -122.85132087504022, + x: 195.0000000045404, + y: 843.99999999482668, + ), + ( + latitude: 45.961718700662743, + longitude: -122.93108816988548, + x: 292.50000000513813, + y: 843.99999999919453, + ), + ( + latitude: 45.941533114046877, + longitude: -123.01085546472675, + x: 390.00000000392993, + y: 843.99999999549266, + ), + ], + ), +]; diff --git a/test/map/camera/pitch_reference_test.dart b/test/map/camera/pitch_reference_test.dart new file mode 100644 index 000000000..5f7afeb25 --- /dev/null +++ b/test/map/camera/pitch_reference_test.dart @@ -0,0 +1,100 @@ +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'; + +MapCamera _cameraOf(ReferenceCamera reference) => 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, + ); + +String _describe(ReferenceCamera reference) => + '${reference.width.toInt()}x${reference.height.toInt()} ' + 'pitch ${reference.pitch} rotation ${reference.rotation}'; + +void main() { + group('matches MapLibre Native', () { + for (final reference in referenceCameras) { + test('pixelToScreenTransform, ${_describe(reference)}', () { + final camera = _cameraOf(reference); + final transform = camera.pixelToScreenTransform; + for (final point in reference.points) { + final screen = MatrixUtils.transformPoint( + transform, + camera.projectAtZoom(LatLng(point.latitude, point.longitude)), + ); + expect(screen.dx, closeTo(point.x, 0.01)); + expect(screen.dy, closeTo(point.y, 0.01)); + } + }); + } + + for (final reference in referenceCameras) { + test('latLngToScreenOffset, ${_describe(reference)}', () { + final camera = _cameraOf(reference); + for (final point in reference.points) { + final screen = camera.latLngToScreenOffset( + LatLng(point.latitude, point.longitude), + ); + expect(screen.dx, closeTo(point.x, 0.01)); + expect(screen.dy, closeTo(point.y, 0.01)); + } + }); + + test('screenOffsetToLatLng, ${_describe(reference)}', () { + final camera = _cameraOf(reference); + for (final point in reference.points) { + final coordinate = + camera.screenOffsetToLatLng(Offset(point.x, point.y)); + expect(coordinate.latitude, closeTo(point.latitude, 1e-7)); + expect(coordinate.longitude, closeTo(point.longitude, 1e-7)); + } + }); + } + }); + + group('pitched projection', () { + final camera = MapCamera( + crs: const Epsg3857(), + center: const LatLng(45.5, -122.6), + zoom: 10, + rotation: 30, + nonRotatedSize: const Size(400, 300), + pitch: 60, + ); + + test('center point at the viewport center', () { + final screen = camera.latLngToScreenOffset(camera.center); + expect(screen.dx, closeTo(200, 1e-9)); + expect(screen.dy, closeTo(150, 1e-9)); + }); + + test('round trip', () { + for (double x = 0; x <= 400; x += 50) { + for (double y = 0; y <= 300; y += 50) { + final coordinate = camera.screenOffsetToLatLng(Offset(x, y)); + final screen = camera.latLngToScreenOffset(coordinate); + expect(screen.dx, closeTo(x, 0.01)); + expect(screen.dy, closeTo(y, 0.01)); + expect(camera.maybeScreenOffsetToLatLng(Offset(x, y)), coordinate); + } + } + }); + + test('above the horizon', () { + const above = Offset(200, -1000); + expect(camera.maybeScreenOffsetToLatLng(above), isNull); + final distant = camera.screenOffsetToLatLng(above); + expect( + const Distance().distance(camera.center, distant), + greaterThan(100000), + ); + }); + }); +} diff --git a/test/map/camera/pitched_bounds_test.dart b/test/map/camera/pitched_bounds_test.dart new file mode 100644 index 000000000..2c81afd05 --- /dev/null +++ b/test/map/camera/pitched_bounds_test.dart @@ -0,0 +1,84 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +MapCamera _camera({double pitch = 60, double rotation = 30}) => MapCamera( + crs: const Epsg3857(), + center: const LatLng(45.5, -122.6), + zoom: 10, + rotation: rotation, + nonRotatedSize: const Size(400, 300), + pitch: pitch, + ); + +Offset _round(Offset o) => Offset(o.dx.roundToDouble(), o.dy.roundToDouble()); + +void main() { + test('visible bounds contain the coordinates across the viewport', () { + final camera = _camera(); + final bounds = camera.visibleBounds; + for (double x = 0; x <= 400; x += 100) { + for (double y = 0; y <= 300; y += 75) { + final coordinate = camera.screenOffsetToLatLng(Offset(x, y)); + expect(bounds.contains(coordinate), isTrue, reason: '($x, $y)'); + } + } + }); + + test('pixel bounds, origin and size describe the same rectangle', () { + final camera = _camera(); + expect(camera.pixelBounds.topLeft, camera.pixelOrigin); + expect(camera.pixelBounds.size, camera.size); + for (double x = 0; x <= 400; x += 100) { + for (double y = 0; y <= 300; y += 75) { + final pixel = + camera.projectAtZoom(camera.screenOffsetToLatLng(Offset(x, y))); + expect(camera.pixelBounds.inflate(1e-6).contains(pixel), isTrue, + reason: '($x, $y)'); + } + } + }); + + test('far edge extends further than the near edge', () { + final camera = _camera(rotation: 0); + final center = camera.projectAtZoom(camera.center); + expect(center.dy - camera.pixelBounds.top, + greaterThan(3 * (camera.pixelBounds.bottom - center.dy))); + expect(camera.size.width, greaterThan(2 * 400)); + }); + + test('pixel bounds and origin at another center and zoom', () { + final camera = _camera(); + const center = LatLng(45.6, -122.4); + final moved = camera.withPosition(center: center, zoom: 11); + expect(camera.getNewPixelOrigin(center, 11), _round(moved.pixelOrigin)); + final atZoom = camera.pixelBoundsAtZoom(11); + expect(atZoom.size.width, closeTo(camera.size.width * 2, 1e-6)); + expect(atZoom.size.height, closeTo(camera.size.height * 2, 1e-6)); + }); + + test('values at pitch 0 are unchanged', () { + for (final rotation in [0.0, 30.0]) { + final camera = _camera(pitch: 0, rotation: rotation); + final projectedCenter = camera.projectAtZoom(camera.center); + final size = + MapCamera.calculateRotatedSize(rotation, const Size(400, 300)); + expect(camera.size, size); + expect(camera.pixelOrigin, projectedCenter - size.center(Offset.zero)); + expect( + camera.pixelBounds, + Rect.fromCenter( + center: Offset(projectedCenter.dx.floorToDouble(), + projectedCenter.dy.floorToDouble()), + width: size.width, + height: size.height, + ), + ); + expect( + camera.getNewPixelOrigin(camera.center), + _round(projectedCenter - size.center(Offset.zero)), + ); + } + }); +} diff --git a/test/map/camera/pitched_focal_point_test.dart b/test/map/camera/pitched_focal_point_test.dart new file mode 100644 index 000000000..b72c0eb6e --- /dev/null +++ b/test/map/camera/pitched_focal_point_test.dart @@ -0,0 +1,70 @@ +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'; + +MapCamera _camera({double pitch = 50}) => MapCamera( + crs: const Epsg3857(), + center: const LatLng(45.5, -122.6), + zoom: 10, + rotation: 30, + nonRotatedSize: const Size(400, 300), + pitch: pitch, + ); + +void main() { + for (final focal in const [ + Offset(320, 20), + Offset(60, 280), + Offset(200, 5) + ]) { + for (final zoom in [11.5, 8.25]) { + test('focusedZoomCenter keeps the coordinate at $focal, zoom $zoom', () { + final camera = _camera(); + final coordinate = camera.screenOffsetToLatLng(focal); + final zoomed = camera.withPosition( + center: camera.focusedZoomCenter(focal, zoom), + zoom: zoom, + ); + final screen = zoomed.latLngToScreenOffset(coordinate); + expect((screen - focal).distance, lessThan(0.5)); + }); + } + } + + test('focusedZoomCenter keeps the center above the horizon', () { + final camera = _camera(); + expect( + camera.focusedZoomCenter(const Offset(200, -1000), 12), camera.center); + }); + + test('offsetToCrs is the coordinate under the offset', () { + final camera = _camera(); + for (final offset in const [Offset(320, 20), Offset(60, 280)]) { + final coordinate = camera.offsetToCrs(offset); + final expected = camera.screenOffsetToLatLng(offset); + expect(coordinate.latitude, closeTo(expected.latitude, 1e-9)); + expect(coordinate.longitude, closeTo(expected.longitude, 1e-9)); + } + }); + + test('offsetToCrs and focusedZoomCenter at pitch 0 are unchanged', () { + final camera = _camera(pitch: 0); + const offset = Offset(320, 20); + final rotated = offset - const Offset(200, 150); + final angle = camera.rotationRad; + final ground = Offset( + rotated.dx * cos(angle) + rotated.dy * sin(angle), + rotated.dy * cos(angle) - rotated.dx * sin(angle), + ); + final center = camera.projectAtZoom(camera.center); + expect(camera.offsetToCrs(offset), camera.unprojectAtZoom(center + ground)); + final scale = camera.getZoomScale(12, 10); + expect( + camera.focusedZoomCenter(offset, 12), + camera.unprojectAtZoom(center + ground * (1 - 1 / scale)), + ); + }); +} diff --git a/test/map/pitch_controller_offsets_test.dart b/test/map/pitch_controller_offsets_test.dart new file mode 100644 index 000000000..6364a36e3 --- /dev/null +++ b/test/map/pitch_controller_offsets_test.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +Future _pump( + WidgetTester tester, { + required double pitch, + required double rotation, +}) async { + final controller = MapController(); + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + height: 600, + child: FlutterMap( + mapController: controller, + options: MapOptions( + initialCenter: const LatLng(45.5, -122.6), + initialZoom: 12, + initialRotation: rotation, + initialPitch: pitch, + maxPitch: 60, + ), + children: const [], + ), + ), + ), + )); + return controller; +} + +void main() { + const target = LatLng(45.52, -122.65); + + for (final pitch in [0.0, 60.0]) { + testWidgets('move with offset, pitch $pitch', (tester) async { + final controller = await _pump(tester, pitch: pitch, rotation: 30); + controller.move(target, 13, offset: const Offset(0, 200)); + final screen = controller.camera.latLngToScreenOffset(target); + expect((screen - const Offset(200, 500)).distance, lessThan(0.5)); + }); + } + + for (final pitch in [0.0, 45.0]) { + testWidgets('rotate around a point, pitch $pitch', (tester) async { + final controller = await _pump(tester, pitch: pitch, rotation: 0); + const point = Offset(250, 550); + final coordinate = controller.camera.screenOffsetToLatLng(point); + controller.rotateAroundPoint(90, offset: point - const Offset(200, 300)); + final screen = controller.camera.latLngToScreenOffset(coordinate); + expect((screen - point).distance, lessThan(0.5)); + }); + } + + testWidgets('move with offset at pitch 0 is unchanged', (tester) async { + final controller = await _pump(tester, pitch: 0, rotation: 30); + final camera = controller.camera; + const offset = Offset(30, 200); + final newPoint = camera.projectAtZoom(target, 13); + final expected = camera.unprojectAtZoom( + camera.rotatePoint(newPoint, newPoint - offset), + 13, + ); + controller.move(target, 13, offset: offset); + expect(controller.camera.center.latitude, closeTo(expected.latitude, 1e-9)); + expect( + controller.camera.center.longitude, closeTo(expected.longitude, 1e-9)); + }); + + testWidgets('offset above the horizon is ignored', (tester) async { + final controller = await _pump(tester, pitch: 60, rotation: 0); + controller.move(target, 13, offset: const Offset(0, -5000)); + expect(controller.camera.center.latitude, closeTo(target.latitude, 1e-9)); + expect(controller.camera.center.longitude, closeTo(target.longitude, 1e-9)); + }); +} diff --git a/test/map/pitch_controller_test.dart b/test/map/pitch_controller_test.dart new file mode 100644 index 000000000..7eb4f8013 --- /dev/null +++ b/test/map/pitch_controller_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late MapController controller; + late List events; + + Future pump(WidgetTester tester, {double initialPitch = 0}) async { + controller = MapController(); + events = []; + await tester.pumpWidget(MaterialApp( + home: SizedBox( + width: 400, + height: 300, + child: FlutterMap( + mapController: controller, + options: MapOptions( + initialPitch: initialPitch, + maxPitch: 60, + initialZoom: 12, + onMapEvent: events.add, + ), + children: const [], + ), + ), + )); + } + + testWidgets('pitch change', (tester) async { + await pump(tester); + final center = controller.camera.center; + expect(controller.pitch(40, id: 'p'), isTrue); + expect(controller.camera.pitch, 40); + expect(controller.camera.zoom, 12); + expect(controller.camera.center, center); + final pitchEvents = events.whereType().toList(); + expect(pitchEvents, hasLength(1)); + expect(pitchEvents.single.id, 'p'); + expect(pitchEvents.single.source, MapEventSource.mapController); + expect(pitchEvents.single.oldCamera.pitch, 0); + expect(pitchEvents.single.camera.pitch, 40); + }); + + testWidgets('unchanged pitch', (tester) async { + await pump(tester, initialPitch: 40); + events.clear(); + expect(controller.pitch(40), isFalse); + expect(events, isEmpty); + }); + + testWidgets('pitch above the maximum', (tester) async { + await pump(tester); + expect(controller.pitch(75), isTrue); + expect(controller.camera.pitch, 60); + events.clear(); + expect(controller.pitch(70), isFalse); + expect(events, isEmpty); + }); +} diff --git a/test/map/pitch_options_test.dart b/test/map/pitch_options_test.dart new file mode 100644 index 000000000..0717c5ad9 --- /dev/null +++ b/test/map/pitch_options_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future _pump(WidgetTester tester, MapOptions options) async { + final controller = MapController(); + await tester.pumpWidget(MaterialApp( + home: SizedBox( + width: 400, + height: 300, + child: FlutterMap( + mapController: controller, + options: options, + children: const [], + ), + ), + )); + return controller; +} + +void main() { + testWidgets('default maximum pitch keeps the map unpitched', (tester) async { + final controller = await _pump(tester, const MapOptions(initialPitch: 30)); + expect(controller.camera.pitch, 0); + }); + + testWidgets('initial pitch is limited to the maximum pitch', (tester) async { + final controller = + await _pump(tester, const MapOptions(initialPitch: 50, maxPitch: 45)); + expect(controller.camera.pitch, 45); + }); + + 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); + }); + + testWidgets('negative initial pitch is limited to 0', (tester) async { + final controller = + await _pump(tester, const MapOptions(initialPitch: -10, maxPitch: 45)); + expect(controller.camera.pitch, 0); + }); + + testWidgets('lowering the maximum pitch limits the pitch', (tester) async { + final controller = MapController(); + Widget map(double maxPitch) => MaterialApp( + home: SizedBox( + width: 400, + height: 300, + child: FlutterMap( + mapController: controller, + options: MapOptions(initialPitch: 40, maxPitch: maxPitch), + children: const [], + ), + ), + ); + await tester.pumpWidget(map(60)); + expect(controller.camera.pitch, 40); + await tester.pumpWidget(map(20)); + expect(controller.camera.pitch, 20); + }); +}