From b3a80406319572c6c6375f7daeea30a5cd0dd428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Neboj=C5=A1a=20Cvetkovi=C4=87?= Date: Wed, 2 Sep 2026 16:11:07 -0600 Subject: [PATCH] fix(tile_layer): Keep loaded descendants when an ancestor is also available staleTiles and renderTiles only looked for descendants if the ancestor walk came up empty. _retainAncestor stops at the first loaded tile up to five levels above, and neighbouring tiles share ancestors, so one cached tile was enough to drop the descendants across the whole view. Zooming out shows it: the sharp tiles already on screen are discarded for a coarse cached ancestor before the new level loads. They are not alternatives, so keep both until the target level is ready. renderOrder already draws the higher resolution ones on top where they overlap. --- lib/src/layer/tile_layer/tile_image_view.dart | 36 +++++------ .../tile_layer/tile_image_view_test.dart | 63 +++++++++++++++++++ 2 files changed, 79 insertions(+), 20 deletions(-) diff --git a/lib/src/layer/tile_layer/tile_image_view.dart b/lib/src/layer/tile_layer/tile_image_view.dart index 5a67e07cd..95ad1163b 100644 --- a/lib/src/layer/tile_layer/tile_image_view.dart +++ b/lib/src/layer/tile_layer/tile_image_view.dart @@ -70,22 +70,20 @@ final class TileImageView { continue; } - final retainedAncestor = _retainAncestor( + _retainAncestor( retain, positionCoordinates.x, positionCoordinates.y, positionCoordinates.z, positionCoordinates.z - 5, ); - if (!retainedAncestor) { - _retainChildren( - retain, - positionCoordinates.x, - positionCoordinates.y, - positionCoordinates.z, - positionCoordinates.z + 2, - ); - } + _retainChildren( + retain, + positionCoordinates.x, + positionCoordinates.y, + positionCoordinates.z, + positionCoordinates.z + 2, + ); } return stale.where((tile) => !retain.contains(tile)); @@ -107,22 +105,20 @@ final class TileImageView { final TileImage? tile = _tileImages[_resolver.get(positionCoordinates)]; if (tile == null || !tile.readyToDisplay) { - final retainedAncestor = _retainAncestor( + _retainAncestor( retain, positionCoordinates.x, positionCoordinates.y, positionCoordinates.z, positionCoordinates.z - 5, ); - if (!retainedAncestor) { - _retainChildren( - retain, - positionCoordinates.x, - positionCoordinates.y, - positionCoordinates.z, - positionCoordinates.z + 2, - ); - } + _retainChildren( + retain, + positionCoordinates.x, + positionCoordinates.y, + positionCoordinates.z, + positionCoordinates.z + 2, + ); } } return retain; diff --git a/test/layer/tile_layer/tile_image_view_test.dart b/test/layer/tile_layer/tile_image_view_test.dart index 0261ccc38..8506e4241 100644 --- a/test/layer/tile_layer/tile_image_view_test.dart +++ b/test/layer/tile_layer/tile_image_view_test.dart @@ -64,6 +64,39 @@ void main() { ); }); + test( + 'loaded descendants are not stale while the tile obscuring them is ' + 'still loading, even when a loaded ancestor is available', () { + // Zooming out from z2 to z1. The z2 tile on screen is loaded, the z1 + // tile replacing it has not loaded, and a loaded z0 ancestor is still + // cached but no longer positioned. Retaining that ancestor must not + // stop the z2 tile being retained too, or the view drops to the + // coarser ancestor part way through the zoom. + // + // (1, 1) at z2 lies outside the z1 keep range, so it is stale unless + // something retains it as a descendant. + final tileImages = tileImagesMappingFrom([ + MockTileImage(0, 0, 0), + MockTileImage(0, 0, 1, loadFinished: false, readyToDisplay: false), + MockTileImage(1, 1, 2), + ]); + + final removalState = TileImageView( + tileImages: tileImages, + positionCoordinates: { + const TileCoordinates(0, 0, 1), + const TileCoordinates(1, 1, 2), + }, + visibleRange: discreteTileRange(0, 0, 0, 0, zoom: 1), + keepRange: discreteTileRange(0, 0, 0, 0, zoom: 1), + ); + + expect( + removalState.staleTiles, + isNot(contains(const TileCoordinates(1, 1, 2))), + ); + }); + test('descendant tile is not stale if there is no loaded tile obscuring it', () { final tileImages = tileImagesMappingFrom([ @@ -139,6 +172,36 @@ void main() { }); group('renderTiles', () { + test( + 'loaded descendants are still rendered when a loaded ancestor is also ' + 'available', () { + // Zooming out from z2 to z1. The z1 tile being zoomed to has not + // loaded; both a coarse z0 ancestor and the sharp z2 tiles already on + // screen could stand in for it. Both are wanted — render order draws + // the higher resolution one on top — so finding the ancestor must not + // stop the descendants being collected. + final tileImages = tileImagesMappingFrom([ + MockTileImage(0, 0, 0), + MockTileImage(0, 0, 1, loadFinished: false, readyToDisplay: false), + MockTileImage(1, 1, 2), + ]); + + final tileImageView = TileImageView( + tileImages: tileImages, + positionCoordinates: Set.from(tileImages.keys), + visibleRange: discreteTileRange(0, 0, 0, 0, zoom: 1), + keepRange: discreteTileRange(0, 0, 0, 0, zoom: 1), + ); + + expect( + tileImageView.renderTiles, + containsAll(const [ + TileCoordinates(0, 0, 0), + TileCoordinates(1, 1, 2), + ]), + ); + }); + test('%.retainChildren uses correct coordinates for z+2 fallback', () { // This test verifies that _retainChildren correctly calculates // descendant coordinates when recursing to z+2 level.