diff --git a/example/lib/pages/pitch.dart b/example/lib/pages/pitch.dart index 3168defd0..894cc3eb2 100644 --- a/example/lib/pages/pitch.dart +++ b/example/lib/pages/pitch.dart @@ -33,8 +33,8 @@ class _PitchPageState extends State { children: [ const Text( 'Markers and labels face the viewer; tiles, polygons and ' - 'polylines lie on the ground. At 60°, the tile layer loads ' - 'up to 7.5 times as many tiles as when unpitched.', + 'polylines lie on the ground. Far ground uses coarser ' + 'tiles.', textAlign: TextAlign.center, style: TextStyle(fontSize: 12), ), diff --git a/lib/src/layer/tile_layer/tile_cover.dart b/lib/src/layer/tile_layer/tile_cover.dart new file mode 100644 index 000000000..3e9a6a5e7 --- /dev/null +++ b/lib/src/layer/tile_layer/tile_cover.dart @@ -0,0 +1,94 @@ +import 'dart:collection'; + +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; +import 'package:flutter_map/src/misc/bounds.dart'; + +/// A set of tiles, at one or more zooms. +abstract interface class TileCover { + /// The cover of exactly [tiles]. [expand] doesn't add the tiles for which + /// [isCovered] is true: tiles that [tiles] cover at another zoom. + factory TileCover( + Iterable tiles, { + bool Function(TileCoordinates tile)? isCovered, + }) = _TileSet; + + /// Whether this cover contains [point], a [TileCoordinates] whose zoom is + /// compared as well as its x and y. + /// + /// If [replicatesWorldLongitude] is true, x is compared modulo the number of + /// tiles at that zoom. + bool contains(TileCoordinates point, {bool replicatesWorldLongitude = false}); + + /// This cover and every tile within [count] tiles of one of its tiles, at + /// that tile's zoom, except tiles that this cover covers at another zoom. + TileCover expand(int count); + + /// The tiles of this cover. + Iterable get coordinates; + + /// The tiles of this cover that are valid within [bounds]. + Iterable validCoordinatesIn(TileBounds bounds); +} + +class _TileSet implements TileCover { + final Set _tiles; + + /// [_tiles] with x wrapped into the world, created when first needed. + Set? _wrappedTiles; + + final bool Function(TileCoordinates tile)? _isCovered; + + _TileSet( + Iterable tiles, { + bool Function(TileCoordinates tile)? isCovered, + }) : _tiles = LinkedHashSet.of(tiles), + _isCovered = isCovered; + + @override + bool contains( + TileCoordinates point, { + bool replicatesWorldLongitude = false, + }) { + if (!replicatesWorldLongitude) return _tiles.contains(point); + return (_wrappedTiles ??= {for (final tile in _tiles) _wrapX(tile)}) + .contains(_wrapX(point)); + } + + static TileCoordinates _wrapX(TileCoordinates tile) => + TileCoordinates(tile.x % (1 << tile.z), tile.y, tile.z); + + @override + TileCover expand(int count) { + if (count == 0) return this; + final expanded = LinkedHashSet.of(_tiles); + for (final tile in _tiles) { + for (var y = tile.y - count; y <= tile.y + count; y++) { + for (var x = tile.x - count; x <= tile.x + count; x++) { + final neighbor = TileCoordinates(x, y, tile.z); + if (_tiles.contains(neighbor) || + (_isCovered?.call(neighbor) ?? false)) { + continue; + } + expanded.add(neighbor); + } + } + } + return _TileSet(expanded, isCovered: _isCovered); + } + + @override + Iterable get coordinates => _tiles; + + @override + Iterable validCoordinatesIn(TileBounds bounds) => + _tiles.where((tile) => bounds + .atZoom(tile.z) + .validCoordinatesIn( + DiscreteTileRange(tile.z, IntegerBounds(tile, tile))) + .isNotEmpty); + + @override + String toString() => 'TileCover(${_tiles.length} tiles)'; +} diff --git a/lib/src/layer/tile_layer/tile_cover_calculator.dart b/lib/src/layer/tile_layer/tile_cover_calculator.dart new file mode 100644 index 000000000..2f7e46a4a --- /dev/null +++ b/lib/src/layer/tile_layer/tile_cover_calculator.dart @@ -0,0 +1,223 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_cover.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; +import 'package:flutter_map/src/misc/extensions.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:meta/meta.dart'; + +/// Calculates the tiles that cover the ground visible in a [MapCamera], each at +/// the zoom that its size on screen gives. +@immutable +class TileCoverCalculator { + /// The tile size in pixels. + final int tileDimension; + + /// Create a new [TileCoverCalculator] instance. + const TileCoverCalculator({required this.tileDimension}); + + /// The tiles that cover the ground visible from [camera], when viewed from + /// [center] at [viewingZoom] instead of the camera's center and zoom. + /// + /// Each tile's zoom is `round(viewingZoom + log2(scale))`, clamped to the + /// range from [minNativeZoom] to [tileZoom], where scale is the largest size + /// on screen of one projected pixel within the tile's part of the visible + /// ground. [tileZoom] is the zoom of an unpitched map: [viewingZoom] rounded, + /// within the native zoom limits. The visible ground is computed from the + /// center projected and floored at [tileZoom], so that on an unpitched, + /// unrotated map the cover is the range of tiles at [tileZoom] that contain + /// the visible pixel bounds. + /// + /// [TileCover.expand] of the result adds only tiles that don't intersect the + /// visible ground. + TileCover calculate({ + required MapCamera camera, + required int tileZoom, + required int minNativeZoom, + LatLng? center, + double? viewingZoom, + }) => + _Cover( + camera: camera, + tileDimension: tileDimension, + tileZoom: tileZoom, + minNativeZoom: minNativeZoom, + center: center ?? camera.center, + viewingZoom: viewingZoom ?? camera.zoom, + ).tiles(); +} + +class _Cover { + final MapCamera camera; + final int tileDimension; + final int minNativeZoom; + final double viewingZoom; + + /// The ground under the corners of the viewport, from the center, in pixels + /// at [viewingZoom]. + final List _corners; + final Offset _pixelCenter; + final int _tileZoom; + final _polygons = >{}; + + _Cover({ + required this.camera, + required this.tileDimension, + required int tileZoom, + required this.minNativeZoom, + required LatLng center, + required this.viewingZoom, + }) : _tileZoom = tileZoom, + _pixelCenter = + camera.projectAtZoom(center, tileZoom.toDouble()).floor(), + _corners = camera.nonRotatedSize.isEmpty + ? const [Offset.zero] + : [ + Offset.zero, + Offset(camera.nonRotatedSize.width, 0), + Offset( + camera.nonRotatedSize.width, camera.nonRotatedSize.height), + Offset(0, camera.nonRotatedSize.height), + ] + .map((corner) => camera.groundOffsetFromCenter(corner)!) + .toList(); + + int _zoomAtScale(double scale) => (viewingZoom + math.log(scale) / math.ln2) + .round() + .clamp(minNativeZoom, _tileZoom); + + /// The center in pixels at [zoom]. + Offset _centerAt(int zoom) => zoom == _tileZoom + ? _pixelCenter + : _pixelCenter * + camera.getZoomScale(zoom.toDouble(), _tileZoom.toDouble()); + + /// The visible ground in pixels at [zoom]. + List _polygon(int zoom) => _polygons.putIfAbsent(zoom, () { + final center = _centerAt(zoom); + final scale = camera.getZoomScale(viewingZoom, zoom.toDouble()); + return [for (final corner in _corners) center + corner / scale]; + }); + + TileCover tiles() { + final startZoom = + _zoomAtScale(_corners.map(camera.groundScale).reduce(math.min)); + final polygon = _polygon(startZoom); + final bounds = Rect.fromLTRB( + polygon.map((p) => p.dx).reduce(math.min), + polygon.map((p) => p.dy).reduce(math.min), + polygon.map((p) => p.dx).reduce(math.max), + polygon.map((p) => p.dy).reduce(math.max), + ); + final range = DiscreteTileRange.fromPixelBounds( + zoom: startZoom, + tileDimension: tileDimension, + pixelBounds: bounds, + ); + // An empty viewport touches no tile; like the range, it covers the tile at + // its position. + if (bounds.isEmpty) return range; + + final result = []; + for (final tile in range.coordinates) { + if (_intersects(tile)) _add(tile, result); + } + return TileCover(result, isCovered: _intersects); + } + + /// Adds [tile], or its descendants if the visible ground in it needs a finer + /// zoom. + void _add(TileCoordinates tile, List result) { + final clipped = _clip(_polygon(tile.z), _square(tile)); + if (clipped.isEmpty) { + result.add(tile); + return; + } + final toViewingZoom = camera.getZoomScale(viewingZoom, tile.z.toDouble()); + final center = _centerAt(tile.z); + final scale = clipped + .map((p) => camera.groundScale((p - center) * toViewingZoom)) + .reduce(math.max); + if (_zoomAtScale(scale) <= tile.z) { + result.add(tile); + return; + } + for (final (i, j) in const [(0, 0), (1, 0), (0, 1), (1, 1)]) { + final child = TileCoordinates(2 * tile.x + i, 2 * tile.y + j, tile.z + 1); + if (_intersects(child)) _add(child, result); + } + } + + Rect _square(TileCoordinates tile) => Rect.fromLTWH( + (tile.x * tileDimension).toDouble(), + (tile.y * tileDimension).toDouble(), + tileDimension.toDouble(), + tileDimension.toDouble(), + ); + + /// Whether [tile] and the visible ground overlap in more than an edge, as + /// in [DiscreteTileRange.fromPixelBounds]: a separating axis test. + bool _intersects(TileCoordinates tile) { + final polygon = _polygon(tile.z); + final square = _square(tile); + final squareCorners = [ + square.topLeft, + square.topRight, + square.bottomRight, + square.bottomLeft, + ]; + bool separated(Offset axis) { + double dot(Offset p) => p.dx * axis.dx + p.dy * axis.dy; + final a = polygon.map(dot); + final b = squareCorners.map(dot); + return a.reduce(math.max) <= b.reduce(math.min) || + b.reduce(math.max) <= a.reduce(math.min); + } + + if (separated(const Offset(1, 0)) || separated(const Offset(0, 1))) { + return false; + } + for (var i = 0; i < polygon.length; i++) { + final edge = polygon[(i + 1) % polygon.length] - polygon[i]; + // Edges along the x and y axes were tested above. + if (edge.dx == 0 || edge.dy == 0) continue; + if (separated(Offset(-edge.dy, edge.dx))) return false; + } + return true; + } + + /// The part of the convex [polygon] inside [rect]. + static List _clip(List polygon, Rect rect) { + var points = polygon; + for (final (inside, intersect) + in <(bool Function(Offset), Offset Function(Offset, Offset))>[ + ((p) => p.dx >= rect.left, (a, b) => _atX(a, b, rect.left)), + ((p) => p.dx <= rect.right, (a, b) => _atX(a, b, rect.right)), + ((p) => p.dy >= rect.top, (a, b) => _atY(a, b, rect.top)), + ((p) => p.dy <= rect.bottom, (a, b) => _atY(a, b, rect.bottom)), + ]) { + final input = points; + points = []; + for (var i = 0; i < input.length; i++) { + final current = input[i]; + final previous = input[(i + input.length - 1) % input.length]; + if (inside(current)) { + if (!inside(previous)) points.add(intersect(previous, current)); + points.add(current); + } else if (inside(previous)) { + points.add(intersect(previous, current)); + } + } + if (points.isEmpty) break; + } + return points; + } + + static Offset _atX(Offset a, Offset b, double x) => + Offset(x, a.dy + (b.dy - a.dy) * (x - a.dx) / (b.dx - a.dx)); + + static Offset _atY(Offset a, Offset b, double y) => + Offset(a.dx + (b.dx - a.dx) * (y - a.dy) / (b.dy - a.dy), y); +} diff --git a/lib/src/layer/tile_layer/tile_image_manager.dart b/lib/src/layer/tile_layer/tile_image_manager.dart index a581b3992..d9fbf18a9 100644 --- a/lib/src/layer/tile_layer/tile_image_manager.dart +++ b/lib/src/layer/tile_layer/tile_image_manager.dart @@ -3,9 +3,8 @@ import 'dart:collection'; import 'package:collection/collection.dart'; import 'package:flutter_map/flutter_map.dart'; 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_cover.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'; /// Callback definition to create a [TileImage] for [TileCoordinates]. @@ -48,7 +47,7 @@ class TileImageManager { /// 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({ - required DiscreteTileRange visibleRange, + required TileCover visibleRange, }) { final Iterable positionCoordinates = _getTileImageView( visibleRange: visibleRange, @@ -67,8 +66,8 @@ class TileImageManager { } TileImageView _getTileImageView({ - required DiscreteTileRange visibleRange, - required DiscreteTileRange keepRange, + required TileCover visibleRange, + required TileCover keepRange, }) => TileImageView( tileImages: _tiles, @@ -83,16 +82,17 @@ class TileImageManager { .map((e) => e.coordinates) .every((coord) => coord.z > maxZoom || coord.z < minZoom); - /// Creates missing [TileImage]s within the provided tile range. Returns a - /// list of [TileImage]s which haven't started loading yet. + /// Creates missing [TileImage]s for the tiles of [tileCover] that are valid + /// within [tileBounds]. Returns a list of [TileImage]s which haven't started + /// loading yet. List createMissingTiles( - DiscreteTileRange tileRange, - TileBoundsAtZoom tileBoundsAtZoom, { + TileCover tileCover, + TileBounds tileBounds, { required TileCreator createTile, }) { final notLoaded = []; - for (final coordinates in tileBoundsAtZoom.validCoordinatesIn(tileRange)) { + for (final coordinates in tileCover.validCoordinatesIn(tileBounds)) { final cleanCoordinates = _resolver.get(coordinates); TileImage? tile = _tiles[cleanCoordinates]; if (tile == null) { @@ -188,7 +188,7 @@ class TileImageManager { /// evict tiles that have an error and prune tiles that are no longer needed. void evictAndPrune({ - required DiscreteTileRange visibleRange, + required TileCover visibleRange, required int pruneBuffer, required EvictErrorTileStrategy evictStrategy, }) { @@ -223,7 +223,7 @@ class TileImageManager { /// Prune tiles from the [TileImageManager]. void prune({ - required DiscreteTileRange visibleRange, + required TileCover visibleRange, required int pruneBuffer, required EvictErrorTileStrategy evictStrategy, }) { diff --git a/lib/src/layer/tile_layer/tile_image_view.dart b/lib/src/layer/tile_layer/tile_image_view.dart index 5a67e07cd..800279db9 100644 --- a/lib/src/layer/tile_layer/tile_image_view.dart +++ b/lib/src/layer/tile_layer/tile_image_view.dart @@ -1,23 +1,23 @@ import 'dart:collection'; import 'package:flutter_map/flutter_map.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_cover.dart'; /// The [TileImageView] stores all loaded [TileImage]s with their /// [TileCoordinates]. final class TileImageView { final Map _tileImages; final Set _positionCoordinates; - final DiscreteTileRange _visibleRange; - final DiscreteTileRange _keepRange; + final TileCover _visibleRange; + final TileCover _keepRange; final TileCoordinatesResolver _resolver; /// Create a new [TileImageView] instance. const TileImageView({ required Map tileImages, required Set positionCoordinates, - required DiscreteTileRange visibleRange, - required DiscreteTileRange keepRange, + required TileCover visibleRange, + required TileCover keepRange, final TileCoordinatesResolver resolver = const TileCoordinatesResolver(false), }) : _tileImages = tileImages, @@ -38,7 +38,7 @@ final class TileImageView { /// Get a list with all tiles that are not visible on the current map /// viewport. - List _errorTilesWithinRange(DiscreteTileRange range) { + List _errorTilesWithinRange(TileCover range) { final List result = []; for (final positionCoordinates in _positionCoordinates) { if (range.contains( diff --git a/lib/src/layer/tile_layer/tile_layer.dart b/lib/src/layer/tile_layer/tile_layer.dart index c53e27ad0..b6ba5a611 100644 --- a/lib/src/layer/tile_layer/tile_layer.dart +++ b/lib/src/layer/tile_layer/tile_layer.dart @@ -7,15 +7,15 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/src/layer/tile_layer/tile.dart'; 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_cover.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_cover_calculator.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_image_manager.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_key.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; -import 'package:flutter_map/src/layer/tile_layer/tile_range_calculator.dart'; import 'package:flutter_map/src/layer/tile_layer/tile_scale_calculator.dart'; import 'package:flutter_map/src/misc/extensions.dart'; import 'package:http/http.dart'; import 'package:http/retry.dart'; +import 'package:latlong2/latlong.dart'; part 'retina_mode.dart'; part 'tile_error_evict_callback.dart'; @@ -154,13 +154,14 @@ class TileLayer extends StatefulWidget { /// https://docs.fleaflet.dev/layers/tile-layer/tile-providers. late final TileProvider tileProvider; - /// When panning the map, keep this many rows and columns of tiles before - /// unloading them. + /// When panning the map, keep this many rows and columns of tiles around the + /// visible ground before unloading them, at the zoom of each tile. final int keepBuffer; /// When loading tiles only visible tiles are loaded by default. This option - /// increases the loaded tiles by the given number on both axis which can help - /// prevent the user from seeing loading tiles whilst panning. Setting the + /// loads this many rows and columns of tiles around the visible ground, at the + /// zoom of each tile, which can help prevent the user from seeing loading + /// tiles whilst panning. Setting the /// pan buffer too high can impact performance, typically this is set to zero /// or one. final int panBuffer; @@ -365,7 +366,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { final _tileImageManager = TileImageManager(); late TileBounds _tileBounds; - late TileRangeCalculator _tileRangeCalculator; + late TileCoverCalculator _tileCoverCalculator; late TileScaleCalculator _tileScaleCalculator; // We have to hold on to the mapController hashCode to determine whether we @@ -388,7 +389,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { void initState() { super.initState(); _resetSub = widget.reset?.listen(_resetStreamHandler); - _tileRangeCalculator = TileRangeCalculator(tileDimension: _tileDimension); + _tileCoverCalculator = TileCoverCalculator(tileDimension: _tileDimension); _warnOpenStreetMapUrl(); } @@ -447,8 +448,8 @@ class _TileLayerState extends State with TickerProviderStateMixin { super.didUpdateWidget(oldWidget); var reloadTiles = false; - // There is no caching in TileRangeCalculator so we can just replace it. - _tileRangeCalculator = TileRangeCalculator(tileDimension: _tileDimension); + // There is no caching in TileCoverCalculator so we can just replace it. + _tileCoverCalculator = TileCoverCalculator(tileDimension: _tileDimension); if (_tileBounds.shouldReplace( _tileBounds.crs, _tileDimension, widget.tileBounds)) { @@ -524,12 +525,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { if (_outsideZoomLimits(map.zoom.round())) return const SizedBox.shrink(); - final tileZoom = _clampToNativeZoom(map.zoom); - final tileBoundsAtZoom = _tileBounds.atZoom(tileZoom); - final visibleTileRange = _tileRangeCalculator.calculate( - camera: map, - tileZoom: tileZoom, - ); + final visibleTileCover = _visibleTileCover(map); // For a given map event both this rebuild method and the tile // loading/pruning logic will be fired. Any TileImages which are not @@ -540,11 +536,10 @@ class _TileLayerState extends State with TickerProviderStateMixin { // the Tile and become visible. We don't need to prune here as any new tiles // will be pruned when the map event triggers tile loading. _tileImageManager.createMissingTiles( - visibleTileRange, - tileBoundsAtZoom, + visibleTileCover, + _tileBounds, createTile: (coordinates) => _createTileImage( coordinates: coordinates, - tileBoundsAtZoom: tileBoundsAtZoom, pruneAfterLoad: false, ), ); @@ -557,7 +552,7 @@ class _TileLayerState extends State with TickerProviderStateMixin { // We're happy to do a bit of diligent work here, since tiles not rendered are // cycles saved later on in the render pipeline. final tiles = _tileImageManager - .getTilesToRender(visibleRange: visibleTileRange) + .getTilesToRender(visibleRange: visibleTileCover) .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. @@ -573,20 +568,10 @@ class _TileLayerState extends State with TickerProviderStateMixin { )) .toList(); - // Sort in render order. In reverse: - // 1. Tiles at the current zoom. - // 2. Tiles at the current zoom +/- 1. - // 3. Tiles at the current zoom +/- 2. - // 4. ...etc - int renderOrder(Tile a, Tile b) { - final (za, zb) = (a.tileImage.coordinates.z, b.tileImage.coordinates.z); - final cmp = (zb - tileZoom).abs().compareTo((za - tileZoom).abs()); - if (cmp == 0) { - // When compare parent/child tiles of equal distance, prefer higher res images. - return za.compareTo(zb); - } - return cmp; - } + // Coarsest first, so that a tile that fills a gap is never drawn over a + // finer tile that is ready, now that the visible tiles have mixed zooms. + int renderOrder(Tile a, Tile b) => + a.tileImage.coordinates.z.compareTo(b.tileImage.coordinates.z); return MobileLayerTransformer( child: Stack(children: tiles..sort(renderOrder)), @@ -595,10 +580,10 @@ class _TileLayerState extends State with TickerProviderStateMixin { TileImage _createTileImage({ required TileCoordinates coordinates, - required TileBoundsAtZoom tileBoundsAtZoom, required bool pruneAfterLoad, }) { final cancelLoading = Completer(); + final tileBoundsAtZoom = _tileBounds.atZoom(coordinates.z); final imageProvider = widget.tileProvider.supportsCancelLoading ? widget.tileProvider.getImageWithCancelLoadingSupport( @@ -628,21 +613,23 @@ class _TileLayerState extends State with TickerProviderStateMixin { /// Load and/or prune tiles according to the visible bounds of the [event] /// center/zoom, or the current center/zoom if not specified. void _onTileUpdateEvent(TileUpdateEvent event) { - final tileZoom = _clampToNativeZoom(event.zoom); - final visibleTileRange = _tileRangeCalculator.calculate( - camera: event.camera, - tileZoom: tileZoom, + final visibleTileCover = _visibleTileCover( + event.camera, center: event.center, - viewingZoom: event.zoom, + zoom: event.zoom, ); - if (event.load && !_outsideZoomLimits(tileZoom)) { - _loadTiles(visibleTileRange, pruneAfterLoad: event.prune); + if (event.load && !_outsideZoomLimits(_clampToNativeZoom(event.zoom))) { + _loadTiles( + visibleTileCover, + event.camera.withPosition(center: event.center, zoom: event.zoom), + pruneAfterLoad: event.prune, + ); } if (event.prune) { _tileImageManager.evictAndPrune( - visibleRange: visibleTileRange, + visibleRange: visibleTileCover, pruneBuffer: widget.panBuffer + widget.keepBuffer, evictStrategy: widget.evictErrorTileStrategy, ); @@ -651,27 +638,39 @@ class _TileLayerState extends State with TickerProviderStateMixin { /// Load new tiles in the visible bounds and prune those outside. void _loadAndPruneInVisibleBounds(MapCamera camera) { - final tileZoom = _clampToNativeZoom(camera.zoom); - final visibleTileRange = _tileRangeCalculator.calculate( - camera: camera, - tileZoom: tileZoom, - ); + final visibleTileCover = _visibleTileCover(camera); - if (!_outsideZoomLimits(tileZoom)) { + if (!_outsideZoomLimits(_clampToNativeZoom(camera.zoom))) { _loadTiles( - visibleTileRange, + visibleTileCover, + camera, pruneAfterLoad: true, ); } _tileImageManager.evictAndPrune( - visibleRange: visibleTileRange, + visibleRange: visibleTileCover, pruneBuffer: max(widget.panBuffer, widget.keepBuffer), evictStrategy: widget.evictErrorTileStrategy, ); } - // For all valid TileCoordinates in the [tileLoadRange], expanded by the + /// The tiles that cover the ground visible from [camera], viewed from + /// [center] at [zoom] if given. + TileCover _visibleTileCover( + MapCamera camera, { + LatLng? center, + double? zoom, + }) => + _tileCoverCalculator.calculate( + camera: camera, + tileZoom: _clampToNativeZoom(zoom ?? camera.zoom), + minNativeZoom: widget.minNativeZoom, + center: center, + viewingZoom: zoom, + ); + + // For all valid TileCoordinates in the [tileLoadCover], expanded by the // [TileLayer.panBuffer], this method will do the following depending on // whether a matching TileImage already exists or not: // * Exists: Mark it as current and initiate image loading if it has not @@ -679,36 +678,38 @@ class _TileLayerState extends State with TickerProviderStateMixin { // * Does not exist: Creates the TileImage (they are current when created) // and initiates loading. // - // Additionally, any current TileImages outside of the [tileLoadRange], - // expanded by the [TileLayer.panBuffer] + [TileLayer.keepBuffer], are marked - // as not current. + // New tiles start loading in order of their distance on screen from the + // center of the viewport of [camera]. void _loadTiles( - DiscreteTileRange tileLoadRange, { + TileCover tileLoadCover, + MapCamera camera, { required bool pruneAfterLoad, }) { - final tileZoom = tileLoadRange.zoom; - final expandedTileLoadRange = tileLoadRange.expand(widget.panBuffer); - - // Build the queue of tiles to load. Marks all tiles with valid coordinates - // in the tileLoadRange as current. - final tileBoundsAtZoom = _tileBounds.atZoom(tileZoom); final tilesToLoad = _tileImageManager.createMissingTiles( - expandedTileLoadRange, - tileBoundsAtZoom, + tileLoadCover.expand(widget.panBuffer), + _tileBounds, createTile: (coordinates) => _createTileImage( coordinates: coordinates, - tileBoundsAtZoom: tileBoundsAtZoom, pruneAfterLoad: pruneAfterLoad, ), ); - // Re-order the tiles by their distance to the center of the range. - final tileCenter = expandedTileLoadRange.center; - tilesToLoad.sort( - (a, b) => (a.coordinates.toOffset() - tileCenter) - .distanceSquared - .compareTo((b.coordinates.toOffset() - tileCenter).distanceSquared), - ); + final pixelToScreen = camera.pixelToScreenTransform; + final viewportCenter = camera.nonRotatedSize.center(Offset.zero); + double distanceSquared(TileImage tile) { + final c = tile.coordinates; + final tileCenter = Offset(c.x + 0.5, c.y + 0.5) * + _tileDimension.toDouble() * + camera.getZoomScale(camera.zoom, c.z.toDouble()); + return (MatrixUtils.transformPoint(pixelToScreen, tileCenter) - + viewportCenter) + .distanceSquared; + } + + final distances = { + for (final tile in tilesToLoad) tile: distanceSquared(tile) + }; + tilesToLoad.sort((a, b) => distances[a]!.compareTo(distances[b]!)); // Create the new Tiles. for (final tile in tilesToLoad) { @@ -746,15 +747,8 @@ class _TileLayerState extends State with TickerProviderStateMixin { } void _pruneWithCurrentCamera() { - final camera = MapCamera.of(context); - final visibleTileRange = _tileRangeCalculator.calculate( - camera: camera, - tileZoom: _clampToNativeZoom(camera.zoom), - center: camera.center, - viewingZoom: camera.zoom, - ); _tileImageManager.prune( - visibleRange: visibleTileRange, + visibleRange: _visibleTileCover(MapCamera.of(context)), pruneBuffer: max(widget.panBuffer, widget.keepBuffer), evictStrategy: widget.evictErrorTileStrategy, ); diff --git a/lib/src/layer/tile_layer/tile_range.dart b/lib/src/layer/tile_layer/tile_range.dart index b7f2b90d8..abd12d51f 100644 --- a/lib/src/layer/tile_layer/tile_range.dart +++ b/lib/src/layer/tile_layer/tile_range.dart @@ -3,6 +3,8 @@ import 'dart:math' show Point; import 'dart:ui'; import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_cover.dart'; import 'package:flutter_map/src/misc/bounds.dart'; import 'package:flutter_map/src/misc/extensions.dart'; import 'package:meta/meta.dart'; @@ -39,7 +41,7 @@ Point _ceil(Offset point) => Point(point.dx.ceil(), point.dy.ceil()); /// Every [TileRange] is a [DiscreteTileRange] if it's not an [EmptyTileRange]. @immutable -class DiscreteTileRange extends TileRange { +class DiscreteTileRange extends TileRange implements TileCover { /// Bounds are inclusive final IntegerBounds _bounds; @@ -67,6 +69,7 @@ class DiscreteTileRange extends TileRange { } /// Expand the [DiscreteTileRange] by a given amount in every direction. + @override DiscreteTileRange expand(int count) { if (count == 0) return this; @@ -119,12 +122,15 @@ class DiscreteTileRange extends TileRange { } /// Check if a [Point] is inside of the bounds of the [DiscreteTileRange]. + /// A [TileCoordinates] at another zoom is not. /// /// We use a modulo in order to prevent side-effects at the end of the world. + @override bool contains( Point point, { bool replicatesWorldLongitude = false, }) { + if (point is TileCoordinates && point.z != zoom) return false; if (!replicatesWorldLongitude) { return _bounds.contains(point); } @@ -165,6 +171,10 @@ class DiscreteTileRange extends TileRange { } } + @override + Iterable validCoordinatesIn(TileBounds bounds) => + bounds.atZoom(zoom).validCoordinatesIn(this); + @override String toString() => 'DiscreteTileRange($min, $max)'; } diff --git a/lib/src/map/camera/camera.dart b/lib/src/map/camera/camera.dart index 50cad6714..e45ef92a1 100644 --- a/lib/src/map/camera/camera.dart +++ b/lib/src/map/camera/camera.dart @@ -486,6 +486,17 @@ class MapCamera { Offset? groundOffsetFromCenter(Offset offset) => _groundFromCenter(offset, aboveHorizon: null); + /// The size on screen of one projected pixel at the ground point [offset] + /// from [center], where [offset] is in projected pixels at [zoom]. It is 1 at + /// [center] and on an unpitched map. + @internal + double groundScale(Offset offset) { + if (pitch == 0) return 1; + final depth = + _cameraDistance - offset.rotate(-rotationRad).dy * math.sin(pitchRad); + return _cameraDistance / depth; + } + /// The offset, in projected pixels at [zoom], from [center] to the ground /// point under the screen position [offset], found by casting the view ray /// to the ground. Above the horizon, uses [aboveHorizon] as the divisor, or diff --git a/lib/src/map/options/options.dart b/lib/src/map/options/options.dart index bb22bfbfa..48b9fd81a 100644 --- a/lib/src/map/options/options.dart +++ b/lib/src/map/options/options.dart @@ -95,8 +95,9 @@ class MapOptions { /// * [CameraConstraint.contain] and [CameraConstraint.containLatitude] /// ignore pitch, so ground beyond their bounds can be visible toward the /// top of the map - /// * the tile layer loads up to 7.5 times as many tiles at 60° as when - /// unpitched + /// * the tile layer loads coarser tiles for far ground, and near ground at + /// the same zoom as when unpitched, so near tiles can look blurry; use + /// vector tiles where the near ground must be sharp /// /// While [maxPitch] is above 0 and the interactive flags contain /// [InteractiveFlag.pitch], the user can pitch the map with a two-finger diff --git a/test/layer/pitched_layers_test.dart b/test/layer/pitched_layers_test.dart index d4373f096..251dbb72a 100644 --- a/test/layer/pitched_layers_test.dart +++ b/test/layer/pitched_layers_test.dart @@ -235,7 +235,8 @@ void main() { }); } - testWidgets('tile under the top edge is loaded', (tester) async { + testWidgets('tile under the top edge is loaded, coarser than at the center', + (tester) async { final controller = await _pump( tester, pitch: 60, @@ -248,12 +249,19 @@ void main() { ], ); final camera = controller.camera; - final top = camera.projectAtZoom( - camera.screenOffsetToLatLng(const Offset(200, 1)), 12); - final expected = - TileCoordinates((top.dx / 256).floor(), (top.dy / 256).floor(), 12); - final quads = await _tileQuads(tester); - expect(quads.map((q) => q.$1), contains(expected)); + final topEdge = camera.screenOffsetToLatLng(const Offset(200, 1)); + TileCoordinates tileAt(LatLng position, int zoom) { + final pixel = camera.projectAtZoom(position, zoom.toDouble()); + return TileCoordinates( + (pixel.dx / 256).floor(), (pixel.dy / 256).floor(), zoom); + } + + final tiles = (await _tileQuads(tester)).map((q) => q.$1).toSet(); + expect(tiles, contains(tileAt(camera.center, 12))); + expect( + tiles.where((t) => t.z < 12 && tiles.contains(tileAt(topEdge, t.z))), + isNotEmpty, + ); }); for (final rotate in [true, false]) { diff --git a/test/layer/tile_layer/tile_cover_calculator_test.dart b/test/layer/tile_layer/tile_cover_calculator_test.dart new file mode 100644 index 000000000..cc02122aa --- /dev/null +++ b/test/layer/tile_layer/tile_cover_calculator_test.dart @@ -0,0 +1,264 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_cover.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_cover_calculator.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_range_calculator.dart'; +import 'package:flutter_map/src/misc/extensions.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:test/test.dart'; + +void main() { + const size = Size(400, 800); + + MapCamera camera({ + LatLng center = const LatLng(45.52, -122.68), + double zoom = 14, + double rotation = 0, + double pitch = 0, + Size nonRotatedSize = size, + }) => + MapCamera( + crs: const Epsg3857(), + center: center, + zoom: zoom, + rotation: rotation, + pitch: pitch, + nonRotatedSize: nonRotatedSize, + ); + + TileCover cover( + MapCamera camera, { + int tileDimension = 256, + int minNativeZoom = 0, + int maxNativeZoom = 19, + LatLng? center, + double? viewingZoom, + }) => + TileCoverCalculator(tileDimension: tileDimension).calculate( + camera: camera, + tileZoom: (viewingZoom ?? camera.zoom) + .round() + .clamp(minNativeZoom, maxNativeZoom), + minNativeZoom: minNativeZoom, + center: center, + viewingZoom: viewingZoom, + ); + + /// For each screen position on a grid, the tile of [tiles] that contains the + /// ground there, and the scale there. + List<(Offset, TileCoordinates?, double)> sample( + MapCamera camera, + TileCover tiles, { + int tileDimension = 256, + double step = 5, + }) { + final zooms = tiles.coordinates.map((t) => t.z).toSet(); + final tileZoom = camera.zoom.round(); + final pixelCenter = + camera.projectAtZoom(camera.center, tileZoom.toDouble()).floor(); + final result = <(Offset, TileCoordinates?, double)>[]; + for (var y = 0.0; y <= camera.nonRotatedSize.height; y += step) { + for (var x = 0.0; x <= camera.nonRotatedSize.width; x += step) { + final screen = Offset(x, y); + final ground = camera.groundOffsetFromCenter(screen)!; + TileCoordinates? found; + for (final z in zooms) { + final pixel = pixelCenter * + camera.getZoomScale(z.toDouble(), tileZoom.toDouble()) + + ground / camera.getZoomScale(camera.zoom, z.toDouble()); + final tile = TileCoordinates( + (pixel.dx / tileDimension).floor(), + (pixel.dy / tileDimension).floor(), + z, + ); + if (tiles.contains(tile)) { + expect(found, isNull, reason: 'tiles $found and $tile overlap'); + found = tile; + } + } + result.add((screen, found, camera.groundScale(ground))); + } + } + return result; + } + + group('unpitched and unrotated', () { + for (final (zoom, tileDimension) in [ + (14.0, 256), + (14.45, 256), + (13.6, 256), + (14.0, 512), + (3.2, 256), + (17.5, 128), + ]) { + test('equals the range at zoom $zoom, tile size $tileDimension', () { + for (final center in const [ + LatLng(45.52, -122.68), + LatLng(-33.9, 151.2), + LatLng(0, 179.99), + ]) { + final c = camera(center: center, zoom: zoom); + final tileZoom = zoom.round(); + final range = TileRangeCalculator(tileDimension: tileDimension) + .calculate(camera: c, tileZoom: tileZoom); + expect( + cover(c, tileDimension: tileDimension).coordinates.toSet(), + range.coordinates.toSet(), + reason: 'center $center', + ); + } + }); + } + + test('equals the range at an overridden center and zoom', () { + final c = camera(); + const center = LatLng(45.6, -122.5); + final range = const TileRangeCalculator(tileDimension: 256).calculate( + camera: c, + tileZoom: 15, + center: center, + viewingZoom: 15.3, + ); + expect( + cover(c, center: center, viewingZoom: 15.3).coordinates.toSet(), + range.coordinates.toSet(), + ); + }); + + test('equals the range above the maximum native zoom', () { + final c = camera(zoom: 16.3); + final range = const TileRangeCalculator(tileDimension: 256) + .calculate(camera: c, tileZoom: 14); + expect( + cover(c, maxNativeZoom: 14).coordinates.toSet(), + range.coordinates.toSet(), + ); + }); + + test('of an empty viewport equals the range', () { + final c = camera(nonRotatedSize: Size.zero); + final range = const TileRangeCalculator(tileDimension: 256) + .calculate(camera: c, tileZoom: 14); + expect(cover(c).coordinates.toSet(), range.coordinates.toSet()); + }); + }); + + test('rotated, contains only tiles that the viewport touches', () { + final c = camera(rotation: 45); + final range = const TileRangeCalculator(tileDimension: 256) + .calculate(camera: c, tileZoom: 14); + final tiles = cover(c); + final touched = { + for (final (screen, tile, _) in sample(c, tiles, step: 1)) + tile ?? fail('no tile at $screen'), + }; + expect(tiles.coordinates.toSet(), touched); + expect(range.coordinates.toSet().containsAll(touched), isTrue); + expect(touched.length, lessThan(range.coordinates.length)); + }); + + group('pitched', () { + for (final (zoom, rotation) in [ + (14.0, 0.0), + (14.45, 0.0), + (14.0, 45.0), + (14.45, 45.0), + (14.7, 160.0), + ]) { + test('far tiles follow the zoom rule, zoom $zoom, rotation $rotation', + () { + final c = camera(zoom: zoom, rotation: rotation, pitch: 60); + final tileZoom = zoom.round(); + final tiles = cover(c); + final largestScale = {}; + for (final (screen, tile, scale) in sample(c, tiles)) { + expect(tile, isNotNull, reason: 'no tile at $screen'); + largestScale[tile!] = math.max(largestScale[tile] ?? 0, scale); + } + expect( + tiles.coordinates.map((t) => t.z), + everyElement(lessThanOrEqualTo(tileZoom)), + ); + for (final tile in tiles.coordinates.where((t) => t.z < tileZoom)) { + final scale = largestScale[tile]; + if (scale == null) continue; + expect( + scale * math.pow(2, zoom - tile.z), + lessThanOrEqualTo(math.sqrt2), + reason: '$tile', + ); + } + }); + } + + test('far tiles are coarser than the center, near tiles are not finer', () { + final c = camera(zoom: 14.45, pitch: 60); + final tiles = cover(c); + final samples = sample(c, tiles); + int zoomAt(double y) => + samples.firstWhere((s) => s.$1 == Offset(200, y)).$2!.z; + expect(zoomAt(400), 14); + expect(zoomAt(0), lessThan(14)); + expect(zoomAt(800), 14); + expect( + tiles.coordinates.map((t) => t.z), + everyElement(lessThanOrEqualTo(14)), + ); + }); + + test('limits the zoom to the native zoom limits', () { + final c = camera(zoom: 14.45, pitch: 60); + expect( + cover(c, maxNativeZoom: 13).coordinates.map((t) => t.z), + everyElement(lessThanOrEqualTo(13)), + ); + final low = camera(zoom: 2.2, pitch: 60); + expect( + cover(low, minNativeZoom: 2).coordinates.map((t) => t.z), + everyElement(greaterThanOrEqualTo(2)), + ); + }); + + test('covers the far edge on both sides of the antimeridian', () { + final c = camera( + center: const LatLng(10, 179.95), + zoom: 12, + rotation: -90, + pitch: 60, + ); + final tiles = cover(c); + for (final (screen, tile, _) in sample(c, tiles)) { + expect(tile, isNotNull, reason: 'no tile at $screen'); + } + final worldEdge = { + for (final tile in tiles.coordinates) tile.x >= 1 << tile.z, + }; + expect(worldEdge, {true, false}); + }); + + test('the buffer adds neighbors outside the visible ground', () { + final c = camera(rotation: 45, pitch: 60); + final tiles = cover(c); + final added = + tiles.expand(1).coordinates.where((t) => !tiles.contains(t)).toSet(); + expect(added, isNotEmpty); + for (final tile in added) { + expect( + tiles.coordinates.any((t) => + t.z == tile.z && + (t.x - tile.x).abs() <= 1 && + (t.y - tile.y).abs() <= 1), + isTrue, + reason: '$tile is not a neighbor', + ); + } + final touched = { + for (final (_, tile, _) in sample(c, TileCover(added), step: 2)) + if (tile != null) tile, + }; + expect(touched, isEmpty); + }); + }); +} diff --git a/test/layer/tile_layer/tile_cover_test.dart b/test/layer/tile_layer/tile_cover_test.dart new file mode 100644 index 000000000..2f107959c --- /dev/null +++ b/test/layer/tile_layer/tile_cover_test.dart @@ -0,0 +1,102 @@ +import 'dart:math'; + +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_bounds/tile_bounds.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_cover.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile_range.dart'; +import 'package:flutter_map/src/misc/bounds.dart'; +import 'package:test/test.dart'; + +void main() { + group('contains', () { + test('compares the zoom', () { + final cover = TileCover(const [TileCoordinates(1, 1, 5)]); + expect(cover.contains(const TileCoordinates(1, 1, 5)), isTrue); + expect(cover.contains(const TileCoordinates(1, 1, 4)), isFalse); + expect(cover.contains(const TileCoordinates(1, 1, 6)), isFalse); + }); + + test('a range compares the zoom of tile coordinates', () { + final range = DiscreteTileRange( + 5, IntegerBounds(const Point(1, 1), const Point(2, 2))); + expect(range.contains(const TileCoordinates(1, 1, 5)), isTrue); + expect(range.contains(const TileCoordinates(1, 1, 4)), isFalse); + expect(range.contains(const Point(1, 1)), isTrue); + }); + + test('wraps x at the zoom of the coordinates', () { + final cover = TileCover(const [ + TileCoordinates(1, 0, 1), + TileCoordinates(5, 0, 3), + ]); + bool wrapped(TileCoordinates c) => + cover.contains(c, replicatesWorldLongitude: true); + + expect(wrapped(const TileCoordinates(3, 0, 1)), isTrue); + expect(wrapped(const TileCoordinates(-1, 0, 1)), isTrue); + expect(wrapped(const TileCoordinates(13, 0, 3)), isTrue); + expect(wrapped(const TileCoordinates(-3, 0, 3)), isTrue); + expect(wrapped(const TileCoordinates(2, 0, 1)), isFalse); + expect(wrapped(const TileCoordinates(12, 0, 3)), isFalse); + expect(wrapped(const TileCoordinates(3, 0, 2)), isFalse); + expect(cover.contains(const TileCoordinates(3, 0, 1)), isFalse); + }); + }); + + group('expand', () { + test('of a single zoom equals the expanded range', () { + final range = DiscreteTileRange( + 7, + IntegerBounds(const Point(2, 3), const Point(4, 6)), + ); + final cover = TileCover(range.coordinates); + for (final count in [0, 1, 2]) { + expect( + cover.expand(count).coordinates.toSet(), + range.expand(count).coordinates.toSet(), + reason: 'count $count', + ); + } + }); + + test('adds a ring at the zoom of each tile', () { + final cover = TileCover(const [ + TileCoordinates(4, 4, 5), + TileCoordinates(1, 1, 3), + ]).expand(1); + expect(cover.coordinates.toSet(), { + for (final (x, y, z) in [(4, 4, 5), (1, 1, 3)]) + for (var j = y - 1; j <= y + 1; j++) + for (var i = x - 1; i <= x + 1; i++) TileCoordinates(i, j, z), + }); + }); + + test('of a rotated cover adds no corners of its bounding box', () { + final cover = TileCover(const [ + TileCoordinates(5, 3, 4), + TileCoordinates(4, 4, 4), + TileCoordinates(5, 4, 4), + TileCoordinates(6, 4, 4), + TileCoordinates(5, 5, 4), + ]).expand(1); + expect(cover.contains(const TileCoordinates(3, 2, 4)), isFalse); + expect(cover.contains(const TileCoordinates(4, 2, 4)), isTrue); + }); + }); + + test('validCoordinatesIn applies the bounds at the zoom of each tile', () { + final bounds = TileBounds(crs: const Epsg3857(), tileDimension: 256); + final cover = TileCover(const [ + TileCoordinates(0, 0, 1), + TileCoordinates(5, 1, 1), + TileCoordinates(0, 3, 1), + TileCoordinates(0, 3, 2), + TileCoordinates(0, -1, 2), + ]); + expect(cover.validCoordinatesIn(bounds).toSet(), { + const TileCoordinates(0, 0, 1), + const TileCoordinates(5, 1, 1), + const TileCoordinates(0, 3, 2), + }); + }); +} diff --git a/test/layer/tile_layer/tile_level_of_detail_test.dart b/test/layer/tile_layer/tile_level_of_detail_test.dart new file mode 100644 index 000000000..12806e296 --- /dev/null +++ b/test/layer/tile_layer/tile_level_of_detail_test.dart @@ -0,0 +1,257 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_map/src/layer/tile_layer/tile.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +import '../../test_utils/test_tile_image.dart'; + +const _size = Size(400, 800); +const _center = LatLng(45.52, -122.68); + +/// Records the tiles in the order they start loading. While [pending], new +/// tiles never finish loading. +class _RecordingTileProvider extends TileProvider { + final loads = []; + bool pending = false; + + @override + ImageProvider getImage(TileCoordinates coordinates, TileLayer options) => + _RecordingImage(coordinates, this); +} + +class _RecordingImage extends ImageProvider<_RecordingImage> { + final TileCoordinates coordinates; + final _RecordingTileProvider provider; + + _RecordingImage(this.coordinates, this.provider); + + @override + Future<_RecordingImage> obtainKey(ImageConfiguration configuration) { + provider.loads.add(coordinates); + return SynchronousFuture(this); + } + + @override + ImageStreamCompleter loadImage( + _RecordingImage key, + ImageDecoderCallback decode, + ) => + provider.pending + ? OneFrameImageStreamCompleter(Completer().future) + : testWhiteTileImage.loadImage(testWhiteTileImage, decode); +} + +Future<(MapController, _RecordingTileProvider)> _pump( + WidgetTester tester, { + required double pitch, + double rotation = 0, + double zoom = 14, + int maxNativeZoom = 19, +}) async { + tester.view + ..physicalSize = _size + ..devicePixelRatio = 1; + addTearDown(tester.view.reset); + final controller = MapController(); + final provider = _RecordingTileProvider(); + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox.fromSize( + size: _size, + child: FlutterMap( + mapController: controller, + options: MapOptions( + initialCenter: _center, + initialZoom: zoom, + initialRotation: rotation, + initialPitch: pitch, + maxPitch: 60, + ), + children: [ + TileLayer( + tileProvider: provider, + maxNativeZoom: maxNativeZoom, + tileDisplay: const TileDisplay.instantaneous(), + ), + ], + ), + ), + ), + )); + return (controller, provider); +} + +Future _decode(WidgetTester tester) async { + await tester + .runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await tester.pump(); +} + +TileCoordinates _tileAt(MapCamera camera, Offset screen, int zoom) { + final pixel = camera.projectAtZoom( + camera.screenOffsetToLatLng(screen), zoom.toDouble()); + return TileCoordinates( + (pixel.dx / 256).floor(), (pixel.dy / 256).floor(), zoom); +} + +/// The tiles at [zooms] that the visible ground touches, from screen positions +/// [step] apart. +Set _touched( + MapCamera camera, + Iterable zooms, { + double step = 2, +}) => + { + for (var y = 0.0; y <= _size.height; y += step) + for (var x = 0.0; x <= _size.width; x += step) + for (final z in zooms) _tileAt(camera, Offset(x, y), z), + }; + +bool _isNeighbor(TileCoordinates a, TileCoordinates b) => + a.z == b.z && (a.x - b.x).abs() <= 1 && (a.y - b.y).abs() <= 1; + +/// Every loaded tile touches the visible ground, or is a neighbor of a loaded +/// tile that does. +void _expectTouchingOrNeighbors(MapCamera camera, Set loads) { + final touched = _touched(camera, loads.map((t) => t.z).toSet()); + final touching = loads.intersection(touched); + for (final tile in loads.difference(touching)) { + expect(touching.any((t) => _isNeighbor(t, tile)), isTrue, reason: '$tile'); + } +} + +void main() { + testWidgets('coarser tiles far away', (tester) async { + final (controller, provider) = await _pump(tester, pitch: 60); + final camera = controller.camera; + final loads = provider.loads.toSet(); + expect(loads, contains(_tileAt(camera, _size.center(Offset.zero), 14))); + final top = Offset(_size.width / 2, 1); + expect( + loads.where((t) => t.z < 14 && t == _tileAt(camera, top, t.z)), + isNotEmpty, + ); + }); + + testWidgets('no finer tiles near the camera', (tester) async { + final (controller, provider) = await _pump(tester, pitch: 60, zoom: 14.45); + final bottom = Offset(_size.width / 2, _size.height - 1); + expect(provider.loads.map((t) => t.z), everyElement(lessThanOrEqualTo(14))); + expect(provider.loads, contains(_tileAt(controller.camera, bottom, 14))); + }); + + testWidgets('maximum native zoom', (tester) async { + final (_, provider) = + await _pump(tester, pitch: 60, zoom: 14.45, maxNativeZoom: 13); + expect(provider.loads, isNotEmpty); + expect(provider.loads.map((t) => t.z), everyElement(lessThanOrEqualTo(13))); + }); + + testWidgets('rotated map', (tester) async { + final (controller, provider) = await _pump(tester, pitch: 0, rotation: 45); + final loads = provider.loads.toSet(); + expect(loads.map((t) => t.z).toSet(), {14}); + _expectTouchingOrNeighbors(controller.camera, loads); + + final touched = _touched(controller.camera, const [14]); + int extent(Iterable values) => + values.reduce(max) - values.reduce(min) + 1; + final boundingBoxWithBuffer = (extent(touched.map((t) => t.x)) + 2) * + (extent(touched.map((t) => t.y)) + 2); + expect(loads.length, lessThan(boundingBoxWithBuffer)); + }); + + testWidgets('pitched and rotated map', (tester) async { + final (controller, provider) = await _pump(tester, pitch: 60, rotation: 45); + final loads = provider.loads.toSet(); + expect(loads.map((t) => t.z).toSet().length, greaterThan(1)); + _expectTouchingOrNeighbors(controller.camera, loads); + }); + + testWidgets('every position shows a tile at pitch 60 while tiles load', + (tester) async { + final (controller, provider) = await _pump(tester, pitch: 60); + await _decode(tester); + provider.pending = true; + final loaded = provider.loads.length; + + controller.moveAndRotate(_center, 14.45, 10); + await tester.pump(); + await _decode(tester); + expect(provider.loads.length, greaterThan(loaded)); + + final quads = [ + for (final element in find.byType(Tile).evaluate()) + if ((element.widget as Tile).tileImage.readyToDisplay) + () { + final box = tester.renderObject(find.descendant( + of: find.byWidget(element.widget), + matching: find.byType(RawImage), + )); + final toScreen = box.getTransformTo(null); + return [ + Offset.zero, + Offset(box.size.width, 0), + Offset(box.size.width, box.size.height), + Offset(0, box.size.height), + ].map((c) => MatrixUtils.transformPoint(toScreen, c)).toList(); + }(), + ]; + for (var y = 0.5; y < _size.height; y += 25) { + for (var x = 0.5; x < _size.width; x += 25) { + expect( + quads.any((q) => _insideConvex(q, Offset(x, y))), + isTrue, + reason: 'no tile at ($x, $y)', + ); + } + } + }); + + testWidgets('pitched load order', (tester) async { + final (controller, provider) = await _pump(tester, pitch: 60); + final loaded = provider.loads.length; + + controller.move(const LatLng(45.6, -122.4), 14); + await tester.pump(); + final camera = controller.camera; + final newLoads = provider.loads.sublist(loaded); + expect(newLoads.length, greaterThan(10)); + final distances = [ + for (final t in newLoads) + (camera.latLngToScreenOffset(camera.unprojectAtZoom( + Offset(t.x + 0.5, t.y + 0.5) * 256, + t.z.toDouble(), + )) - + _size.center(Offset.zero)) + .distance, + ]; + for (var i = 1; i < distances.length; i++) { + expect(distances[i], greaterThanOrEqualTo(distances[i - 1] - 1e-6), + reason: '${newLoads[i]} after ${newLoads[i - 1]}'); + } + }); +} + +bool _insideConvex(List quad, Offset point) { + var sign = 0.0; + for (var i = 0; i < quad.length; i++) { + final a = quad[i]; + final b = quad[(i + 1) % quad.length]; + final cross = + (b.dx - a.dx) * (point.dy - a.dy) - (b.dy - a.dy) * (point.dx - a.dx); + if (cross == 0) continue; + if (sign == 0) { + sign = cross.sign; + } else if (cross.sign != sign) { + return false; + } + } + return true; +}