diff --git a/src/source/source_cache.js b/src/source/source_cache.js index a9f97d0b8af..36a69738c75 100644 --- a/src/source/source_cache.js +++ b/src/source/source_cache.js @@ -43,6 +43,7 @@ class SourceCache extends Evented { _sourceLoaded: boolean; _sourceErrored: boolean; _tiles: {[any]: Tile}; + _prevLng: number | void; _cache: Cache; _timers: {[any]: TimeoutID}; _cacheTimers: {[any]: TimeoutID}; @@ -392,6 +393,49 @@ class SourceCache extends Evented { this._cache.setMaxSize(maxSize); } + handleWrapJump(lng: number) { + // On top of the regular z/x/y values, TileIDs have a `wrap` value that specify + // which cppy of the world the tile belongs to. For example, at `lng: 10` you + // might render z/x/y/0 while at `lng: 370` you would render z/x/y/1. + // + // When lng values get wrapped (going from `lng: 370` to `long: 10`) you expect + // to see the same thing on the screen (370 degrees and 10 degrees is the same + // place in the world) but all the TileIDs will have different wrap values. + // + // In order to make this transition seamless, we calculate the rounded difference of + // "worlds" between the last frame and the current frame. If the map panned by + // a world, then we can assign all the tiles new TileIDs with updated wrap values. + // For example, assign z/x/y/1 a new id: z/x/y/0. It is the same tile, just rendered + // in a different position. + // + // This enables us to reuse the tiles at more ideal locations and prevent flickering. + const prevLng = this._prevLng === undefined ? lng : this._prevLng; + const lngDifference = lng - prevLng; + const worldDifference = lngDifference / 360; + const wrapDelta = Math.round(worldDifference); + this._prevLng = lng; + + if (wrapDelta) { + const tiles = {}; + for (const key in this._tiles) { + const tile = this._tiles[key]; + tile.tileID = tile.tileID.unwrapTo(tile.tileID.wrap + wrapDelta); + tiles[tile.tileID.key] = tile; + } + this._tiles = tiles; + + // Reset tile reload timers + for (const id in this._timers) { + clearTimeout(this._timers[id]); + delete this._timers[id]; + } + for (const id in this._tiles) { + const tile = this._tiles[id]; + this._setTileReloadTimer(id, tile); + } + } + } + /** * Removes tiles that are outside the viewport and adds new tiles that * are inside the viewport. @@ -401,6 +445,8 @@ class SourceCache extends Evented { if (!this._sourceLoaded || this._paused) { return; } this.updateCacheSize(transform); + this.handleWrapJump(this.transform.center.lng); + // Covered is a list of retained tiles who's areas are fully covered by other, // better, retained tiles. They are not drawn separately. this._coveredTiles = {}; @@ -572,13 +618,13 @@ class SourceCache extends Evented { tile = this._cache.getAndRemove((tileID.wrapped().key: any)); if (tile) { - // set the tileID because the cached tile could have had a different wrap value - tile.tileID = tileID; if (this._cacheTimers[tileID.key]) { clearTimeout(this._cacheTimers[tileID.key]); delete this._cacheTimers[tileID.key]; this._setTileReloadTimer(tileID.key, tile); } + // set the tileID because the cached tile could have had a different wrap value + tile.tileID = tileID; } const cached = Boolean(tile); diff --git a/src/source/tile_id.js b/src/source/tile_id.js index 82ce0b12002..2a507526448 100644 --- a/src/source/tile_id.js +++ b/src/source/tile_id.js @@ -68,6 +68,10 @@ export class OverscaledTileID { this.key = calculateKey(wrap, overscaledZ, x, y); } + equals(id: OverscaledTileID) { + return this.overscaledZ === id.overscaledZ && this.wrap === id.wrap && this.canonical.equals(id.canonical); + } + scaledTo(targetZ: number) { assert(targetZ <= this.overscaledZ); const zDifference = this.canonical.z - targetZ; @@ -122,6 +126,10 @@ export class OverscaledTileID { return new OverscaledTileID(this.overscaledZ, 0, this.canonical.z, this.canonical.x, this.canonical.y); } + unwrapTo(wrap: number) { + return new OverscaledTileID(this.overscaledZ, wrap, this.canonical.z, this.canonical.x, this.canonical.y); + } + overscaleFactor() { return Math.pow(2, this.overscaledZ - this.canonical.z); } diff --git a/src/style/style.js b/src/style/style.js index 327147ac4b1..e34f9d6995a 100644 --- a/src/style/style.js +++ b/src/style/style.js @@ -975,7 +975,7 @@ class Style extends Evented { .sort((a, b) => (b.tileID.overscaledZ - a.tileID.overscaledZ) || (a.tileID.isLessThan(b.tileID) ? -1 : 1)); } - const layerBucketsChanged = this.crossTileSymbolIndex.addLayer(styleLayer, layerTiles[styleLayer.source]); + const layerBucketsChanged = this.crossTileSymbolIndex.addLayer(styleLayer, layerTiles[styleLayer.source], transform.center.lng); symbolBucketsChanged = symbolBucketsChanged || layerBucketsChanged; } this.crossTileSymbolIndex.pruneUnusedLayers(this._order); diff --git a/src/symbol/cross_tile_symbol_index.js b/src/symbol/cross_tile_symbol_index.js index 4d7d3566d1c..609edcf70bb 100644 --- a/src/symbol/cross_tile_symbol_index.js +++ b/src/symbol/cross_tile_symbol_index.js @@ -118,10 +118,35 @@ class CrossTileIDs { class CrossTileSymbolLayerIndex { indexes: {[zoom: string | number]: {[tileId: string | number]: TileLayerIndex}}; usedCrossTileIDs: {[zoom: string | number]: {[crossTileID: number]: boolean}}; + lng: number; constructor() { this.indexes = {}; this.usedCrossTileIDs = {}; + this.lng = 0; + } + + /* + * Sometimes when a user pans across the antimeridian the longitude value gets wrapped. + * To prevent labels from flashing out and in we adjust the tileID values in the indexes + * so that they match the new wrapped version of the map. + */ + handleWrapJump(lng: number) { + const wrapDelta = Math.round((lng - this.lng) / 360); + if (wrapDelta !== 0) { + for (const zoom in this.indexes) { + const zoomIndexes = this.indexes[zoom]; + const newZoomIndex = {}; + for (const key in zoomIndexes) { + // change the tileID's wrap and add it to a new index + const index = zoomIndexes[key]; + index.tileID = index.tileID.unwrapTo(index.tileID.wrap + wrapDelta); + newZoomIndex[index.tileID.key] = index; + } + this.indexes[zoom] = newZoomIndex; + } + } + this.lng = lng; } addBucket(tileID: OverscaledTileID, bucket: SymbolBucket, crossTileIDs: CrossTileIDs) { @@ -221,7 +246,7 @@ class CrossTileSymbolIndex { this.bucketsInCurrentPlacement = {}; } - addLayer(styleLayer: StyleLayer, tiles: Array) { + addLayer(styleLayer: StyleLayer, tiles: Array, lng: number) { let layerIndex = this.layerIndexes[styleLayer.id]; if (layerIndex === undefined) { layerIndex = this.layerIndexes[styleLayer.id] = new CrossTileSymbolLayerIndex(); @@ -230,6 +255,8 @@ class CrossTileSymbolIndex { let symbolBucketsChanged = false; const currentBucketIDs = {}; + layerIndex.handleWrapJump(lng); + for (const tile of tiles) { const symbolBucket = ((tile.getBucket(styleLayer): any): SymbolBucket); if (!symbolBucket) continue; diff --git a/test/unit/source/source_cache.test.js b/test/unit/source/source_cache.test.js index 3fe7b26beb6..3a8ff81a7d2 100644 --- a/test/unit/source/source_cache.test.js +++ b/test/unit/source/source_cache.test.js @@ -709,6 +709,32 @@ test('SourceCache#update', (t) => { sourceCache.onAdd(); }); + t.test('reassigns tiles for large jumps in longitude', (t) => { + + const transform = new Transform(); + transform.resize(511, 511); + transform.zoom = 0; + + const sourceCache = createSourceCache({}); + sourceCache.on('data', (e) => { + if (e.sourceDataType === 'metadata') { + transform.center = new LngLat(360, 0); + const tileID = new OverscaledTileID(0, 1, 0, 0, 0); + sourceCache.update(transform); + t.deepEqual(sourceCache.getIds(), [tileID.key]); + const tile = sourceCache.getTile(tileID); + + transform.center = new LngLat(0, 0); + const wrappedTileID = new OverscaledTileID(0, 0, 0, 0, 0); + sourceCache.update(transform); + t.deepEqual(sourceCache.getIds(), [wrappedTileID.key]); + t.equal(sourceCache.getTile(wrappedTileID), tile); + t.end(); + } + }); + sourceCache.onAdd(); + }); + t.end(); }); diff --git a/test/unit/symbol/cross_tile_symbol_index.js b/test/unit/symbol/cross_tile_symbol_index.js index 92d3e31dabb..298e2437113 100644 --- a/test/unit/symbol/cross_tile_symbol_index.js +++ b/test/unit/symbol/cross_tile_symbol_index.js @@ -37,7 +37,7 @@ test('CrossTileSymbolIndex.addLayer', (t) => { ]; const mainTile = makeTile(mainID, mainInstances); - index.addLayer(styleLayer, [mainTile]); + index.addLayer(styleLayer, [mainTile], 0); // Assigned new IDs t.equal(mainInstances[0].crossTileID, 1); t.equal(mainInstances[1].crossTileID, 2); @@ -51,7 +51,7 @@ test('CrossTileSymbolIndex.addLayer', (t) => { ]; const childTile = makeTile(childID, childInstances); - index.addLayer(styleLayer, [mainTile, childTile]); + index.addLayer(styleLayer, [mainTile, childTile], 0); // matched parent tile t.equal(childInstances[0].crossTileID, 1); // does not match because of different key @@ -67,7 +67,7 @@ test('CrossTileSymbolIndex.addLayer', (t) => { ]; const parentTile = makeTile(parentID, parentInstances); - index.addLayer(styleLayer, [mainTile, childTile, parentTile]); + index.addLayer(styleLayer, [mainTile, childTile, parentTile], 0); // matched child tile t.equal(parentInstances[0].crossTileID, 1); @@ -78,8 +78,8 @@ test('CrossTileSymbolIndex.addLayer', (t) => { ]; const grandchildTile = makeTile(grandchildID, grandchildInstances); - index.addLayer(styleLayer, [mainTile]); - index.addLayer(styleLayer, [mainTile, grandchildTile]); + index.addLayer(styleLayer, [mainTile], 0); + index.addLayer(styleLayer, [mainTile, grandchildTile], 0); // Matches the symbol in `mainBucket` t.equal(grandchildInstances[0].crossTileID, 1); // Does not match the previous value for Windsor because that tile was removed @@ -100,18 +100,18 @@ test('CrossTileSymbolIndex.addLayer', (t) => { const childTile = makeTile(childID, childInstances); // assigns a new id - index.addLayer(styleLayer, [mainTile]); + index.addLayer(styleLayer, [mainTile], 0); t.equal(mainInstances[0].crossTileID, 1); // removes the tile - index.addLayer(styleLayer, []); + index.addLayer(styleLayer, [], 0); // assigns a new id - index.addLayer(styleLayer, [childTile]); + index.addLayer(styleLayer, [childTile], 0); t.equal(childInstances[0].crossTileID, 2); // overwrites the old id to match the already-added tile - index.addLayer(styleLayer, [mainTile, childTile]); + index.addLayer(styleLayer, [mainTile, childTile], 0); t.equal(mainInstances[0].crossTileID, 2); t.equal(childInstances[0].crossTileID, 2); @@ -137,7 +137,7 @@ test('CrossTileSymbolIndex.addLayer', (t) => { const childTile = makeTile(childID, childInstances); // assigns new ids - index.addLayer(styleLayer, [mainTile]); + index.addLayer(styleLayer, [mainTile], 0); t.equal(mainInstances[0].crossTileID, 1); t.equal(mainInstances[1].crossTileID, 2); @@ -145,7 +145,7 @@ test('CrossTileSymbolIndex.addLayer', (t) => { t.deepEqual(Object.keys(layerIndex.usedCrossTileIDs[6]), [1, 2]); // copies parent ids without duplicate ids in this tile - index.addLayer(styleLayer, [childTile]); + index.addLayer(styleLayer, [childTile], 0); t.equal(childInstances[0].crossTileID, 1); // A' copies from A t.equal(childInstances[1].crossTileID, 2); // B' copies from B t.equal(childInstances[2].crossTileID, 3); // C' gets new ID @@ -175,7 +175,7 @@ test('CrossTileSymbolIndex.addLayer', (t) => { const secondTile = makeTile(tileID, secondInstances); // assigns new ids - index.addLayer(styleLayer, [firstTile]); + index.addLayer(styleLayer, [firstTile], 0); t.equal(firstInstances[0].crossTileID, 1); t.equal(firstInstances[1].crossTileID, 2); @@ -183,7 +183,7 @@ test('CrossTileSymbolIndex.addLayer', (t) => { t.deepEqual(Object.keys(layerIndex.usedCrossTileIDs[6]), [1, 2]); // uses same ids when tile gets updated - index.addLayer(styleLayer, [secondTile]); + index.addLayer(styleLayer, [secondTile], 0); t.equal(secondInstances[0].crossTileID, 1); // A' copies from A t.equal(secondInstances[1].crossTileID, 2); // B' copies from B t.equal(secondInstances[2].crossTileID, 3); // C' gets new ID @@ -193,6 +193,27 @@ test('CrossTileSymbolIndex.addLayer', (t) => { t.end(); }); + t.test('reuses indexes when longitude is wrapped', (t) => { + const index = new CrossTileSymbolIndex(); + const longitude = 370; + + const tileID = new OverscaledTileID(6, 1, 6, 8, 8); + const firstInstances = [ + makeSymbolInstance(1000, 1000, ""), // A + ]; + const tile = makeTile(tileID, firstInstances); + + index.addLayer(styleLayer, [tile], longitude); + t.equal(firstInstances[0].crossTileID, 1); // A + + tile.tileID = tileID.wrapped(); + + index.addLayer(styleLayer, [tile], longitude % 360); + t.equal(firstInstances[0].crossTileID, 1); + t.end(); + + }); + t.end(); }); @@ -207,7 +228,7 @@ test('CrossTileSymbolIndex.pruneUnusedLayers', (t) => { const tile = makeTile(tileID, instances); // assigns new ids - index.addLayer(styleLayer, [tile]); + index.addLayer(styleLayer, [tile], 0); t.equal(instances[0].crossTileID, 1); t.equal(instances[1].crossTileID, 2); t.ok(index.layerIndexes[styleLayer.id]);