Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
75 changes: 50 additions & 25 deletions lib/src/map/camera/camera.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 (position == null) {
return center;
}
double adjustedLongitude = position.longitude;
if (adjustedLongitude >= 180.0) {
adjustedLongitude -= 360.0;
} else if (adjustedLongitude <= -180.0) {
adjustedLongitude += 360.0;
if (!crs.replicatesWorldLongitude) return position ?? center;

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 + bufferZoneSize) {
lon += worldWrap;
} else if (center.longitude < 0 && lon > 180 - bufferZoneSize) {
lon -= worldWrap;
}
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
Expand Down Expand Up @@ -241,11 +247,12 @@ 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);
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);

return 2 * (offset180.dx - offset0.dx).abs();
}

Expand Down Expand Up @@ -308,17 +315,35 @@ 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);
// 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 = 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) {
point = rotatePoint(mapCenter, point);
final mapCenter = crs.latLngToOffset(center, zoom);
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);
} 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
Expand Down
25 changes: 24 additions & 1 deletion lib/src/map/controller/map_controller_impl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
25 changes: 20 additions & 5 deletions lib/src/misc/offsets.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import 'dart:math' as math;
import 'dart:ui';

import 'package:flutter_map/flutter_map.dart';
Expand Down Expand Up @@ -76,22 +77,36 @@ List<Offset> 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;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be collapsed into a single decision?

I'm also not aware of any situation where oscillation at the boundary is an issue? Did you reproduce this? As far as I understand, this is very different to #2019, but I'm probably missing something.

// Only switch worlds if there's a significant improvement
// This prevents oscillation near the boundary
if (i == 0 || error < bestError - hysteresisThreshold) {
	result = addedWidth;
	bestError = error;
}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll have to give this a test - I had also noticed that the second assignment of bestX / bestError seemed unused. I knew I would need to review that anyways.

Further, I remember this was related but I was iterating so many times I will confirm if reverting has impact and if so if we can simplify the logic.

}

return result;
}

Expand Down