From 76dd2e9bc5c89a0e6646f1b0a58ba18eb506c4bd Mon Sep 17 00:00:00 2001 From: WudgeJalk <149012910+WudgeJalk@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:33:04 +0200 Subject: [PATCH] fix: convert pinch scale to zoom through the CRS instead of log2 `_getZoomForScale` used `zoom + log2(scale)`, which only holds when each level doubles the scale. It now uses `crs.zoom(crs.scale(zoom) * scale)`, the formula of `MapCamera.getScaleZoom`, identical in EPSG:3857, so pinching x2 doubles the map scale in any CRS. Co-Authored-By: Claude Opus 5.5 --- lib/src/gestures/map_interactive_viewer.dart | 8 +- test/gestures/pinch_zoom_crs_test.dart | 97 ++++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 test/gestures/pinch_zoom_crs_test.dart diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index 3d1e89e3b..b88d60dd9 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -1392,10 +1392,12 @@ class MapInteractiveViewerState extends State // Utilities + // Through the CRS rather than `log2`: the ratio between zoom levels is not + // 2 in every CRS (e.g. `Proj4Crs` with custom resolutions). double _getZoomForScale(double startZoom, double scale) { - final resultZoom = - scale == 1.0 ? startZoom : startZoom + math.log(scale) / math.ln2; - return _camera.clampZoom(resultZoom); + if (scale == 1.0) return _camera.clampZoom(startZoom); + final crs = _camera.crs; + return _camera.clampZoom(crs.zoom(crs.scale(startZoom) * scale)); } Offset _rotateOffset(Offset offset) { diff --git a/test/gestures/pinch_zoom_crs_test.dart b/test/gestures/pinch_zoom_crs_test.dart new file mode 100644 index 000000000..2a28db55f --- /dev/null +++ b/test/gestures/pinch_zoom_crs_test.dart @@ -0,0 +1,97 @@ +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 'package:proj4dart/proj4dart.dart' as proj4; + +// Resolutions with a ratio other than 2 between levels (the Swiss LV95 grid): +// 2.5 -> 2 -> 1.5 -> 1 m/px from zoom 22. +const _resolutions = [ + 4000, 3750, 3500, 3250, 3000, 2750, 2500, 2250, 2000, 1750, // + 1500, 1250, 1000, 750, 650, 500, 250, 100, 50, 20, // + 10, 5, 2.5, 2, 1.5, 1, 0.5, 0.25, 0.1, +]; + +final _customCrs = Proj4Crs.fromFactory( + code: 'TEST:CUSTOM', + proj4Projection: proj4.Projection.get('EPSG:3857')!, + resolutions: _resolutions, +); + +/// Two fingers around [c], [scale] times their initial 100 px apart. +List _fingers(Offset c, double scale) { + final v = Offset(50 * scale, 0); + return [c - v, c + v]; +} + +Future _pumpMap( + WidgetTester tester, { + required Crs crs, + required double zoom, +}) async { + final controller = MapController(); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + width: 400, + height: 400, + child: FlutterMap( + mapController: controller, + options: MapOptions( + crs: crs, + initialCenter: const LatLng(46.5, 6.6), + initialZoom: zoom, + minZoom: 8, + maxZoom: 28, + ), + children: const [], + ), + ), + ), + ), + ); + return controller; +} + +/// Spreads two fingers through [scales]. +Future _pinch(WidgetTester tester, List scales) async { + final c = tester.getCenter(find.byType(FlutterMap)); + final start = _fingers(c, 1); + final g1 = await tester.startGesture(start[0], pointer: 1); + final g2 = await tester.startGesture(start[1], pointer: 2); + await tester.pump(); + for (final scale in scales) { + final p = _fingers(c, scale); + await g1.moveTo(p[0]); + await g2.moveTo(p[1]); + await tester.pump(); + } + await g1.up(); + await g2.up(); + await tester.pumpAndSettle(); + // Let the double tap timer expire. + await tester.pump(const Duration(seconds: 1)); +} + +void main() { + group('pinch zoom', () { + testWidgets('doubles the scale when the ratio between levels is not 2', + (tester) async { + final controller = await _pumpMap(tester, crs: _customCrs, zoom: 22); + await _pinch(tester, [1.5, 2]); + expect( + _customCrs.scale(controller.camera.zoom) / _customCrs.scale(22), + closeTo(2, 1e-3), + ); + }); + + testWidgets('adds one level in EPSG:3857', (tester) async { + final controller = + await _pumpMap(tester, crs: const Epsg3857(), zoom: 10); + await _pinch(tester, [1.5, 2]); + expect(controller.camera.zoom, closeTo(11, 1e-3)); + }); + }); +}