Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion lib/src/gestures/map_interactive_viewer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -706,7 +706,8 @@ class MapInteractiveViewerState extends State<MapInteractiveViewer>
final moveDifference = _rotateOffset(_focalStartLocal - _lastFocalLocal);

final newCenterPt = oldCenterPt + zoomDifference + moveDifference;
return _camera.unprojectAtZoom(newCenterPt, zoomAfterPinchZoom);
final newCenter = _camera.unprojectAtZoom(newCenterPt, zoomAfterPinchZoom);
return isFiniteMapLatLng(newCenter) ? newCenter : _camera.center;
}

void _handleScalePinchRotate(
Expand Down
40 changes: 37 additions & 3 deletions lib/src/map/camera/camera.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ 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';

/// Whether [latLng] has finite coordinates (not NaN or ±infinity).
@internal
bool isFiniteMapLatLng(LatLng latLng) =>
latLng.latitude.isFinite && latLng.longitude.isFinite;

/// 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
Expand Down Expand Up @@ -418,15 +424,43 @@ 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].
LatLng focusedZoomCenter(Offset cursorPos, double zoom) {
if (!zoom.isFinite ||
!this.zoom.isFinite ||
!nonRotatedSize.width.isFinite ||
!nonRotatedSize.height.isFinite ||
nonRotatedSize.width <= 0 ||
nonRotatedSize.height <= 0) {
return center;
}

// Calculate offset of mouse cursor from viewport center
final offset =
(cursorPos - nonRotatedSize.center(Offset.zero)).rotate(rotationRad);
// Match new center coordinate to mouse cursor position
final scale = getZoomScale(zoom, this.zoom);
final newOffset = offset * (1.0 - 1.0 / scale);
// scale == 0 makes (1 - 1/scale) non-finite; 0 * infinity => NaN offset
if (!scale.isFinite || scale <= 0) {
return center;
}

final zoomMultiplier = 1.0 - 1.0 / scale;
if (!zoomMultiplier.isFinite) {
return center;
}

final newOffset = offset * zoomMultiplier;
if (!newOffset.dx.isFinite || !newOffset.dy.isFinite) {
return center;
}

final mapCenter = projectAtZoom(center);
final newCenter = unprojectAtZoom(mapCenter + newOffset);
return newCenter;
final projected = mapCenter + newOffset;
if (!projected.dx.isFinite || !projected.dy.isFinite) {
return center;
}

final newCenter = unprojectAtZoom(projected);
return isFiniteMapLatLng(newCenter) ? newCenter : center;
}

@override
Expand Down
9 changes: 9 additions & 0 deletions lib/src/map/controller/map_controller_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState>
required MapEventSource source,
String? id,
}) {
if (!newZoom.isFinite || !isFiniteMapLatLng(newCenter)) {
return false;
}

// Algorithm thanks to https://github.com/tlserver/flutter_map_location_marker
LatLng center = newCenter;
if (offset != Offset.zero) {
Expand All @@ -160,13 +164,18 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState>
);
}

if (!isFiniteMapLatLng(center)) {
return false;
}

MapCamera? newCamera = camera.withPosition(
center: center,
zoom: camera.clampZoom(newZoom),
);

newCamera = options.cameraConstraint.constrain(newCamera);
if (newCamera == null ||
!isFiniteMapLatLng(newCamera.center) ||
(newCamera.center == camera.center && newCamera.zoom == camera.zoom)) {
return false;
}
Expand Down
162 changes: 162 additions & 0 deletions test/map/camera/camera_finite_latlng_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import 'dart:ui';

import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_map/src/layer/tile_layer/tile_range_calculator.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:latlong2/latlong.dart';

import '../../test_utils/test_app.dart';

