From b15b06e163df11c1b57cfba12b670e55156258de Mon Sep 17 00:00:00 2001 From: Jakub Pelc Date: Tue, 15 Oct 2024 09:29:36 +0200 Subject: [PATCH] TileBounds supports ranges that cross the antimeridian --- src/source/tile_bounds.test.ts | 56 ++++++++++++++++++++++++++++++++++ src/source/tile_bounds.ts | 38 +++++++++++++++++------ 2 files changed, 85 insertions(+), 9 deletions(-) create mode 100644 src/source/tile_bounds.test.ts diff --git a/src/source/tile_bounds.test.ts b/src/source/tile_bounds.test.ts new file mode 100644 index 0000000000..26a9ce595c --- /dev/null +++ b/src/source/tile_bounds.test.ts @@ -0,0 +1,56 @@ +import {TileBounds} from './tile_bounds'; +import {CanonicalTileID} from './tile_id'; + +describe('TileBounds', () => { + test('default', () => { + const bounds = new TileBounds([-180, -90, 180, 90]); + expect(bounds.contains(new CanonicalTileID(2, 0, 0))).toBe(true); + expect(bounds.contains(new CanonicalTileID(2, 1, 1))).toBe(true); + expect(bounds.contains(new CanonicalTileID(2, 2, 2))).toBe(true); + expect(bounds.contains(new CanonicalTileID(2, 3, 3))).toBe(true); + }); + + describe('latitude', () => { + test('is clamped', () => { + const bounds = new TileBounds([-180, -900, 180, 900]); + expect(bounds.contains(new CanonicalTileID(2, 0, 0))).toBe(true); + expect(bounds.contains(new CanonicalTileID(2, 1, 1))).toBe(true); + expect(bounds.contains(new CanonicalTileID(2, 2, 2))).toBe(true); + expect(bounds.contains(new CanonicalTileID(2, 3, 3))).toBe(true); + }); + + test('limits extent', () => { + const bounds = new TileBounds([-180, -45, 180, 45]); + expect(bounds.contains(new CanonicalTileID(2, 0, 0))).toBe(false); + expect(bounds.contains(new CanonicalTileID(2, 1, 1))).toBe(true); + expect(bounds.contains(new CanonicalTileID(2, 2, 2))).toBe(true); + expect(bounds.contains(new CanonicalTileID(2, 3, 3))).toBe(false); + }); + }); + + describe('longitude with wrapping', () => { + test('half range', () => { + const bounds = new TileBounds([0, -90, 180, 90]); + expect(bounds.contains(new CanonicalTileID(2, 0, 0))).toBe(false); + expect(bounds.contains(new CanonicalTileID(2, 1, 1))).toBe(false); + expect(bounds.contains(new CanonicalTileID(2, 2, 2))).toBe(true); + expect(bounds.contains(new CanonicalTileID(2, 3, 3))).toBe(true); + }); + + test('wrapped positive', () => { + const bounds = new TileBounds([0, -90, 270, 90]); + expect(bounds.contains(new CanonicalTileID(2, 0, 0))).toBe(true); + expect(bounds.contains(new CanonicalTileID(2, 1, 1))).toBe(false); + expect(bounds.contains(new CanonicalTileID(2, 2, 2))).toBe(true); + expect(bounds.contains(new CanonicalTileID(2, 3, 3))).toBe(true); + }); + + test('wrapped negative', () => { + const bounds = new TileBounds([-270, -90, 0, 90]); + expect(bounds.contains(new CanonicalTileID(2, 0, 0))).toBe(true); + expect(bounds.contains(new CanonicalTileID(2, 1, 1))).toBe(true); + expect(bounds.contains(new CanonicalTileID(2, 2, 2))).toBe(false); + expect(bounds.contains(new CanonicalTileID(2, 3, 3))).toBe(true); + }); + }); +}); diff --git a/src/source/tile_bounds.ts b/src/source/tile_bounds.ts index 9a90c803f8..a2878e4882 100644 --- a/src/source/tile_bounds.ts +++ b/src/source/tile_bounds.ts @@ -1,9 +1,17 @@ import {LngLatBounds, LngLatBoundsLike} from '../geo/lng_lat_bounds'; import {mercatorXfromLng, mercatorYfromLat} from '../geo/mercator_coordinate'; +import {mod} from '../util/util'; import type {CanonicalTileID} from './tile_id'; +function fract(x: number): number { + return x - Math.floor(x); +} + export class TileBounds { + /** + * Coordinate bounds. Longitude is *not* clamped to [-180, 180]! + */ bounds: LngLatBounds; minzoom: number; maxzoom: number; @@ -17,18 +25,30 @@ export class TileBounds { validateBounds(bounds: [number, number, number, number]): LngLatBoundsLike { // make sure the bounds property contains valid longitude and latitudes if (!Array.isArray(bounds) || bounds.length !== 4) return [-180, -90, 180, 90]; - return [Math.max(-180, bounds[0]), Math.max(-90, bounds[1]), Math.min(180, bounds[2]), Math.min(90, bounds[3])]; + return [bounds[0], Math.max(-90, bounds[1]), bounds[2], Math.min(90, bounds[3])]; } contains(tileID: CanonicalTileID) { const worldSize = Math.pow(2, tileID.z); - const level = { - minX: Math.floor(mercatorXfromLng(this.bounds.getWest()) * worldSize), - minY: Math.floor(mercatorYfromLat(this.bounds.getNorth()) * worldSize), - maxX: Math.ceil(mercatorXfromLng(this.bounds.getEast()) * worldSize), - maxY: Math.ceil(mercatorYfromLat(this.bounds.getSouth()) * worldSize) - }; - const hit = tileID.x >= level.minX && tileID.x < level.maxX && tileID.y >= level.minY && tileID.y < level.maxY; - return hit; + + // Latitude test + const minY = Math.floor(mercatorYfromLat(this.bounds.getNorth()) * worldSize); + const maxY = Math.ceil(mercatorYfromLat(this.bounds.getSouth()) * worldSize); + + if (tileID.y < minY || tileID.y >= maxY) { + return false; + } + + // Longitude test with wrapping around the globe + let minX = fract(mercatorXfromLng(this.bounds.getWest())); + let maxX = fract(mercatorXfromLng(this.bounds.getEast())); + if (minX >= maxX) { + maxX += 1; + } + minX = Math.floor(minX * worldSize); + maxX = Math.ceil(maxX * worldSize); + + const wrappedTileX = mod(tileID.x, worldSize); + return (wrappedTileX >= minX && wrappedTileX < maxX) || (wrappedTileX + worldSize >= minX && wrappedTileX + worldSize < maxX); } }