From f1f51e493c0c5160a8a85aba98425733880c1502 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 1 Oct 2026 19:25:29 -0400 Subject: [PATCH] fix(deck.gl-geotiff): Honor the extent prop in MosaicLayer MosaicLayer forwards `extent` to its tileset, but MosaicTileset2D overrides `getTileIndices` without reading it, so every source in view still loaded. Keep only the sources whose bbox overlaps the extent (not just touching it, as in deck.gl's TileLayer), reading it on every call so prop updates apply. `extent` is now documented on MosaicLayerProps as WGS84 `[minLng, minLat, maxLng, maxLat]`. Co-Authored-By: Claude Opus 5.5 --- .../src/mosaic-layer/mosaic-layer.ts | 18 +++-- .../src/mosaic-layer/mosaic-tileset-2d.ts | 17 ++++- .../tests/mosaic-tileset-2d.test.ts | 67 ++++++++++++++++++- 3 files changed, 94 insertions(+), 8 deletions(-) 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", + ]); + }); +});