void main() {
group('isFiniteMapLatLng', () {
test('returns true for normal coordinates', () {
expect(isFiniteMapLatLng(const LatLng(51.5, -0.09)), isTrue);
});

test('returns false for NaN and infinity', () {
expect(isFiniteMapLatLng(const LatLng(double.nan, 0)), isFalse);
expect(isFiniteMapLatLng(const LatLng(0, double.nan)), isFalse);
expect(isFiniteMapLatLng(const LatLng(double.infinity, 0)), isFalse);
});
});

group('MapCamera.focusedZoomCenter', () {
const center = LatLng(40.7128, -74.006);

MapCamera camera({
required Size size,
double zoom = 12,
}) =>
MapCamera(
crs: const Epsg3857(),
center: center,
zoom: zoom,
rotation: 0,
nonRotatedSize: size,
);

test('returns current center before layout size is known', () {
final cam = MapCamera.initialCamera(
const MapOptions(
initialCenter: center,
initialZoom: 12,
),
);

expect(cam.nonRotatedSize, MapCamera.kImpossibleSize);
expect(
cam.focusedZoomCenter(const Offset(100, 100), 5.5),
cam.center,
);
});

test('returns current center for non-finite target zoom', () {
final cam = camera(size: const Size(400, 600));
expect(
cam.focusedZoomCenter(const Offset(200, 300), double.nan),
center,
);
});

test('returns current center for invalid viewport size', () {
final cam = camera(size: const Size(0, 600));
expect(
cam.focusedZoomCenter(const Offset(0, 0), 8),
center,
);
});

test('returns finite coordinates when cursor is at viewport center', () {
final cam = camera(size: const Size(400, 600));
final result = cam.focusedZoomCenter(
const Offset(200, 300),
12,
);
expect(isFiniteMapLatLng(result), isTrue);
expect(result.latitude, closeTo(center.latitude, 0.0001));
expect(result.longitude, closeTo(center.longitude, 0.0001));
});

test('returns finite coordinates when zooming out toward min zoom', () {
final cam = camera(size: const Size(400, 600), zoom: 14);
final result = cam.focusedZoomCenter(const Offset(50, 80), 5.5);
expect(isFiniteMapLatLng(result), isTrue);
expect(() => cam.projectAtZoom(result, 5.5), returnsNormally);
});
});

group('MapController.move', () {
testWidgets('rejects non-finite center and leaves camera unchanged', (
tester,
) async {
final controller = MapController();
await tester.pumpWidget(TestApp(controller: controller));
await tester.pump();

final beforeCenter = controller.camera.center;
final beforeZoom = controller.camera.zoom;

expect(
controller.move(const LatLng(double.nan, double.nan), beforeZoom),
isFalse,
);
expect(controller.camera.center, beforeCenter);
expect(controller.camera.zoom, beforeZoom);
expect(isFiniteMapLatLng(controller.camera.center), isTrue);
});

testWidgets('rejects non-finite zoom', (tester) async {
final controller = MapController();
await tester.pumpWidget(TestApp(controller: controller));
await tester.pump();

final before = controller.camera.center;

expect(
controller.move(before, double.nan),
isFalse,
);
expect(controller.camera.center, before);
});
});

group('TileRangeCalculator', () {
test('calculate succeeds with finite map center', () {
const calculator = TileRangeCalculator(tileDimension: 256);
final camera = MapCamera(
crs: const Epsg3857(),
center: const LatLng(51.5, -0.09),
zoom: 10,
rotation: 0,
nonRotatedSize: const Size(400, 400),
);

expect(
() => calculator.calculate(camera: camera, tileZoom: 10),
returnsNormally,
);
});

test('calculate throws when center is non-finite', () {
const calculator = TileRangeCalculator(tileDimension: 256);
final camera = MapCamera(
crs: const Epsg3857(),
center: const LatLng(double.nan, double.nan),
zoom: 10,
rotation: 0,
nonRotatedSize: const Size(400, 400),
);

expect(
() => calculator.calculate(camera: camera, tileZoom: 10),
throwsA(
isA<Exception>().having(
(e) => e.toString(),
'message',
contains('LatLng is not finite'),
),
),
);
});
});
}