From 182c4fc0c73411e2be10774dd227d0da9cf4276a Mon Sep 17 00:00:00 2001 From: monsieurtanuki Date: Tue, 10 Sep 2024 23:20:07 +0200 Subject: [PATCH 1/6] feat: unbounded horizontal scroll (#1948) --- lib/src/gestures/map_interactive_viewer.dart | 13 +- lib/src/layer/tile_layer/tile.dart | 20 +++- .../layer/tile_layer/tile_coordinates.dart | 23 ++++ lib/src/layer/tile_layer/tile_image.dart | 2 +- .../layer/tile_layer/tile_image_manager.dart | 71 +++++++---- lib/src/layer/tile_layer/tile_image_view.dart | 113 ++++++++++++------ lib/src/layer/tile_layer/tile_layer.dart | 9 +- lib/src/layer/tile_layer/tile_range.dart | 18 ++- lib/src/layer/tile_layer/tile_renderer.dart | 30 +++++ lib/src/map/camera/camera.dart | 19 ++- .../tile_layer/tile_image_view_test.dart | 71 ++++++----- test/layer/tile_layer/tile_range_test.dart | 2 +- 12 files changed, 286 insertions(+), 105 deletions(-) create mode 100644 lib/src/layer/tile_layer/tile_renderer.dart diff --git a/lib/src/gestures/map_interactive_viewer.dart b/lib/src/gestures/map_interactive_viewer.dart index 8f4ba975a..a41e4b732 100644 --- a/lib/src/gestures/map_interactive_viewer.dart +++ b/lib/src/gestures/map_interactive_viewer.dart @@ -883,7 +883,18 @@ class MapInteractiveViewerState extends State final newCenterPoint = _camera.project(_mapCenterStart) + _flingAnimation.value.toPoint().rotate(_camera.rotationRad); - final newCenter = _camera.unproject(newCenterPoint); + final math.Point 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; + } + final newCenter = _camera.unproject(bestCenterPoint); widget.controller.moveRaw( newCenter, diff --git a/lib/src/layer/tile_layer/tile.dart b/lib/src/layer/tile_layer/tile.dart index b438380a5..509419fc1 100644 --- a/lib/src/layer/tile_layer/tile.dart +++ b/lib/src/layer/tile_layer/tile.dart @@ -21,6 +21,21 @@ class Tile extends StatefulWidget { /// visible pixel when the map is rotated. final Point currentPixelOrigin; + /// Position Coordinates. + /// + /// Most of the time, they are the same as in [tileImage]. + /// Except for multi-world or scrolled maps, for instance, scrolling from + /// Europe to Alaska on zoom level 3 (i.e. tile coordinates between 0 and 7): + /// * Alaska is first considered as from the next world (tile X: 8) + /// * Scrolling again, Alaska is considered as part of the current world, as + /// the center of the map is now in America (tile X: 0) + /// In both cases, we reuse the same [tileImage] (tile X: 0) for different + /// [positionCoordinates] (tile X: 0 and 8). This prevents a "flash" effect + /// when scrolling beyond the end of the world: we skip the part where we + /// create a new tileImage (for tile X: 0) as we've already downloaded it + /// (for tile X: 8). + final TileCoordinates positionCoordinates; + /// Creates a new instance of [Tile]. const Tile({ super.key, @@ -28,6 +43,7 @@ class Tile extends StatefulWidget { required this.currentPixelOrigin, required this.tileImage, required this.tileBuilder, + required this.positionCoordinates, }); @override @@ -54,9 +70,9 @@ class _TileState extends State { @override Widget build(BuildContext context) { return Positioned( - left: widget.tileImage.coordinates.x * widget.scaledTileSize - + left: widget.positionCoordinates.x * widget.scaledTileSize - widget.currentPixelOrigin.x, - top: widget.tileImage.coordinates.y * widget.scaledTileSize - + top: widget.positionCoordinates.y * widget.scaledTileSize - widget.currentPixelOrigin.y, width: widget.scaledTileSize, height: widget.scaledTileSize, diff --git a/lib/src/layer/tile_layer/tile_coordinates.dart b/lib/src/layer/tile_layer/tile_coordinates.dart index fc45701c5..2e5a1baa0 100644 --- a/lib/src/layer/tile_layer/tile_coordinates.dart +++ b/lib/src/layer/tile_layer/tile_coordinates.dart @@ -20,6 +20,29 @@ class TileCoordinates extends Point { /// 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)'; diff --git a/lib/src/layer/tile_layer/tile_image.dart b/lib/src/layer/tile_layer/tile_image.dart index 0fa452b77..882384931 100644 --- a/lib/src/layer/tile_layer/tile_image.dart +++ b/lib/src/layer/tile_layer/tile_image.dart @@ -21,7 +21,7 @@ class TileImage extends ChangeNotifier { /// indicate the position of the tile at that zoom level. final TileCoordinates coordinates; - /// Callback fired when loading finishes with or withut an error. This + /// Callback fired when loading finishes with or without an error. This /// callback is not triggered after this TileImage is disposed. final void Function(TileCoordinates coordinates) onLoadComplete; diff --git a/lib/src/layer/tile_layer/tile_image_manager.dart b/lib/src/layer/tile_layer/tile_image_manager.dart index 986e51e7f..9fabcd544 100644 --- a/lib/src/layer/tile_layer/tile_image_manager.dart +++ b/lib/src/layer/tile_layer/tile_image_manager.dart @@ -6,6 +6,7 @@ import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds_at_zoom.dart'; 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]. @@ -14,12 +15,14 @@ typedef TileCreator = TileImage Function(TileCoordinates coordinates); /// The [TileImageManager] orchestrates the loading and pruning of tiles. @immutable class TileImageManager { + final Set _positionCoordinates = HashSet(); + final Map _tiles = HashMap(); - /// Check if the [TileImageManager] has the tile for a given tile cooridantes. + /// Check if the [TileImageManager] has the tile for a given tile coordinates. bool containsTileAt(TileCoordinates coordinates) => - _tiles.containsKey(coordinates); + _positionCoordinates.contains(coordinates); /// Check if all tile images are loaded bool get allLoaded => @@ -29,16 +32,26 @@ class TileImageManager { /// 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 getTilesToRender({ + Iterable getTilesToRender({ required DiscreteTileRange visibleRange, - }) => - TileImageView( - tileImages: _tiles, - 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 Iterable positionCoordinates = TileImageView( + tileImages: _tiles, + positionCoordinates: _positionCoordinates, + 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 tileRenderers = []; + for (final position in positionCoordinates) { + final TileImage? tileImage = _tiles[TileCoordinates.key(position)]; + if (tileImage != null) { + tileRenderers.add(TileRenderer(tileImage, position)); + } + } + return tileRenderers; + } /// Check if all loaded tiles are within the [minZoom] and [maxZoom] level. bool allWithinZoom(double minZoom, double maxZoom) => _tiles.values @@ -55,7 +68,13 @@ class TileImageManager { final notLoaded = []; for (final coordinates in tileBoundsAtZoom.validCoordinatesIn(tileRange)) { - final tile = _tiles[coordinates] ??= createTile(coordinates); + final cleanCoordinates = TileCoordinates.key(coordinates); + TileImage? tile = _tiles[cleanCoordinates]; + if (tile == null) { + tile = createTile(cleanCoordinates); + _tiles[cleanCoordinates] = tile; + } + _positionCoordinates.add(coordinates); if (tile.loadStarted == null) { notLoaded.add(tile); } @@ -77,7 +96,17 @@ class TileImageManager { TileCoordinates key, { required bool Function(TileImage tileImage) evictImageFromCache, }) { - final removed = _tiles.remove(key); + _positionCoordinates.remove(key); + final cleanKey = TileCoordinates.key(key); + + // guard if positionCoordinates with the same tileImage. + for (final positionCoordinates in _positionCoordinates) { + if (TileCoordinates.key(positionCoordinates) == cleanKey) { + return; + } + } + + final removed = _tiles.remove(cleanKey); if (removed != null) { removed.dispose(evictImageFromCache: evictImageFromCache(removed)); @@ -97,7 +126,7 @@ class TileImageManager { /// Remove all tiles with a given [EvictErrorTileStrategy]. void removeAll(EvictErrorTileStrategy evictStrategy) { - final keysToRemove = List.from(_tiles.keys); + final keysToRemove = List.from(_positionCoordinates); for (final key in keysToRemove) { _removeWithEvictionStrategy(key, evictStrategy); @@ -140,6 +169,7 @@ class TileImageManager { }) { final pruningState = TileImageView( tileImages: _tiles, + positionCoordinates: _positionCoordinates, visibleRange: visibleRange, keepRange: visibleRange.expand(pruneBuffer), ); @@ -154,13 +184,13 @@ class TileImageManager { ) { switch (evictStrategy) { case EvictErrorTileStrategy.notVisibleRespectMargin: - for (final tileImage + for (final coordinates in tileRemovalState.errorTilesOutsideOfKeepMargin()) { - _remove(tileImage.coordinates, evictImageFromCache: (_) => true); + _remove(coordinates, evictImageFromCache: (_) => true); } case EvictErrorTileStrategy.notVisible: - for (final tileImage in tileRemovalState.errorTilesNotVisible()) { - _remove(tileImage.coordinates, evictImageFromCache: (_) => true); + for (final coordinates in tileRemovalState.errorTilesNotVisible()) { + _remove(coordinates, evictImageFromCache: (_) => true); } case EvictErrorTileStrategy.dispose: case EvictErrorTileStrategy.none: @@ -177,6 +207,7 @@ class TileImageManager { _prune( TileImageView( tileImages: _tiles, + positionCoordinates: _positionCoordinates, visibleRange: visibleRange, keepRange: visibleRange.expand(pruneBuffer), ), @@ -189,8 +220,8 @@ class TileImageManager { TileImageView tileRemovalState, EvictErrorTileStrategy evictStrategy, ) { - for (final tileImage in tileRemovalState.staleTiles) { - _removeWithEvictionStrategy(tileImage.coordinates, evictStrategy); + for (final coordinates in tileRemovalState.staleTiles) { + _removeWithEvictionStrategy(coordinates, evictStrategy); } } } diff --git a/lib/src/layer/tile_layer/tile_image_view.dart b/lib/src/layer/tile_layer/tile_image_view.dart index eb80f7540..fb39c591b 100644 --- a/lib/src/layer/tile_layer/tile_image_view.dart +++ b/lib/src/layer/tile_layer/tile_image_view.dart @@ -7,70 +7,109 @@ import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; /// [TileCoordinates]. final class TileImageView { final Map _tileImages; + final Set _positionCoordinates; final DiscreteTileRange _visibleRange; final DiscreteTileRange _keepRange; /// Create a new [TileImageView] instance. const TileImageView({ required Map tileImages, + required Set positionCoordinates, required DiscreteTileRange visibleRange, required DiscreteTileRange keepRange, }) : _tileImages = tileImages, + _positionCoordinates = positionCoordinates, _visibleRange = visibleRange, _keepRange = keepRange; /// Get a list with all tiles that have an error and are outside of the /// margin that should get kept. - List errorTilesOutsideOfKeepMargin() => _tileImages.values - .where((tileImage) => - tileImage.loadError && !_keepRange.contains(tileImage.coordinates)) - .toList(); + List errorTilesOutsideOfKeepMargin() => + _errorTilesWithinRange(_keepRange); /// Get a list with all tiles that are not visible on the current map /// viewport. - List errorTilesNotVisible() => _tileImages.values - .where((tileImage) => - tileImage.loadError && !_visibleRange.contains(tileImage.coordinates)) - .toList(); + List errorTilesNotVisible() => + _errorTilesWithinRange(_visibleRange); + + /// Get a list with all tiles that are not visible on the current map + /// viewport. + List _errorTilesWithinRange(DiscreteTileRange range) { + final List result = []; + for (final positionCoordinates in _positionCoordinates) { + if (range.contains(positionCoordinates)) { + continue; + } + final TileImage? tileImage = + _tileImages[TileCoordinates.key(positionCoordinates)]; + if (tileImage?.loadError ?? false) { + result.add(positionCoordinates); + } + } + return result; + } /// Get a list of [TileImage] that are stale and can get for pruned. - Iterable get staleTiles { - final stale = HashSet(); - final retain = HashSet(); - - for (final tile in _tileImages.values) { - final c = tile.coordinates; - if (!_keepRange.contains(c)) { - stale.add(tile); + Iterable get staleTiles { + final stale = HashSet(); + final retain = HashSet(); + + for (final positionCoordinates in _positionCoordinates) { + if (!_keepRange.contains(positionCoordinates)) { + stale.add(positionCoordinates); continue; } - final retainedAncestor = _retainAncestor(retain, c.x, c.y, c.z, c.z - 5); + final retainedAncestor = _retainAncestor( + retain, + positionCoordinates.x, + positionCoordinates.y, + positionCoordinates.z, + positionCoordinates.z - 5, + ); if (!retainedAncestor) { - _retainChildren(retain, c.x, c.y, c.z, c.z + 2); + _retainChildren( + retain, + positionCoordinates.x, + positionCoordinates.y, + positionCoordinates.z, + positionCoordinates.z + 2, + ); } } return stale.where((tile) => !retain.contains(tile)); } - /// Get a list of [TileImage] that need to get rendered on screen. - Iterable get renderTiles { - final retain = HashSet(); + /// Get a list of [TileCoordinates] that need to get rendered on screen. + Iterable get renderTiles { + final retain = HashSet(); - for (final tile in _tileImages.values) { - final c = tile.coordinates; - if (!_visibleRange.contains(c)) { + for (final positionCoordinates in _positionCoordinates) { + if (!_visibleRange.contains(positionCoordinates)) { continue; } - retain.add(tile); - - if (!tile.readyToDisplay) { - final retainedAncestor = - _retainAncestor(retain, c.x, c.y, c.z, c.z - 5); + retain.add(positionCoordinates); + + final TileImage? tile = + _tileImages[TileCoordinates.key(positionCoordinates)]; + if (tile == null || !tile.readyToDisplay) { + final retainedAncestor = _retainAncestor( + retain, + positionCoordinates.x, + positionCoordinates.y, + positionCoordinates.z, + positionCoordinates.z - 5, + ); if (!retainedAncestor) { - _retainChildren(retain, c.x, c.y, c.z, c.z + 2); + _retainChildren( + retain, + positionCoordinates.x, + positionCoordinates.y, + positionCoordinates.z, + positionCoordinates.z + 2, + ); } } } @@ -81,7 +120,7 @@ final class TileImageView { /// them to [retain] if they are ready to display or loaded. Returns true if /// any of the ancestor tiles were ready to display. bool _retainAncestor( - Set retain, + Set retain, int x, int y, int z, @@ -92,13 +131,13 @@ final class TileImageView { final z2 = z - 1; final coords2 = TileCoordinates(x2, y2, z2); - final tile = _tileImages[coords2]; + final tile = _tileImages[TileCoordinates.key(coords2)]; if (tile != null) { if (tile.readyToDisplay) { - retain.add(tile); + retain.add(coords2); return true; } else if (tile.loadFinishedAt != null) { - retain.add(tile); + retain.add(coords2); } } @@ -112,7 +151,7 @@ final class TileImageView { /// Recurse through the descendants of the Tile at the given coordinates /// adding them to [retain] if they are ready to display or loaded. void _retainChildren( - Set retain, + Set retain, int x, int y, int z, @@ -121,10 +160,10 @@ 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[coords]; + final tile = _tileImages[TileCoordinates.key(coords)]; if (tile != null) { if (tile.readyToDisplay || tile.loadFinishedAt != null) { - retain.add(tile); + retain.add(coords); // If have the child, we do not recurse. We don't need the child's children. continue; diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index 09c0e75f5..b7067613b 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -504,16 +504,17 @@ class _TileLayerState extends State with TickerProviderStateMixin { // cycles saved later on in the render pipeline. final tiles = _tileImageManager .getTilesToRender(visibleRange: visibleTileRange) - .map((tileImage) => Tile( + .map((tileRenderer) => Tile( // Must be an ObjectKey, not a ValueKey using the coordinates, in // case we remove and replace the TileImage with a different one. - key: ObjectKey(tileImage), + key: ObjectKey(tileRenderer), scaledTileSize: _tileScaleCalculator.scaledTileSize( map.zoom, - tileImage.coordinates.z, + tileRenderer.positionCoordinates.z, ), currentPixelOrigin: map.pixelOrigin, - tileImage: tileImage, + tileImage: tileRenderer.tileImage, + positionCoordinates: tileRenderer.positionCoordinates, tileBuilder: widget.tileBuilder, )) .toList(); diff --git a/lib/src/layer/tile_layer/tile_range.dart b/lib/src/layer/tile_layer/tile_range.dart index e39200be1..8970e54e1 100644 --- a/lib/src/layer/tile_layer/tile_range.dart +++ b/lib/src/layer/tile_layer/tile_range.dart @@ -111,8 +111,24 @@ 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 point) { - return _bounds.contains(point); + final int modulo = 1 << zoom; + + bool containsCoordinate(int value, int min, int max) { + int tmp = value; + while (tmp < min) { + tmp += modulo; + } + while (tmp > max) { + tmp -= modulo; + } + return tmp >= min && tmp <= max; + } + + return containsCoordinate(point.x, min.x, max.x) && + containsCoordinate(point.y, min.y, max.y); } /// The minimum [Point] of the [DiscreteTileRange] diff --git a/lib/src/layer/tile_layer/tile_renderer.dart b/lib/src/layer/tile_layer/tile_renderer.dart new file mode 100644 index 000000000..2425b32aa --- /dev/null +++ b/lib/src/layer/tile_layer/tile_renderer.dart @@ -0,0 +1,30 @@ +import 'package:flutter_map/src/layer/tile_layer/tile_coordinates.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_image.dart'; + +/// Display of a [TileImage] at given [TileCoordinates]. +/// +/// In most cases, the [positionCoordinates] are equal to tileImage coordinates. +/// Except when we display several worlds in the same map, or when we cross the +/// 180/-180 border. +class TileRenderer { + /// TileImage to display. + final TileImage tileImage; + + /// Position where to display [tileImage]. + final TileCoordinates positionCoordinates; + + /// Create an instance of [TileRenderer]. + const TileRenderer(this.tileImage, this.positionCoordinates); + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is TileRenderer && + other.positionCoordinates == positionCoordinates; + } + + @override + int get hashCode => positionCoordinates.hashCode; +} diff --git a/lib/src/map/camera/camera.dart b/lib/src/map/camera/camera.dart index 2bd37d522..18dc81eab 100644 --- a/lib/src/map/camera/camera.dart +++ b/lib/src/map/camera/camera.dart @@ -179,13 +179,30 @@ class MapCamera { crs: crs, minZoom: minZoom, maxZoom: maxZoom, - center: center ?? this.center, + center: _adjustPositionForSeamlessScrolling(center), zoom: zoom ?? this.zoom, rotation: rotation, nonRotatedSize: nonRotatedSize, size: _cameraSize, ); + /// Jumps camera to opposite side of the world to enable seamless scrolling + /// between 180 and -180 longitude. + LatLng _adjustPositionForSeamlessScrolling(LatLng? position) { + 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); + } + /// Calculates the size of a bounding box which surrounds a box of size /// [nonRotatedSize] which is rotated by [rotation]. static Point calculateRotatedSize( diff --git a/test/layer/tile_layer/tile_image_view_test.dart b/test/layer/tile_layer/tile_image_view_test.dart index 9bf21922b..00e7820a7 100644 --- a/test/layer/tile_layer/tile_image_view_test.dart +++ b/test/layer/tile_layer/tile_image_view_test.dart @@ -14,18 +14,6 @@ void main() { List tileImages) => {for (final tileImage in tileImages) tileImage.coordinates: tileImage}; - Matcher containsTileImage( - Map tileImages, - TileCoordinates coordinates, - ) => - contains(tileImages[coordinates]); - - Matcher doesNotContainTileImage( - Map tileImages, - TileCoordinates coordinates, - ) => - isNot(containsTileImage(tileImages, coordinates)); - DiscreteTileRange discreteTileRange( int x1, int y1, @@ -40,19 +28,21 @@ void main() { group('staleTiles', () { test('tiles outside of the keep range are stale', () { + const zoom = 10; final tileImages = tileImagesMappingFrom([ - MockTileImage(1, 1, 1), - MockTileImage(2, 1, 1), + MockTileImage(1, 1, zoom), + MockTileImage(2, 1, zoom), ]); final removalState = TileImageView( tileImages: tileImages, - visibleRange: discreteTileRange(2, 1, 3, 3, zoom: 1), - keepRange: discreteTileRange(2, 1, 3, 3, zoom: 1), + positionCoordinates: Set.from(tileImages.keys), + visibleRange: discreteTileRange(2, 1, 3, 3, zoom: zoom), + keepRange: discreteTileRange(2, 1, 3, 3, zoom: zoom), ); expect( removalState.staleTiles, - containsTileImage(tileImages, const TileCoordinates(1, 1, 1)), + contains(const TileCoordinates(1, 1, zoom)), ); }); @@ -63,12 +53,13 @@ void main() { ]); final removalState = TileImageView( tileImages: tileImages, + positionCoordinates: Set.from(tileImages.keys), visibleRange: discreteTileRange(0, 0, 0, 0, zoom: 1), keepRange: discreteTileRange(0, 0, 0, 0, zoom: 1), ); expect( removalState.staleTiles, - doesNotContainTileImage(tileImages, const TileCoordinates(0, 0, 0)), + isNot(contains(const TileCoordinates(0, 0, 0))), ); }); @@ -81,37 +72,40 @@ void main() { ]); final removalState = TileImageView( tileImages: tileImages, + positionCoordinates: Set.from(tileImages.keys), visibleRange: discreteTileRange(0, 0, 0, 0, zoom: 1), keepRange: discreteTileRange(0, 0, 0, 0, zoom: 1), ); expect( removalState.staleTiles, - doesNotContainTileImage(tileImages, const TileCoordinates(0, 0, 2)), + isNot(contains(const TileCoordinates(0, 0, 2))), ); }); test( 'returned elements can be removed from the source collection in a for loop', () { + const zoom = 10; final tileImages = tileImagesMappingFrom([ - MockTileImage(1, 1, 1), + MockTileImage(1, 1, zoom), ]); final removalState = TileImageView( tileImages: tileImages, - visibleRange: discreteTileRange(2, 1, 3, 3, zoom: 1), - keepRange: discreteTileRange(2, 1, 3, 3, zoom: 1), + positionCoordinates: Set.from(tileImages.keys), + visibleRange: discreteTileRange(2, 1, 3, 3, zoom: zoom), + keepRange: discreteTileRange(2, 1, 3, 3, zoom: zoom), ); expect( removalState.staleTiles, - containsTileImage(tileImages, const TileCoordinates(1, 1, 1)), + contains(const TileCoordinates(1, 1, zoom)), ); // If an iterator over the original collection is returned then when // looping over that iterator and removing from the original collection // a concurrent modification exception is thrown. This ensures that the // returned collection is not an iterable over the original collection. for (final staleTile in removalState.staleTiles) { - tileImages.remove(staleTile.coordinates)!; + tileImages.remove(staleTile)!; } }); }); @@ -125,11 +119,12 @@ void main() { ]); final tileImageView = TileImageView( tileImages: tileImages, + positionCoordinates: Set.from(tileImages.keys), visibleRange: discreteTileRange(1, 2, 1, 2, zoom: 1), keepRange: discreteTileRange(1, 2, 2, 2, zoom: 1), ); expect( - tileImageView.errorTilesOutsideOfKeepMargin().map((e) => e.coordinates), + tileImageView.errorTilesOutsideOfKeepMargin(), [const TileCoordinates(1, 1, 1)], ); @@ -137,34 +132,36 @@ void main() { // looping over that iterator and removing from the original collection // a concurrent modification exception is thrown. This ensures that the // returned collection is not an iterable over the original collection. - for (final tileImage in tileImageView.errorTilesOutsideOfKeepMargin()) { - tileImages.remove(tileImage.coordinates)!; + for (final coordinates in tileImageView.errorTilesOutsideOfKeepMargin()) { + tileImages.remove(coordinates)!; } }); test('errorTilesNotVisible', () { + const zoom = 10; final tileImages = tileImagesMappingFrom([ - MockTileImage(1, 1, 1, loadError: true), - MockTileImage(2, 1, 1), - MockTileImage(1, 2, 1), - MockTileImage(2, 2, 1, loadError: true), + MockTileImage(1, 1, zoom, loadError: true), + MockTileImage(2, 1, zoom), + MockTileImage(1, 2, zoom), + MockTileImage(2, 2, zoom, loadError: true), ]); final tileImageView = TileImageView( tileImages: tileImages, - visibleRange: discreteTileRange(1, 2, 1, 2, zoom: 1), - keepRange: discreteTileRange(1, 2, 2, 2, zoom: 1), + positionCoordinates: Set.from(tileImages.keys), + visibleRange: discreteTileRange(1, 2, 1, 2, zoom: zoom), + keepRange: discreteTileRange(1, 2, 2, 2, zoom: zoom), ); expect( - tileImageView.errorTilesNotVisible().map((e) => e.coordinates), - [const TileCoordinates(1, 1, 1), const TileCoordinates(2, 2, 1)], + tileImageView.errorTilesNotVisible(), + [const TileCoordinates(1, 1, zoom), const TileCoordinates(2, 2, zoom)], ); // If an iterator over the original collection is returned then when // looping over that iterator and removing from the original collection // a concurrent modification exception is thrown. This ensures that the // returned collection is not an iterable over the original collection. - for (final tileImage in tileImageView.errorTilesOutsideOfKeepMargin()) { - tileImages.remove(tileImage.coordinates)!; + for (final coordinates in tileImageView.errorTilesOutsideOfKeepMargin()) { + tileImages.remove(coordinates)!; } }); } diff --git a/test/layer/tile_layer/tile_range_test.dart b/test/layer/tile_layer/tile_range_test.dart index 346ffb532..62d001557 100644 --- a/test/layer/tile_layer/tile_range_test.dart +++ b/test/layer/tile_layer/tile_range_test.dart @@ -263,7 +263,7 @@ void main() { test('contains', () { final tileRange = DiscreteTileRange.fromPixelBounds( - zoom: 0, + zoom: 10, tileSize: 10, pixelBounds: Bounds( const Point(35, 35), From cb6d6a03125b92a3f6648bb4ae095dadc7a68b3a Mon Sep 17 00:00:00 2001 From: Luka S Date: Thu, 12 Sep 2024 20:00:40 +0100 Subject: [PATCH 2/6] fix: added polygon validity check before hit testing (#1964) --- lib/src/layer/polygon_layer/painter.dart | 42 +++++++++++------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/lib/src/layer/polygon_layer/painter.dart b/lib/src/layer/polygon_layer/painter.dart index 878bea4ea..44aff4b8b 100644 --- a/lib/src/layer/polygon_layer/painter.dart +++ b/lib/src/layer/polygon_layer/painter.dart @@ -64,32 +64,30 @@ base class _PolygonPainter origin: hitTestCameraOrigin, points: projectedPolygon.points, ); - if (projectedCoords.first != projectedCoords.last) { projectedCoords.add(projectedCoords.first); } - final isInPolygon = isPointInPolygon(point, projectedCoords); - - final hasHoles = projectedPolygon.holePoints.isNotEmpty; - final isInHole = hasHoles && - () { - for (final points in projectedPolygon.holePoints) { - final projectedHoleCoords = getOffsetsXY( - camera: camera, - origin: hitTestCameraOrigin, - points: points, - ); - - if (projectedHoleCoords.first != projectedHoleCoords.last) { - projectedHoleCoords.add(projectedHoleCoords.first); - } - if (isPointInPolygon(point, projectedHoleCoords)) { - return true; - } - } - return false; - }(); + final isValidPolygon = projectedCoords.length >= 3; + final isInPolygon = + isValidPolygon && isPointInPolygon(point, projectedCoords); + + final isInHole = projectedPolygon.holePoints.any( + (points) { + final projectedHoleCoords = getOffsetsXY( + camera: camera, + origin: hitTestCameraOrigin, + points: points, + ); + if (projectedHoleCoords.first != projectedHoleCoords.last) { + projectedHoleCoords.add(projectedHoleCoords.first); + } + + final isValidHolePolygon = projectedHoleCoords.length >= 3; + return isValidHolePolygon && + isPointInPolygon(point, projectedHoleCoords); + }, + ); // Second check handles case where polygon outline intersects a hole, // ensuring that the hit matches with the visual representation From 93e51e75dd07dcb6f83b73b93e533a97a233800a Mon Sep 17 00:00:00 2001 From: Luka S Date: Thu, 12 Sep 2024 20:09:13 +0100 Subject: [PATCH 3/6] chore(example): updated WMS tile source & updated web config (#1963) --- example/.metadata | 12 ++-- example/lib/pages/wms_tile_layer.dart | 87 +++++++++++++++++++++++++-- example/web/index.html | 61 ++++++------------- example/web/manifest.json | 4 +- 4 files changed, 109 insertions(+), 55 deletions(-) diff --git a/example/.metadata b/example/.metadata index 7b8b9e04e..fff87d5d9 100644 --- a/example/.metadata +++ b/example/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "0b591f2c82e9f59276ed68c7d4cbd63196f7c865" + revision: "7c6b7e9ca485f7eaaed913c6bb50f4be6da47e30" channel: "beta" project_type: app @@ -13,11 +13,11 @@ project_type: app migration: platforms: - platform: root - create_revision: 0b591f2c82e9f59276ed68c7d4cbd63196f7c865 - base_revision: 0b591f2c82e9f59276ed68c7d4cbd63196f7c865 - - platform: android - create_revision: 0b591f2c82e9f59276ed68c7d4cbd63196f7c865 - base_revision: 0b591f2c82e9f59276ed68c7d4cbd63196f7c865 + create_revision: 7c6b7e9ca485f7eaaed913c6bb50f4be6da47e30 + base_revision: 7c6b7e9ca485f7eaaed913c6bb50f4be6da47e30 + - platform: web + create_revision: 7c6b7e9ca485f7eaaed913c6bb50f4be6da47e30 + base_revision: 7c6b7e9ca485f7eaaed913c6bb50f4be6da47e30 # User provided section diff --git a/example/lib/pages/wms_tile_layer.dart b/example/lib/pages/wms_tile_layer.dart index 484da0031..33e25f709 100644 --- a/example/lib/pages/wms_tile_layer.dart +++ b/example/lib/pages/wms_tile_layer.dart @@ -22,7 +22,7 @@ class WMSLayerPage extends StatelessWidget { children: [ TileLayer( wmsOptions: WMSTileLayerOptions( - baseUrl: 'https://{s}.s2maps-tiles.eu/wms/?', + baseUrl: 'https://tiles.maps.eox.at/wms?', layers: const ['s2cloudless-2021_3857'], ), subdomains: const ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'], @@ -32,15 +32,90 @@ class WMSLayerPage extends StatelessWidget { popupInitialDisplayDuration: const Duration(seconds: 5), attributions: [ TextSourceAttribution( - 'Sentinel-2 cloudless - https://s2maps.eu by EOX IT Services GmbH', - onTap: () => launchUrl(Uri.parse('https://s2maps.eu')), + 'Sentinel-2 provided by the European Commission (free, full ' + 'and open access) [Sentinel-2 cloudless 2016, 2018, 2019, ' + '2020, 2021, 2022 & 2023]', + onTap: () => launchUrl(Uri.parse( + 'https://sentinel.esa.int/documents/247904/690755/Sentinel_Data_Legal_Notice', + )), + prependCopyright: false, + ), + TextSourceAttribution( + 'OpenStreetMap © OpenStreetMap contributors [Terrain Light, ' + 'Terrain, OpenStreetMap, Overlay]', + onTap: () => launchUrl(Uri.parse( + 'http://www.openstreetmap.org/copyright', + )), + prependCopyright: false, + ), + TextSourceAttribution( + 'NaturalEarth public domain [Terrain Light, Terrain, Overlay]', + onTap: () => launchUrl(Uri.parse( + 'http://www.naturalearthdata.com/about/terms-of-use/', + )), + prependCopyright: false, + ), + const TextSourceAttribution( + 'EUDEM © Produced using Copernicus data and information funded ' + 'by the European Union [Terrain]', + prependCopyright: false, + ), + TextSourceAttribution( + 'ASTER GDEM is a product of METI and NASA [Terrain Light]', + onTap: () => launchUrl(Uri.parse( + 'https://lpdaac.usgs.gov/products/aster_policies', + )), + prependCopyright: false, + ), + TextSourceAttribution( + 'SRTM © NASA [Terrain]', + onTap: () => launchUrl(Uri.parse('http://www.nasa.gov/')), + prependCopyright: false, + ), + TextSourceAttribution( + 'GTOPO30 Data available from the U.S. Geological Survey ' + '[Terrain Light, Terrain]', + onTap: () => launchUrl(Uri.parse( + 'https://lta.cr.usgs.gov/GTOPO30', + )), + prependCopyright: false, ), const TextSourceAttribution( - 'Modified Copernicus Sentinel data 2021', + 'CleanTOPO2 public domain [Terrain]', + prependCopyright: false, + ), + TextSourceAttribution( + 'GEBCO © GEBCO [Terrain Light]', + onTap: () => launchUrl(Uri.parse('http://www.gebco.net/')), + prependCopyright: false, + ), + TextSourceAttribution( + 'GlobCover © ESA [Terrain]', + onTap: () => launchUrl(Uri.parse( + 'http://due.esrin.esa.int/page_globcover.php', + )), + prependCopyright: false, + ), + const TextSourceAttribution( + "Blue Marble © NASA's Earth Observatory [Blue Marble]", + prependCopyright: false, + ), + const TextSourceAttribution( + "Black Marble © NASA's Earth Observatory [Black Marble]", + prependCopyright: false, + ), + TextSourceAttribution( + 'Rendering: © EOX [Terrain Light, Terrain, OpenStreetMap, ' + 'Overlay]', + onTap: () => launchUrl(Uri.parse('http://eox.at/')), + prependCopyright: false, ), TextSourceAttribution( - 'Rendering: EOX::Maps', - onTap: () => launchUrl(Uri.parse('https://maps.eox.at/')), + 'Rendering: © MapServer [OpenStreetMap, Overlay]', + onTap: () => launchUrl(Uri.parse( + 'https://github.com/mapserver/basemaps', + )), + prependCopyright: false, ), ], ), diff --git a/example/web/index.html b/example/web/index.html index 74938b05c..5fd73a2fc 100644 --- a/example/web/index.html +++ b/example/web/index.html @@ -2,7 +2,7 @@ - - - - - - - - - - - - - - - - - flutter_map Demo - - - - - + + + + + + + + + + + + + + + + flutter_map Demo + - + \ No newline at end of file diff --git a/example/web/manifest.json b/example/web/manifest.json index 7e15589a8..b970439d5 100644 --- a/example/web/manifest.json +++ b/example/web/manifest.json @@ -3,8 +3,8 @@ "short_name": "flutter_map", "start_url": ".", "display": "standalone", - "background_color": "#0175C2", - "theme_color": "#0175C2", + "background_color": "#8EEA88", + "theme_color": "#8EEA88", "description": "A versatile mapping package for Flutter, based off leaflet.js, that's simple and easy to learn, yet completely customizable and configurable.", "orientation": "portrait-primary", "prefer_related_applications": false, From 7632ccc6d95cf4b0d02760f6d259495e7a1d09d0 Mon Sep 17 00:00:00 2001 From: Luka S Date: Thu, 12 Sep 2024 20:25:07 +0100 Subject: [PATCH 4/6] chore(example): added WASM support to example (#1885) --- .github/workflows/branch.yml | 2 +- .github/workflows/master.yml | 2 +- example/lib/widgets/drawer/menu_drawer.dart | 9 +++++++++ example/pubspec.yaml | 8 ++++---- firebase.json | 17 ++++++++++++++++- 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/.github/workflows/branch.yml b/.github/workflows/branch.yml index 1798d9128..5e62a2ba5 100644 --- a/.github/workflows/branch.yml +++ b/.github/workflows/branch.yml @@ -138,7 +138,7 @@ jobs: channel: "stable" cache: true - name: Build Web Application - run: flutter build web --web-renderer canvaskit + run: flutter build web --wasm - name: Archive Artifact uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index 335ccd9f8..07f01a897 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -104,7 +104,7 @@ jobs: channel: "stable" cache: true - name: Build Web Application - run: flutter build web --web-renderer canvaskit + run: flutter build web --wasm - name: Archive Artifact uses: actions/upload-artifact@v4 with: diff --git a/example/lib/widgets/drawer/menu_drawer.dart b/example/lib/widgets/drawer/menu_drawer.dart index b66ad2acb..14a4cab6b 100644 --- a/example/lib/widgets/drawer/menu_drawer.dart +++ b/example/lib/widgets/drawer/menu_drawer.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map_example/pages/animated_map_controller.dart'; import 'package:flutter_map_example/pages/bundled_offline_map.dart'; @@ -33,6 +34,8 @@ import 'package:flutter_map_example/pages/tile_loading_error_handle.dart'; import 'package:flutter_map_example/pages/wms_tile_layer.dart'; import 'package:flutter_map_example/widgets/drawer/menu_item.dart'; +const _isWASM = bool.fromEnvironment('dart.tool.dart2wasm'); + class MenuDrawer extends StatelessWidget { final String currentRoute; @@ -61,6 +64,12 @@ class MenuDrawer extends StatelessWidget { textAlign: TextAlign.center, style: TextStyle(fontSize: 14), ), + if (kIsWeb) + const Text( + _isWASM ? 'Running with WASM' : 'Running without WASM', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14), + ), ], ), ), diff --git a/example/pubspec.yaml b/example/pubspec.yaml index be2e58452..705010e06 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -11,14 +11,14 @@ dependencies: flutter: sdk: flutter flutter_map: - flutter_map_cancellable_tile_provider: ^3.0.0 + flutter_map_cancellable_tile_provider: ^3.0.2 flutter_map_geojson: ^1.0.8 - http: ^1.2.1 + http: ^1.2.2 latlong2: ^0.9.1 proj4dart: ^2.1.0 - shared_preferences: ^2.2.3 + shared_preferences: ^2.3.2 url_launcher: ^6.3.0 - url_strategy: ^0.2.0 + url_strategy: ^0.3.0 vector_math: ^2.1.4 dependency_overrides: diff --git a/firebase.json b/firebase.json index 6adbf8c5b..4824c2b57 100644 --- a/firebase.json +++ b/firebase.json @@ -11,6 +11,21 @@ "source": "**", "destination": "/index.html" } + ], + "headers": [ + { + "source": "**", + "headers": [ + { + "key": "Cross-Origin-Embedder-Policy", + "value": "credentialless" + }, + { + "key": "Cross-Origin-Opener-Policy", + "value": "same-origin" + } + ] + } ] } -} +} \ No newline at end of file From 5f2d6461bdc50cf1078ce94aee97e500e256e487 Mon Sep 17 00:00:00 2001 From: Luka S Date: Thu, 19 Sep 2024 00:04:41 +0100 Subject: [PATCH 5/6] fix: remove `hitValue` from `Polyline.renderHashCode` (#1967) Removed `hitValue` from `Polyline.renderHashCode` calculation to improve performance --- lib/src/layer/polyline_layer/polyline.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/src/layer/polyline_layer/polyline.dart b/lib/src/layer/polyline_layer/polyline.dart index 72dc2f4f6..73521a937 100644 --- a/lib/src/layer/polyline_layer/polyline.dart +++ b/lib/src/layer/polyline_layer/polyline.dart @@ -91,7 +91,6 @@ class Polyline { strokeCap, strokeJoin, useStrokeWidthInMeter, - hitValue, ); int? _hashCode; From 4c3e3e25a67c5c69fa03b94037d8076f96d3dc04 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Sep 2024 10:15:45 +0200 Subject: [PATCH 6/6] chore(deps): bump flutter_lints from 4.0.0 to 5.0.0, add /example to dependabot, fix lint (#1971) * chore(deps): bump flutter_lints from 4.0.0 to 5.0.0 Bumps [flutter_lints](https://github.com/flutter/packages/tree/main/packages) from 4.0.0 to 5.0.0. - [Release notes](https://github.com/flutter/packages/releases) - [Commits](https://github.com/flutter/packages/commits/flutter_lints-v5.0.0/packages) --- updated-dependencies: - dependency-name: flutter_lints dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] * bump flutter_lints in /example, fix lint, add dependabot alerts for /example --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Joscha <34318751+josxha@users.noreply.github.com> --- .github/dependabot.yml | 2 +- example/pubspec.yaml | 2 +- lib/flutter_map.dart | 2 +- pubspec.yaml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c4baacd3e..a387de716 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,7 +7,7 @@ version: 2 enable-beta-ecosystems: true updates: - package-ecosystem: "pub" - directory: "/" + directories: [ "/", "/example/" ] schedule: interval: "daily" ignore: diff --git a/example/pubspec.yaml b/example/pubspec.yaml index 705010e06..37c2fb06f 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -26,7 +26,7 @@ dependency_overrides: path: ../ dev_dependencies: - flutter_lints: ^4.0.0 + flutter_lints: ">=4.0.0 <6.0.0" flutter_test: sdk: flutter diff --git a/lib/flutter_map.dart b/lib/flutter_map.dart index 8e7d1dd3f..f7ad8b96d 100644 --- a/lib/flutter_map.dart +++ b/lib/flutter_map.dart @@ -14,7 +14,7 @@ /// * github.com: /// * pub.dev: /// * discord.gg: -library flutter_map; +library; export 'package:flutter_map/src/geo/crs.dart' hide CrsWithStaticTransformation; export 'package:flutter_map/src/geo/latlng_bounds.dart'; diff --git a/pubspec.yaml b/pubspec.yaml index fa55c078f..f79b21829 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -40,7 +40,7 @@ dependencies: vector_math: ^2.1.4 dev_dependencies: - flutter_lints: ^4.0.0 + flutter_lints: ">=4.0.0 <6.0.0" flutter_test: sdk: flutter mocktail: ^1.0.3