Skip to content

Commit

Permalink
fix: make horizontal repetition CRS dependent (#1978)
Browse files Browse the repository at this point in the history
  • Loading branch information
monsieurtanuki authored Nov 17, 2024
1 parent 59d7f69 commit d816b4d
Show file tree
Hide file tree
Showing 8 changed files with 133 additions and 59 deletions.
6 changes: 6 additions & 0 deletions lib/src/geo/crs.dart
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ abstract class Crs {

/// Rescales the bounds to a given zoom value.
Bounds<double>? getProjectedBounds(double zoom);

/// Returns true if we want the world to be replicated, longitude-wise.
bool get replicatesWorldLongitude => false;
}

/// Internal base class for CRS with a single zoom-level independent transformation.
Expand Down Expand Up @@ -175,6 +178,9 @@ class Epsg3857 extends CrsWithStaticTransformation {
);
return Point<double>(x, y);
}

@override
bool get replicatesWorldLongitude => true;
}

/// EPSG:4326, A common CRS among GIS enthusiasts.
Expand Down
25 changes: 15 additions & 10 deletions lib/src/gestures/map_interactive_viewer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -883,18 +883,23 @@ class MapInteractiveViewerState extends State<MapInteractiveViewer>

final newCenterPoint = _camera.project(_mapCenterStart) +
_flingAnimation.value.toPoint().rotate(_camera.rotationRad);
final math.Point<double> bestCenterPoint;
final double worldSize = _camera.crs.scale(_camera.zoom);
if (newCenterPoint.x > worldSize) {
bestCenterPoint =
math.Point(newCenterPoint.x - worldSize, newCenterPoint.y);
} else if (newCenterPoint.x < 0) {
bestCenterPoint =
math.Point(newCenterPoint.x + worldSize, newCenterPoint.y);
final LatLng newCenter;
if (!_camera.crs.replicatesWorldLongitude) {
newCenter = _camera.unproject(newCenterPoint);
} else {
bestCenterPoint = newCenterPoint;
final math.Point<double> bestCenterPoint;
final double worldSize = _camera.crs.scale(_camera.zoom);
if (newCenterPoint.x > worldSize) {
bestCenterPoint =
math.Point(newCenterPoint.x - worldSize, newCenterPoint.y);
} else if (newCenterPoint.x < 0) {
bestCenterPoint =
math.Point(newCenterPoint.x + worldSize, newCenterPoint.y);
} else {
bestCenterPoint = newCenterPoint;
}
newCenter = _camera.unproject(bestCenterPoint);
}
final newCenter = _camera.unproject(bestCenterPoint);

widget.controller.moveRaw(
newCenter,
Expand Down
68 changes: 45 additions & 23 deletions lib/src/layer/tile_layer/tile_coordinates.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,29 +20,6 @@ class TileCoordinates extends Point<int> {
/// Create a new [TileCoordinates] instance.
const TileCoordinates(super.x, super.y, this.z);

/// Returns a unique value for the same tile on all world replications.
factory TileCoordinates.key(TileCoordinates coordinates) {
if (coordinates.z < 0) {
return coordinates;
}
final modulo = 1 << coordinates.z;
int x = coordinates.x;
while (x < 0) {
x += modulo;
}
while (x >= modulo) {
x -= modulo;
}
int y = coordinates.y;
while (y < 0) {
y += modulo;
}
while (y >= modulo) {
y -= modulo;
}
return TileCoordinates(x, y, coordinates.z);
}

@override
String toString() => 'TileCoordinate($x, $y, $z)';

Expand Down Expand Up @@ -70,3 +47,48 @@ class TileCoordinates extends Point<int> {
return x ^ y << 24 ^ z << 48;
}
}

/// Resolves coordinates in the context of world replications.
///
/// On maps with world replications, different tile coordinates may actually
/// refer to the same "resolved" tile coordinate - the coordinate that starts
/// from 0.
/// For instance, on zoom level 0, all tile coordinates can be simplified to
/// (0,0), which is the only tile.
/// On zoom level 1, (0, 1) and (2, 1) can be simplified to (0, 1), as they both
/// mean the bottom left tile.
/// And when we're not in the context of world replications, we don't have to
/// simplify the tile coordinates: we just return the same value.
class TileCoordinatesResolver {
/// Resolves coordinates in the context of world replications.
const TileCoordinatesResolver(this.replicatesWorldLongitude);

/// True if we simplify the coordinates according to the world replications.
final bool replicatesWorldLongitude;

/// Returns the simplification of the coordinates.
TileCoordinates get(TileCoordinates positionCoordinates) {
if (!replicatesWorldLongitude) {
return positionCoordinates;
}
if (positionCoordinates.z < 0) {
return positionCoordinates;
}
final modulo = 1 << positionCoordinates.z;
int x = positionCoordinates.x;
while (x < 0) {
x += modulo;
}
while (x >= modulo) {
x -= modulo;
}
int y = positionCoordinates.y;
while (y < 0) {
y += modulo;
}
while (y >= modulo) {
y -= modulo;
}
return TileCoordinates(x, y, positionCoordinates.z);
}
}
47 changes: 31 additions & 16 deletions lib/src/layer/tile_layer/tile_image_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,11 @@ import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds_at_zoom
import 'package:flutter_map/src/layer/tile_layer/tile_image_view.dart';
import 'package:flutter_map/src/layer/tile_layer/tile_range.dart';
import 'package:flutter_map/src/layer/tile_layer/tile_renderer.dart';
import 'package:meta/meta.dart';

/// Callback definition to crete a [TileImage] for [TileCoordinates].
/// Callback definition to create a [TileImage] for [TileCoordinates].
typedef TileCreator = TileImage Function(TileCoordinates coordinates);

/// The [TileImageManager] orchestrates the loading and pruning of tiles.
@immutable
class TileImageManager {
final Set<TileCoordinates> _positionCoordinates = HashSet<TileCoordinates>();

Expand All @@ -28,31 +26,52 @@ class TileImageManager {
bool get allLoaded =>
_tiles.values.none((tile) => tile.loadFinishedAt == null);

/// Coordinates resolver.
TileCoordinatesResolver _resolver = const TileCoordinatesResolver(false);

/// Sets if we replicate the world longitude in several worlds.
void setReplicatesWorldLongitude(bool replicatesWorldLongitude) {
if (_resolver.replicatesWorldLongitude == replicatesWorldLongitude) {
return;
}
_resolver = TileCoordinatesResolver(replicatesWorldLongitude);
}

/// Filter tiles to only tiles that would be visible on screen. Specifically:
/// 1. Tiles in the visible range at the target zoom level.
/// 2. Tiles at non-target zoom level that would cover up holes that would
/// be left by tiles in #1, which are not ready yet.
Iterable<TileRenderer> getTilesToRender({
required DiscreteTileRange visibleRange,
}) {
final Iterable<TileCoordinates> positionCoordinates = TileImageView(
tileImages: _tiles,
positionCoordinates: _positionCoordinates,
final Iterable<TileCoordinates> positionCoordinates = _getTileImageView(
visibleRange: visibleRange,
// `keepRange` is irrelevant here since we're not using the output for
// pruning storage but rather to decide on what to put on screen.
keepRange: visibleRange,
).renderTiles;
final List<TileRenderer> tileRenderers = <TileRenderer>[];
for (final position in positionCoordinates) {
final TileImage? tileImage = _tiles[TileCoordinates.key(position)];
final TileImage? tileImage = _tiles[_resolver.get(position)];
if (tileImage != null) {
tileRenderers.add(TileRenderer(tileImage, position));
}
}
return tileRenderers;
}

TileImageView _getTileImageView({
required DiscreteTileRange visibleRange,
required DiscreteTileRange keepRange,
}) =>
TileImageView(
tileImages: _tiles,
positionCoordinates: _positionCoordinates,
visibleRange: visibleRange,
keepRange: keepRange,
resolver: _resolver,
);

/// Check if all loaded tiles are within the [minZoom] and [maxZoom] level.
bool allWithinZoom(double minZoom, double maxZoom) => _tiles.values
.map((e) => e.coordinates)
Expand All @@ -68,7 +87,7 @@ class TileImageManager {
final notLoaded = <TileImage>[];

for (final coordinates in tileBoundsAtZoom.validCoordinatesIn(tileRange)) {
final cleanCoordinates = TileCoordinates.key(coordinates);
final cleanCoordinates = _resolver.get(coordinates);
TileImage? tile = _tiles[cleanCoordinates];
if (tile == null) {
tile = createTile(cleanCoordinates);
Expand Down Expand Up @@ -97,11 +116,11 @@ class TileImageManager {
required bool Function(TileImage tileImage) evictImageFromCache,
}) {
_positionCoordinates.remove(key);
final cleanKey = TileCoordinates.key(key);
final cleanKey = _resolver.get(key);

// guard if positionCoordinates with the same tileImage.
for (final positionCoordinates in _positionCoordinates) {
if (TileCoordinates.key(positionCoordinates) == cleanKey) {
if (_resolver.get(positionCoordinates) == cleanKey) {
return;
}
}
Expand Down Expand Up @@ -167,9 +186,7 @@ class TileImageManager {
required int pruneBuffer,
required EvictErrorTileStrategy evictStrategy,
}) {
final pruningState = TileImageView(
tileImages: _tiles,
positionCoordinates: _positionCoordinates,
final pruningState = _getTileImageView(
visibleRange: visibleRange,
keepRange: visibleRange.expand(pruneBuffer),
);
Expand Down Expand Up @@ -205,9 +222,7 @@ class TileImageManager {
required EvictErrorTileStrategy evictStrategy,
}) {
_prune(
TileImageView(
tileImages: _tiles,
positionCoordinates: _positionCoordinates,
_getTileImageView(
visibleRange: visibleRange,
keepRange: visibleRange.expand(pruneBuffer),
),
Expand Down
30 changes: 21 additions & 9 deletions lib/src/layer/tile_layer/tile_image_view.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,21 @@ final class TileImageView {
final Set<TileCoordinates> _positionCoordinates;
final DiscreteTileRange _visibleRange;
final DiscreteTileRange _keepRange;
final TileCoordinatesResolver _resolver;

/// Create a new [TileImageView] instance.
const TileImageView({
required Map<TileCoordinates, TileImage> tileImages,
required Set<TileCoordinates> positionCoordinates,
required DiscreteTileRange visibleRange,
required DiscreteTileRange keepRange,
final TileCoordinatesResolver resolver =
const TileCoordinatesResolver(false),
}) : _tileImages = tileImages,
_positionCoordinates = positionCoordinates,
_visibleRange = visibleRange,
_keepRange = keepRange;
_keepRange = keepRange,
_resolver = resolver;

/// Get a list with all tiles that have an error and are outside of the
/// margin that should get kept.
Expand All @@ -37,11 +41,14 @@ final class TileImageView {
List<TileCoordinates> _errorTilesWithinRange(DiscreteTileRange range) {
final List<TileCoordinates> result = <TileCoordinates>[];
for (final positionCoordinates in _positionCoordinates) {
if (range.contains(positionCoordinates)) {
if (range.contains(
positionCoordinates,
replicatesWorldLongitude: _resolver.replicatesWorldLongitude,
)) {
continue;
}
final TileImage? tileImage =
_tileImages[TileCoordinates.key(positionCoordinates)];
_tileImages[_resolver.get(positionCoordinates)];
if (tileImage?.loadError ?? false) {
result.add(positionCoordinates);
}
Expand All @@ -55,7 +62,10 @@ final class TileImageView {
final retain = HashSet<TileCoordinates>();

for (final positionCoordinates in _positionCoordinates) {
if (!_keepRange.contains(positionCoordinates)) {
if (!_keepRange.contains(
positionCoordinates,
replicatesWorldLongitude: _resolver.replicatesWorldLongitude,
)) {
stale.add(positionCoordinates);
continue;
}
Expand Down Expand Up @@ -86,14 +96,16 @@ final class TileImageView {
final retain = HashSet<TileCoordinates>();

for (final positionCoordinates in _positionCoordinates) {
if (!_visibleRange.contains(positionCoordinates)) {
if (!_visibleRange.contains(
positionCoordinates,
replicatesWorldLongitude: _resolver.replicatesWorldLongitude,
)) {
continue;
}

retain.add(positionCoordinates);

final TileImage? tile =
_tileImages[TileCoordinates.key(positionCoordinates)];
final TileImage? tile = _tileImages[_resolver.get(positionCoordinates)];
if (tile == null || !tile.readyToDisplay) {
final retainedAncestor = _retainAncestor(
retain,
Expand Down Expand Up @@ -131,7 +143,7 @@ final class TileImageView {
final z2 = z - 1;
final coords2 = TileCoordinates(x2, y2, z2);

final tile = _tileImages[TileCoordinates.key(coords2)];
final tile = _tileImages[_resolver.get(coords2)];
if (tile != null) {
if (tile.readyToDisplay) {
retain.add(coords2);
Expand Down Expand Up @@ -160,7 +172,7 @@ final class TileImageView {
for (final (i, j) in const [(0, 0), (0, 1), (1, 0), (1, 1)]) {
final coords = TileCoordinates(2 * x + i, 2 * y + j, z + 1);

final tile = _tileImages[TileCoordinates.key(coords)];
final tile = _tileImages[_resolver.get(coords)];
if (tile != null) {
if (tile.readyToDisplay || tile.loadFinishedAt != null) {
retain.add(coords);
Expand Down
4 changes: 4 additions & 0 deletions lib/src/layer/tile_layer/tile_layer.dart
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,10 @@ class _TileLayerState extends State<TileLayer> with TickerProviderStateMixin {
final camera = MapCamera.of(context);
final mapController = MapController.of(context);

_tileImageManager.setReplicatesWorldLongitude(
camera.crs.replicatesWorldLongitude,
);

if (_mapControllerHashCode != mapController.hashCode) {
_tileUpdateSubscription?.cancel();

Expand Down
9 changes: 8 additions & 1 deletion lib/src/layer/tile_layer/tile_range.dart
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,14 @@ class DiscreteTileRange extends TileRange {
/// Check if a [Point] is inside of the bounds of the [DiscreteTileRange].
///
/// We use a modulo in order to prevent side-effects at the end of the world.
bool contains(Point<int> point) {
bool contains(
Point<int> point, {
bool replicatesWorldLongitude = false,
}) {
if (!replicatesWorldLongitude) {
return _bounds.contains(point);
}

final int modulo = 1 << zoom;

bool containsCoordinate(int value, int min, int max) {
Expand Down
3 changes: 3 additions & 0 deletions lib/src/map/camera/camera.dart
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ 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;
}
Expand Down

0 comments on commit d816b4d

Please sign in to comment.