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);
+ });
+}