From c7be7cd98a49220aa19fc75b45c01d9fe24b54b2 Mon Sep 17 00:00:00 2001 From: Zach Snell Date: Tue, 4 Mar 2025 00:24:49 -0600 Subject: [PATCH 1/5] Initial working fix - Resolves #2019 and related issues around dateline and rotation with infinite scrolling longitudinal map. May still need clean up / optimization. --- lib/src/map/camera/camera.dart | 77 ++++++++++++------- .../map/controller/map_controller_impl.dart | 25 +++++- lib/src/misc/offsets.dart | 21 ++++- 3 files changed, 93 insertions(+), 30 deletions(-) diff --git a/lib/src/map/camera/camera.dart b/lib/src/map/camera/camera.dart index 5d046e8ec..3ca289a32 100644 --- a/lib/src/map/camera/camera.dart +++ b/lib/src/map/camera/camera.dart @@ -190,21 +190,27 @@ class MapCamera { /// Jumps camera to opposite side of the world to enable seamless scrolling /// between 180 and -180 longitude. LatLng _adjustPositionForSeamlessScrolling(LatLng? position) { - if (!crs.replicatesWorldLongitude) { - return position ?? center; + if (!crs.replicatesWorldLongitude) return position ?? center; + + LatLng safePosition = position ?? center; + + const double bufferZoneSize = 10.0; + const double worldWrap = 360.0; + const double epsilon = 1e-6; + double lon = safePosition.longitude; + + // Wrap longitude to [-180, 180] range efficiently + lon = ((lon + 180) % worldWrap + worldWrap) % worldWrap - 180; + + // Adjust position when crossing boundaries + if (center.longitude > 0 && lon < -180.0 + bufferZoneSize) { + lon += worldWrap; + } else if (center.longitude < 0 && lon > 180.0 - bufferZoneSize) { + lon -= worldWrap; } - if (position == null) { - return center; - } - double adjustedLongitude = position.longitude; - if (adjustedLongitude >= 180.0) { - adjustedLongitude -= 360.0; - } else if (adjustedLongitude <= -180.0) { - adjustedLongitude += 360.0; - } - return adjustedLongitude == position.longitude - ? position - : LatLng(position.latitude, adjustedLongitude); + return (lon - safePosition.longitude).abs() < epsilon + ? safePosition + : LatLng(safePosition.latitude, lon); } /// Calculates the size of a bounding box which surrounds a box of size @@ -241,12 +247,13 @@ class MapCamera { /// Returns the width of the world at the current zoom, or 0 if irrelevant. double getWorldWidthAtZoom([double? zoom]) { - if (!crs.replicatesWorldLongitude) { - return 0; - } - final offset0 = projectAtZoom(const LatLng(0, 0), zoom ?? this.zoom); - final offset180 = projectAtZoom(const LatLng(0, 180), zoom ?? this.zoom); - return 2 * (offset180.dx - offset0.dx).abs(); + if (!crs.replicatesWorldLongitude) return 0; + + final double effectiveZoom = zoom ?? this.zoom; + final Offset offset0 = projectAtZoom(const LatLng(0, 0), effectiveZoom); + final Offset offset180 = projectAtZoom(const LatLng(0, 180), effectiveZoom); + + return 2.0 * (offset180.dx - offset0.dx).abs(); } /// Calculates the scale for a zoom from [fromZoom] to [toZoom] using this @@ -307,18 +314,34 @@ class MapCamera { } /// Calculate the [LatLng] coordinates for a [offset]. - LatLng screenOffsetToLatLng(Offset offset) { - final localPointCenterDistance = - nonRotatedSize.center(Offset.zero) - offset; - final mapCenter = crs.latLngToOffset(center, zoom); + LatLng screenOffsetToLatLng(Offset screenOffset) { + // 1) Compute the 'nonRotatedPixelOrigin' — the same as latLngToScreenOffset does. + final nonRotatedPixelOrigin = + projectAtZoom(center, zoom) - nonRotatedSize.center(Offset.zero); - var point = mapCenter - localPointCenterDistance; + // 2) Convert the screen offset into projected coordinates: + // If screenOffset is (100, 200), we add that to the "origin" in projection space. + var projectedPoint = screenOffset + nonRotatedPixelOrigin; + // 3) Rotate the projectedPoint “back” (counter-rotate) around the mapCenter + // so that we’re aligned with the CRS’s x-axis before applying world-wrap logic. if (rotation != 0.0) { - point = rotatePoint(mapCenter, point); + final mapCenter = crs.latLngToOffset(center, zoom); + projectedPoint = rotatePoint(mapCenter, projectedPoint, counterRotation: true); + } + + // 4) Apply the usual world-wrap check if needed, but now in unrotated space. + if (crs.replicatesWorldLongitude) { + final worldWidth = getWorldWidthAtZoom(); + if (projectedPoint.dx < 0) { + projectedPoint = Offset(projectedPoint.dx + worldWidth, projectedPoint.dy); + } else if (projectedPoint.dx > worldWidth) { + projectedPoint = Offset(projectedPoint.dx - worldWidth, projectedPoint.dy); + } } - return crs.offsetToLatLng(point, zoom); + // 5) Finally, convert from projected coordinates to lat/lng. + return crs.offsetToLatLng(projectedPoint, zoom); } /// Sometimes we need to make allowances that a rotation already exists, so diff --git a/lib/src/map/controller/map_controller_impl.dart b/lib/src/map/controller/map_controller_impl.dart index d36c605aa..36b7cb6a2 100644 --- a/lib/src/map/controller/map_controller_impl.dart +++ b/lib/src/map/controller/map_controller_impl.dart @@ -377,7 +377,30 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> final oldCenterPt = camera.projectAtZoom(camera.center); final newCenterPt = oldCenterPt + offset; - final newCenter = camera.unprojectAtZoom(newCenterPt); + + // Account for world wrapping at 180/-180 boundary + final LatLng newCenter; + if (camera.crs.replicatesWorldLongitude) { + final worldWidth = camera.getWorldWidthAtZoom(); + + // If worldWidth is 0, it means world replication is irrelevant + if (worldWidth > 0) { + // Apply the same logic used in fling animation for consistency + final Offset bestCenterPoint; + if (newCenterPt.dx > worldWidth) { + bestCenterPoint = Offset(newCenterPt.dx - worldWidth, newCenterPt.dy); + } else if (newCenterPt.dx < 0) { + bestCenterPoint = Offset(newCenterPt.dx + worldWidth, newCenterPt.dy); + } else { + bestCenterPoint = newCenterPt; + } + newCenter = camera.unprojectAtZoom(bestCenterPoint); + } else { + newCenter = camera.unprojectAtZoom(newCenterPt); + } + } else { + newCenter = camera.unprojectAtZoom(newCenterPt); + } moveRaw( newCenter, diff --git a/lib/src/misc/offsets.dart b/lib/src/misc/offsets.dart index fed76d672..0ff418a1e 100644 --- a/lib/src/misc/offsets.dart +++ b/lib/src/misc/offsets.dart @@ -1,3 +1,4 @@ +import 'dart:math' as math; import 'dart:ui'; import 'package:flutter_map/flutter_map.dart'; @@ -76,22 +77,38 @@ List getOffsetsXY({ ]; final halfScreenWidth = camera.size.width / 2; final p = realPoints.elementAt(0); + + // Define hysteresis threshold - this prevents oscillation + // The threshold value is approximately 5% of the screen width, + // which should provide enough stability without affecting usability + // Adjust hysteresis based on zoom level - tighter at high zoom, looser at low zoom + final double hysteresisThreshold = camera.size.width * (0.05 / math.max(1, camera.zoom / 10)); + late double result; late double bestX; + late double bestError; + for (int i = 0; i < addedWidths.length; i++) { final addedWidth = addedWidths[i]; final (x, _) = crs.transform(p.dx + addedWidth, p.dy, zoomScale); + final error = (x + ox - halfScreenWidth).abs(); + if (i == 0) { result = addedWidth; bestX = x; + bestError = error; continue; } - if ((bestX + ox - halfScreenWidth).abs() > - (x + ox - halfScreenWidth).abs()) { + + // Only switch worlds if there's a significant improvement + // This prevents oscillation near the boundary + if (error < bestError - hysteresisThreshold) { result = addedWidth; bestX = x; + bestError = error; } } + return result; } From 53e76fd2b38303c2dfe0b03b101640a1073b6bf5 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 4 Mar 2025 10:41:19 +0000 Subject: [PATCH 2/5] Fixed formatting issues --- lib/src/map/camera/camera.dart | 34 ++++++++++++++++++---------------- lib/src/misc/offsets.dart | 18 ++++++++---------- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/lib/src/map/camera/camera.dart b/lib/src/map/camera/camera.dart index 3ca289a32..977bd9d18 100644 --- a/lib/src/map/camera/camera.dart +++ b/lib/src/map/camera/camera.dart @@ -191,21 +191,21 @@ class MapCamera { /// between 180 and -180 longitude. LatLng _adjustPositionForSeamlessScrolling(LatLng? position) { if (!crs.replicatesWorldLongitude) return position ?? center; - - LatLng safePosition = position ?? center; - const double bufferZoneSize = 10.0; - const double worldWrap = 360.0; - const double epsilon = 1e-6; + final safePosition = position ?? center; + + const bufferZoneSize = 10; + const worldWrap = 360; + const epsilon = 1e-6; double lon = safePosition.longitude; // Wrap longitude to [-180, 180] range efficiently lon = ((lon + 180) % worldWrap + worldWrap) % worldWrap - 180; // Adjust position when crossing boundaries - if (center.longitude > 0 && lon < -180.0 + bufferZoneSize) { + if (center.longitude > 0 && lon < -180 + bufferZoneSize) { lon += worldWrap; - } else if (center.longitude < 0 && lon > 180.0 - bufferZoneSize) { + } else if (center.longitude < 0 && lon > 180 - bufferZoneSize) { lon -= worldWrap; } return (lon - safePosition.longitude).abs() < epsilon @@ -249,11 +249,11 @@ class MapCamera { double getWorldWidthAtZoom([double? zoom]) { if (!crs.replicatesWorldLongitude) return 0; - final double effectiveZoom = zoom ?? this.zoom; - final Offset offset0 = projectAtZoom(const LatLng(0, 0), effectiveZoom); - final Offset offset180 = projectAtZoom(const LatLng(0, 180), effectiveZoom); + final effectiveZoom = zoom ?? this.zoom; + final offset0 = projectAtZoom(const LatLng(0, 0), effectiveZoom); + final offset180 = projectAtZoom(const LatLng(0, 180), effectiveZoom); - return 2.0 * (offset180.dx - offset0.dx).abs(); + return 2 * (offset180.dx - offset0.dx).abs(); } /// Calculates the scale for a zoom from [fromZoom] to [toZoom] using this @@ -314,29 +314,31 @@ class MapCamera { } /// Calculate the [LatLng] coordinates for a [offset]. - LatLng screenOffsetToLatLng(Offset screenOffset) { + LatLng screenOffsetToLatLng(Offset offset) { // 1) Compute the 'nonRotatedPixelOrigin' — the same as latLngToScreenOffset does. final nonRotatedPixelOrigin = projectAtZoom(center, zoom) - nonRotatedSize.center(Offset.zero); // 2) Convert the screen offset into projected coordinates: // If screenOffset is (100, 200), we add that to the "origin" in projection space. - var projectedPoint = screenOffset + nonRotatedPixelOrigin; + var projectedPoint = offset + nonRotatedPixelOrigin; // 3) Rotate the projectedPoint “back” (counter-rotate) around the mapCenter // so that we’re aligned with the CRS’s x-axis before applying world-wrap logic. if (rotation != 0.0) { final mapCenter = crs.latLngToOffset(center, zoom); - projectedPoint = rotatePoint(mapCenter, projectedPoint, counterRotation: true); + projectedPoint = rotatePoint(mapCenter, projectedPoint); } // 4) Apply the usual world-wrap check if needed, but now in unrotated space. if (crs.replicatesWorldLongitude) { final worldWidth = getWorldWidthAtZoom(); if (projectedPoint.dx < 0) { - projectedPoint = Offset(projectedPoint.dx + worldWidth, projectedPoint.dy); + projectedPoint = + Offset(projectedPoint.dx + worldWidth, projectedPoint.dy); } else if (projectedPoint.dx > worldWidth) { - projectedPoint = Offset(projectedPoint.dx - worldWidth, projectedPoint.dy); + projectedPoint = + Offset(projectedPoint.dx - worldWidth, projectedPoint.dy); } } diff --git a/lib/src/misc/offsets.dart b/lib/src/misc/offsets.dart index 0ff418a1e..767eff438 100644 --- a/lib/src/misc/offsets.dart +++ b/lib/src/misc/offsets.dart @@ -77,38 +77,36 @@ List getOffsetsXY({ ]; final halfScreenWidth = camera.size.width / 2; final p = realPoints.elementAt(0); - + // Define hysteresis threshold - this prevents oscillation // The threshold value is approximately 5% of the screen width, // which should provide enough stability without affecting usability // Adjust hysteresis based on zoom level - tighter at high zoom, looser at low zoom - final double hysteresisThreshold = camera.size.width * (0.05 / math.max(1, camera.zoom / 10)); - + final double hysteresisThreshold = + camera.size.width * (0.05 / math.max(1, camera.zoom / 10)); + late double result; - late double bestX; late double bestError; - + for (int i = 0; i < addedWidths.length; i++) { final addedWidth = addedWidths[i]; final (x, _) = crs.transform(p.dx + addedWidth, p.dy, zoomScale); final error = (x + ox - halfScreenWidth).abs(); - + if (i == 0) { result = addedWidth; - bestX = x; bestError = error; continue; } - + // Only switch worlds if there's a significant improvement // This prevents oscillation near the boundary if (error < bestError - hysteresisThreshold) { result = addedWidth; - bestX = x; bestError = error; } } - + return result; } From 7594c2aabee908233e43db47641bd9e9284428da Mon Sep 17 00:00:00 2001 From: Luka S Date: Tue, 4 Mar 2025 11:06:59 +0000 Subject: [PATCH 3/5] Update lib/src/map/camera/camera.dart with suggestion --- lib/src/map/camera/camera.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/src/map/camera/camera.dart b/lib/src/map/camera/camera.dart index 977bd9d18..ef698c430 100644 --- a/lib/src/map/camera/camera.dart +++ b/lib/src/map/camera/camera.dart @@ -190,13 +190,13 @@ class MapCamera { /// Jumps camera to opposite side of the world to enable seamless scrolling /// between 180 and -180 longitude. LatLng _adjustPositionForSeamlessScrolling(LatLng? position) { - if (!crs.replicatesWorldLongitude) return position ?? center; - - final safePosition = position ?? center; - const bufferZoneSize = 10; const worldWrap = 360; const epsilon = 1e-6; + + final safePosition = position ?? center; + if (!crs.replicatesWorldLongitude) return safePosition; + double lon = safePosition.longitude; // Wrap longitude to [-180, 180] range efficiently From 12ea1908a9abb0ad75725caf35251dcd29d66231 Mon Sep 17 00:00:00 2001 From: JaffaKetchup Date: Tue, 4 Mar 2025 11:22:10 +0000 Subject: [PATCH 4/5] Fixed formatting --- lib/src/map/controller/map_controller_impl.dart | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/src/map/controller/map_controller_impl.dart b/lib/src/map/controller/map_controller_impl.dart index 36b7cb6a2..489aaf2f3 100644 --- a/lib/src/map/controller/map_controller_impl.dart +++ b/lib/src/map/controller/map_controller_impl.dart @@ -375,14 +375,13 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> /// To be called when an ongoing drag movement updates. void dragUpdated(MapEventSource source, Offset offset) { final oldCenterPt = camera.projectAtZoom(camera.center); - final newCenterPt = oldCenterPt + offset; - + // Account for world wrapping at 180/-180 boundary final LatLng newCenter; if (camera.crs.replicatesWorldLongitude) { final worldWidth = camera.getWorldWidthAtZoom(); - + // If worldWidth is 0, it means world replication is irrelevant if (worldWidth > 0) { // Apply the same logic used in fling animation for consistency From f4ecf7a3e08f5560e6acfd049c415307858165a5 Mon Sep 17 00:00:00 2001 From: Zach Snell Date: Tue, 4 Mar 2025 16:34:14 -0600 Subject: [PATCH 5/5] Reverting unncessary changes - minimum necessary to resolve this specific issue remains. --- lib/src/map/camera/camera.dart | 45 ++++++++----------- .../map/controller/map_controller_impl.dart | 26 +---------- lib/src/misc/offsets.dart | 25 +++-------- 3 files changed, 26 insertions(+), 70 deletions(-) diff --git a/lib/src/map/camera/camera.dart b/lib/src/map/camera/camera.dart index ef698c430..62e5b262b 100644 --- a/lib/src/map/camera/camera.dart +++ b/lib/src/map/camera/camera.dart @@ -190,27 +190,21 @@ class MapCamera { /// Jumps camera to opposite side of the world to enable seamless scrolling /// between 180 and -180 longitude. LatLng _adjustPositionForSeamlessScrolling(LatLng? position) { - const bufferZoneSize = 10; - const worldWrap = 360; - const epsilon = 1e-6; - - final safePosition = position ?? center; - if (!crs.replicatesWorldLongitude) return safePosition; - - double lon = safePosition.longitude; - - // Wrap longitude to [-180, 180] range efficiently - lon = ((lon + 180) % worldWrap + worldWrap) % worldWrap - 180; - - // Adjust position when crossing boundaries - if (center.longitude > 0 && lon < -180 + bufferZoneSize) { - lon += worldWrap; - } else if (center.longitude < 0 && lon > 180 - bufferZoneSize) { - lon -= worldWrap; + if (!crs.replicatesWorldLongitude) { + return position ?? center; + } + if (position == null) { + return center; } - return (lon - safePosition.longitude).abs() < epsilon - ? safePosition - : LatLng(safePosition.latitude, lon); + double adjustedLongitude = position.longitude; + if (adjustedLongitude >= 180.0) { + adjustedLongitude -= 360.0; + } else if (adjustedLongitude <= -180.0) { + adjustedLongitude += 360.0; + } + return adjustedLongitude == position.longitude + ? position + : LatLng(position.latitude, adjustedLongitude); } /// Calculates the size of a bounding box which surrounds a box of size @@ -247,12 +241,11 @@ class MapCamera { /// Returns the width of the world at the current zoom, or 0 if irrelevant. double getWorldWidthAtZoom([double? zoom]) { - if (!crs.replicatesWorldLongitude) return 0; - - final effectiveZoom = zoom ?? this.zoom; - final offset0 = projectAtZoom(const LatLng(0, 0), effectiveZoom); - final offset180 = projectAtZoom(const LatLng(0, 180), effectiveZoom); - + if (!crs.replicatesWorldLongitude) { + return 0; + } + final offset0 = projectAtZoom(const LatLng(0, 0), zoom ?? this.zoom); + final offset180 = projectAtZoom(const LatLng(0, 180), zoom ?? this.zoom); return 2 * (offset180.dx - offset0.dx).abs(); } diff --git a/lib/src/map/controller/map_controller_impl.dart b/lib/src/map/controller/map_controller_impl.dart index 489aaf2f3..d36c605aa 100644 --- a/lib/src/map/controller/map_controller_impl.dart +++ b/lib/src/map/controller/map_controller_impl.dart @@ -375,31 +375,9 @@ class MapControllerImpl extends ValueNotifier<_MapControllerState> /// To be called when an ongoing drag movement updates. void dragUpdated(MapEventSource source, Offset offset) { final oldCenterPt = camera.projectAtZoom(camera.center); - final newCenterPt = oldCenterPt + offset; - // Account for world wrapping at 180/-180 boundary - final LatLng newCenter; - if (camera.crs.replicatesWorldLongitude) { - final worldWidth = camera.getWorldWidthAtZoom(); - - // If worldWidth is 0, it means world replication is irrelevant - if (worldWidth > 0) { - // Apply the same logic used in fling animation for consistency - final Offset bestCenterPoint; - if (newCenterPt.dx > worldWidth) { - bestCenterPoint = Offset(newCenterPt.dx - worldWidth, newCenterPt.dy); - } else if (newCenterPt.dx < 0) { - bestCenterPoint = Offset(newCenterPt.dx + worldWidth, newCenterPt.dy); - } else { - bestCenterPoint = newCenterPt; - } - newCenter = camera.unprojectAtZoom(bestCenterPoint); - } else { - newCenter = camera.unprojectAtZoom(newCenterPt); - } - } else { - newCenter = camera.unprojectAtZoom(newCenterPt); - } + final newCenterPt = oldCenterPt + offset; + final newCenter = camera.unprojectAtZoom(newCenterPt); moveRaw( newCenter, diff --git a/lib/src/misc/offsets.dart b/lib/src/misc/offsets.dart index 767eff438..fed76d672 100644 --- a/lib/src/misc/offsets.dart +++ b/lib/src/misc/offsets.dart @@ -1,4 +1,3 @@ -import 'dart:math' as math; import 'dart:ui'; import 'package:flutter_map/flutter_map.dart'; @@ -77,36 +76,22 @@ List getOffsetsXY({ ]; final halfScreenWidth = camera.size.width / 2; final p = realPoints.elementAt(0); - - // Define hysteresis threshold - this prevents oscillation - // The threshold value is approximately 5% of the screen width, - // which should provide enough stability without affecting usability - // Adjust hysteresis based on zoom level - tighter at high zoom, looser at low zoom - final double hysteresisThreshold = - camera.size.width * (0.05 / math.max(1, camera.zoom / 10)); - late double result; - late double bestError; - + late double bestX; for (int i = 0; i < addedWidths.length; i++) { final addedWidth = addedWidths[i]; final (x, _) = crs.transform(p.dx + addedWidth, p.dy, zoomScale); - final error = (x + ox - halfScreenWidth).abs(); - if (i == 0) { result = addedWidth; - bestError = error; + bestX = x; continue; } - - // Only switch worlds if there's a significant improvement - // This prevents oscillation near the boundary - if (error < bestError - hysteresisThreshold) { + if ((bestX + ox - halfScreenWidth).abs() > + (x + ox - halfScreenWidth).abs()) { result = addedWidth; - bestError = error; + bestX = x; } } - return result; }