From fe8e4f49c9ae9b4f02a2d37073beb71da38a57b6 Mon Sep 17 00:00:00 2001 From: David Green Date: Sun, 25 Oct 2026 15:16:00 -0700 Subject: [PATCH] load tiles at a zoom chosen from their size on screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a pitched map, the tile layer loaded every tile in the bounding box of the visible ground, all at one zoom. It now loads only the tiles that cover the visible ground, and far tiles at a coarser zoom, so that they are drawn at most √2 times their native size. No tile is finer than the tile zoom of an unpitched map. The pan and keep buffers surround the visible ground at each tile's zoom, tiles are drawn coarsest first, and new tiles load in order of their distance on screen from the viewport center. At 60° on a Pixel 4a this makes 36% to 58% fewer tile requests, with as much less decoded image memory, and the visible tiles load sooner. An unrotated, unpitched map loads the same tiles as before. --- example/lib/pages/pitch.dart | 4 +- lib/src/layer/tile_layer/tile_cover.dart | 94 +++++++ .../tile_layer/tile_cover_calculator.dart | 223 +++++++++++++++ .../layer/tile_layer/tile_image_manager.dart | 24 +- lib/src/layer/tile_layer/tile_image_view.dart | 12 +- lib/src/layer/tile_layer/tile_layer.dart | 156 +++++------ lib/src/layer/tile_layer/tile_range.dart | 12 +- lib/src/map/camera/camera.dart | 11 + lib/src/map/options/options.dart | 5 +- test/layer/pitched_layers_test.dart | 22 +- .../tile_cover_calculator_test.dart | 264 ++++++++++++++++++ test/layer/tile_layer/tile_cover_test.dart | 102 +++++++ .../tile_layer/tile_level_of_detail_test.dart | 257 +++++++++++++++++ 13 files changed, 1075 insertions(+), 111 deletions(-) create mode 100644 lib/src/layer/tile_layer/tile_cover.dart create mode 100644 lib/src/layer/tile_layer/tile_cover_calculator.dart create mode 100644 test/layer/tile_layer/tile_cover_calculator_test.dart create mode 100644 test/layer/tile_layer/tile_cover_test.dart create mode 100644 test/layer/tile_layer/tile_level_of_detail_test.dart 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; +}