diff --git a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts index 79084286..efbf07a5 100644 --- a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts +++ b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-layer.ts @@ -27,12 +27,7 @@ export type MosaicLayerProps< TileLayerProps, // NOTE: `debounceTime` is intentionally not exposed. // See https://github.com/developmentseed/deck.gl-raster/issues/562 - | "extent" - | "maxCacheByteSize" - | "maxCacheSize" - | "maxRequests" - | "maxZoom" - | "minZoom" + "maxCacheByteSize" | "maxCacheSize" | "maxRequests" | "maxZoom" | "minZoom" > & { /** * List of mosaic sources to render. @@ -55,6 +50,17 @@ export type MosaicLayerProps< */ sources: MosaicT[]; + /** + * Only load and render sources whose `bbox` overlaps this bounding box, + * given as `[minLng, minLat, maxLng, maxLat]` in WGS84 degrees. + * + * A source that overlaps it is drawn in full, not clipped to it. To limit + * what a source loads as well, pass the same `extent` to the layers + * `renderSource` returns. Unlike in deck.gl's TileLayer, it does not keep + * sources loading below `minZoom`. + */ + extent?: number[] | null; + /** * Caps concurrent HTTP requests for this layer's source fetches. * diff --git a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-tileset-2d.ts b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-tileset-2d.ts index bcc9ede4..b53e5aa4 100644 --- a/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-tileset-2d.ts +++ b/packages/deck.gl-geotiff/src/mosaic-layer/mosaic-tileset-2d.ts @@ -4,6 +4,7 @@ import type { _Tileset2DProps as Tileset2DProps, } from "@deck.gl/geo-layers"; import { _Tileset2D as Tileset2D } from "@deck.gl/geo-layers"; +import type { Bounds } from "@developmentseed/deck.gl-raster"; import type Flatbush from "flatbush"; /** Tile index. @@ -120,9 +121,15 @@ export class MosaicTileset2D extends Tileset2D { } const viewportBounds = viewport.getBounds(); - const indices = index.search(...viewportBounds); + let indices = index.search(...viewportBounds); const sources = this.getSources(); + // Read `extent` on every call: TileLayer updates it through `setOptions`. + const extent = this.opts.extent as Bounds | null; + if (extent) { + indices = indices.filter((i) => overlaps(sources[i]!.bbox, extent)); + } + return indices.map((sourceIndex) => { const source = sources[sourceIndex]!; return { @@ -137,3 +144,11 @@ export class MosaicTileset2D extends Tileset2D { }); } } + +/** + * Whether two `[minX, minY, maxX, maxY]` boxes overlap. Boxes that only share + * an edge or a corner don't, as with tiles and `extent` in deck.gl's TileLayer. + */ +function overlaps(a: Bounds, b: Bounds): boolean { + return a[0] < b[2] && a[2] > b[0] && a[1] < b[3] && a[3] > b[1]; +} diff --git a/packages/deck.gl-geotiff/tests/mosaic-tileset-2d.test.ts b/packages/deck.gl-geotiff/tests/mosaic-tileset-2d.test.ts index 8d3c81d8..b99c7459 100644 --- a/packages/deck.gl-geotiff/tests/mosaic-tileset-2d.test.ts +++ b/packages/deck.gl-geotiff/tests/mosaic-tileset-2d.test.ts @@ -31,7 +31,7 @@ function buildIndex(sources: MosaicSource[]): Flatbush | null { function makeTileset( sources: T[], - opts: { maxRequests?: number } = {}, + opts: { maxRequests?: number; extent?: number[] } = {}, ): MosaicTileset2D { const index = buildIndex(sources); return new MosaicTileset2D( @@ -42,6 +42,8 @@ function makeTileset( ...(opts.maxRequests !== undefined ? { maxRequests: opts.maxRequests } : {}), + // deck.gl's TileLayer always passes `extent`, `null` by default. + extent: opts.extent ?? null, } as unknown as Tileset2DProps, ); } @@ -125,3 +127,66 @@ describe("MosaicTileset2D tile ids", () => { expect(tileset.getTileId(result[0]!)).toBe("stable-id"); }); }); + +describe("MosaicTileset2D extent", () => { + // Shows all of A, B and C. + const viewport = makeViewport([-1, -1, 51, 11]); + const names = (sources: Item[]) => sources.map((s) => s.name).sort(); + + it("selects only the sources that overlap the extent", () => { + // Covers part of A and part of B. Asymmetric, so reading it with swapped + // axes would select only A. + const tileset = makeTileset([A, B, C], { extent: [5, 2, 25, 8] }); + expect(names(tileset.getTileIndices({ viewport }))).toEqual(["A", "B"]); + }); + + it("keeps array-position ids when the extent skips an earlier source", () => { + // Skips A. B and C still get ids from their positions in `sources`. + const tileset = makeTileset([A, B, C], { extent: [25, 2, 45, 8] }); + const result = tileset.getTileIndices({ viewport }); + const ids = Object.fromEntries( + result.map((s) => [s.name, tileset.getTileId(s)]), + ); + expect(ids).toEqual({ B: "1", C: "2" }); + }); + + it("skips sources that only touch the extent", () => { + // Shares A's east edge and B's west edge. deck.gl's TileLayer likewise + // skips tiles that only touch its extent. + const sideBySide = makeTileset([A, B, C], { extent: [10, 0, 20, 10] }); + expect(names(sideBySide.getTileIndices({ viewport }))).toEqual([]); + + // Shares the north edges of A, B and C. + const above = makeTileset([A, B, C], { extent: [0, 10, 50, 20] }); + expect(names(above.getTileIndices({ viewport }))).toEqual([]); + }); + + it("keeps a visible source whose overlap with the extent is off-screen", () => { + // `wide` reaches the extent off-screen to the east, and a source is drawn + // in full, so its west end stays on screen. `near` is on screen but + // misses the extent. + const wide: Item = { name: "wide", bbox: [0, 0, 100, 10] }; + const near: Item = { name: "near", bbox: [0, 20, 10, 30] }; + const tileset = makeTileset([wide, near], { extent: [80, 0, 90, 10] }); + const result = tileset.getTileIndices({ + viewport: makeViewport([-1, -1, 11, 31]), + }); + expect(names(result)).toEqual(["wide"]); + }); + + it("applies an extent changed through setOptions on the next call", () => { + const tileset = makeTileset([A, B, C]); + const getTileData = () => new Promise(() => {}); + + // deck.gl's TileLayer calls `setOptions` when its props change. + tileset.setOptions({ getTileData, extent: [5, 2, 25, 8] }); + expect(names(tileset.getTileIndices({ viewport }))).toEqual(["A", "B"]); + + tileset.setOptions({ getTileData, extent: null }); + expect(names(tileset.getTileIndices({ viewport }))).toEqual([ + "A", + "B", + "C", + ]); + }); +});