From a79d63534273168871ddc5282e63921742a0f153 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 18 Mar 2026 19:29:32 -0400 Subject: [PATCH 1/9] fix: Refinement strategy --- .../src/raster-tileset/raster-tileset-2d.ts | 37 ++- .../tests/tileset-refinement.test.ts | 290 ++++++++++++++++++ 2 files changed, 324 insertions(+), 3 deletions(-) create mode 100644 packages/deck.gl-raster/tests/tileset-refinement.test.ts diff --git a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts index 91ae842f..8a27acb8 100644 --- a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts +++ b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts @@ -109,11 +109,26 @@ export class TileMatrixSetTileset extends Tileset2D { const currentOverview = this.tms.tileMatrices[index.z]!; const parentOverview = this.tms.tileMatrices[index.z - 1]!; - const decimation = currentOverview.cellSize / parentOverview.cellSize; + // Decimation is the number of child tiles that fit across one parent tile. + // Must use tile footprint (cellSize × tileWidth/Height), not cellSize alone, + // because tileWidth can change between levels (e.g. the last Sentinel-2 + // overview doubles tileWidth while halving cellSize, giving a 1:1 spatial + // mapping where decimation = 1). + const parentFootprintX = + parentOverview.cellSize * parentOverview.tileWidth; + const parentFootprintY = + parentOverview.cellSize * parentOverview.tileHeight; + const currentFootprintX = + currentOverview.cellSize * currentOverview.tileWidth; + const currentFootprintY = + currentOverview.cellSize * currentOverview.tileHeight; + + const decimationX = parentFootprintX / currentFootprintX; + const decimationY = parentFootprintY / currentFootprintY; return { - x: Math.floor(index.x / decimation), - y: Math.floor(index.y / decimation), + x: Math.floor(index.x / decimationX), + y: Math.floor(index.y / decimationY), z: index.z - 1, }; } @@ -153,7 +168,23 @@ export class TileMatrixSetTileset extends Tileset2D { Math.max(topLeft[1], topRight[1], bottomLeft[1], bottomRight[1]), ]; + // Project all four corners to WGS84 and compute geographic bounding box. + // deck.gl's Tile2DHeader uses `bbox` (GeoBoundingBox) for screen-space + // culling in filterSubLayer → isTileVisible. Without this, all tiles + // would pass (or fail) the cull-rect test and the refinementStrategy + // (best-available) would not show parent tiles correctly. + const corners = [topLeft, topRight, bottomLeft, bottomRight].map(([cx, cy]) => + this.projectTo4326(cx, cy), + ); + const bbox = { + west: Math.min(...corners.map(([lon]) => lon)), + south: Math.min(...corners.map(([, lat]) => lat)), + east: Math.max(...corners.map(([lon]) => lon)), + north: Math.max(...corners.map(([, lat]) => lat)), + }; + return { + bbox, bounds, projectedBounds, tileWidth, diff --git a/packages/deck.gl-raster/tests/tileset-refinement.test.ts b/packages/deck.gl-raster/tests/tileset-refinement.test.ts new file mode 100644 index 00000000..d13febf5 --- /dev/null +++ b/packages/deck.gl-raster/tests/tileset-refinement.test.ts @@ -0,0 +1,290 @@ +/** + * Tests for TileMatrixSetTileset refinement strategy. + * + * Verifies that when zooming in, parent tiles already in cache remain visible + * while child tiles are still loading (best-available / no-flash behavior). + */ + +import type { Viewport } from "@deck.gl/core"; +import type { _Tileset2DProps as Tileset2DProps } from "@deck.gl/geo-layers"; +import { describe, expect, it } from "vitest"; +import { TileMatrixSetTileset } from "../src/raster-tileset/raster-tileset-2d.js"; +import type { TileIndex } from "../src/raster-tileset/types.js"; + +// --------------------------------------------------------------------------- +// Minimal TileMatrixSet fixture (2 zoom levels, EPSG:4326 image space) +// --------------------------------------------------------------------------- +// +// z=0: 1×1 tile covering the whole image +// z=1: 2×2 tiles each covering a quadrant +// +// cellSize ratio is 2 (standard power-of-2 pyramid). + +const MOCK_TMS = { + id: "test", + crs: { uri: "http://www.opengis.net/def/crs/EPSG/0/4326" }, + boundingBox: { + lowerLeft: [0, 0] as [number, number], + upperRight: [1, 1] as [number, number], + }, + tileMatrices: [ + { + id: "0", + scaleDenominator: 1000, + cellSize: 0.02, + cornerOfOrigin: "topLeft" as const, + pointOfOrigin: [0, 1] as [number, number], + tileWidth: 64, + tileHeight: 64, + matrixWidth: 1, + matrixHeight: 1, + }, + { + id: "1", + scaleDenominator: 500, + cellSize: 0.01, + cornerOfOrigin: "topLeft" as const, + pointOfOrigin: [0, 1] as [number, number], + tileWidth: 64, + tileHeight: 64, + matrixWidth: 2, + matrixHeight: 2, + }, + ], +}; + +// Identity projection (image CRS == WGS84 for this fixture) +const identity = (x: number, y: number): [number, number] => [x, y]; + +// --------------------------------------------------------------------------- +// Test-friendly subclass: override getTileIndices to control which tiles are +// "visible" at each simulated zoom level without needing a real Viewport. +// --------------------------------------------------------------------------- + +class ControlledTileset extends TileMatrixSetTileset { + private _forcedIndices: TileIndex[] = []; + + setForcedIndices(indices: TileIndex[]) { + this._forcedIndices = indices; + } + + override getTileIndices(_opts: Parameters[0]): TileIndex[] { + return this._forcedIndices; + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Minimal mock viewport whose `equals()` always returns false so that + * `Tileset2D.update()` always re-evaluates the tile selection. + */ +function makeViewport(): Viewport { + return { + equals: () => false, + resolution: undefined, + } as unknown as Viewport; +} + +/** + * Build a ControlledTileset with a never-resolving getTileData so that + * tiles stay in the "loading" state for the duration of the test. + */ +function makeTileset(opts?: Partial): ControlledTileset { + return new ControlledTileset( + { + getTileData: () => new Promise(() => {}), // never resolves + ...opts, + }, + MOCK_TMS as any, + { projectTo4326: identity, projectTo3857: identity }, + ); +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("TileMatrixSetTileset – best-available refinement", () => { + it("marks the z=0 parent visible while z=1 children are loading", async () => { + const tileset = makeTileset(); + + // --- Step 1: simulate viewport at z=0 (one tile covers the whole image) + tileset.setForcedIndices([{ x: 0, y: 0, z: 0 }]); + tileset.update(makeViewport(), { zRange: null, modelMatrix: null }); + + // The z=0 tile is now selected and loading. + const parentTile = tileset.tiles.find( + (t) => t.index.x === 0 && t.index.y === 0 && t.index.z === 0, + ); + expect(parentTile, "parent tile should be in cache").toBeDefined(); + + // Simulate the tile loading successfully by injecting content. + (parentTile as any).content = { width: 64, height: 64 }; + (parentTile as any)._isLoaded = true; + + // Run another update so the tileset sees the loaded state. + tileset.setForcedIndices([{ x: 0, y: 0, z: 0 }]); + tileset.update(makeViewport(), { zRange: null, modelMatrix: null }); + + expect(parentTile!.isLoaded).toBe(true); + expect(parentTile!.isVisible).toBe(true); + + // --- Step 2: zoom in — z=1 tiles are now selected, still loading + tileset.setForcedIndices([ + { x: 0, y: 0, z: 1 }, + { x: 1, y: 0, z: 1 }, + { x: 0, y: 1, z: 1 }, + { x: 1, y: 1, z: 1 }, + ]); + tileset.update(makeViewport(), { zRange: null, modelMatrix: null }); + + // All four z=1 tiles should be in cache and loading (never-resolve getData) + const childTiles = tileset.tiles.filter((t) => t.index.z === 1); + expect(childTiles).toHaveLength(4); + for (const child of childTiles) { + expect(child.isLoaded, `child ${child.id} should still be loading`).toBe(false); + } + + // The z=0 parent should still be visible as a placeholder. + expect( + parentTile!.isVisible, + "parent tile should remain visible while children are loading", + ).toBe(true); + + // The loading child tiles should NOT be visible (no content yet). + for (const child of childTiles) { + expect( + child.isVisible, + `loading child ${child.id} should not be visible`, + ).toBe(false); + } + }); + + it("hides the parent once all children have loaded", async () => { + const tileset = makeTileset(); + + // Load z=0 parent + tileset.setForcedIndices([{ x: 0, y: 0, z: 0 }]); + tileset.update(makeViewport(), { zRange: null, modelMatrix: null }); + + const parentTile = tileset.tiles.find((t) => t.index.z === 0)!; + (parentTile as any).content = { width: 64, height: 64 }; + (parentTile as any)._isLoaded = true; + + // Zoom in + tileset.setForcedIndices([ + { x: 0, y: 0, z: 1 }, + { x: 1, y: 0, z: 1 }, + { x: 0, y: 1, z: 1 }, + { x: 1, y: 1, z: 1 }, + ]); + tileset.update(makeViewport(), { zRange: null, modelMatrix: null }); + + // Load all children + const childTiles = tileset.tiles.filter((t) => t.index.z === 1); + for (const child of childTiles) { + (child as any).content = { width: 64, height: 64 }; + (child as any)._isLoaded = true; + } + + tileset.update(makeViewport(), { zRange: null, modelMatrix: null }); + + // Now children are loaded and selected, so the parent should not be visible. + expect( + parentTile.isVisible, + "parent should not be visible once children are loaded", + ).toBe(false); + + for (const child of childTiles) { + expect( + child.isVisible, + `loaded child ${child.id} should be visible`, + ).toBe(true); + } + }); + + it("getParentIndex correctly maps z=1 children to z=0 parent (2:1 ratio)", () => { + const tileset = makeTileset(); + + // MOCK_TMS: z=0 cellSize=0.02 tileWidth=64, z=1 cellSize=0.01 tileWidth=64 + // Footprint ratio = (0.02*64) / (0.01*64) = 2 → each parent covers 2×2 children. + const parent00 = tileset.getParentIndex({ x: 0, y: 0, z: 1 }); + const parent10 = tileset.getParentIndex({ x: 1, y: 0, z: 1 }); + const parent01 = tileset.getParentIndex({ x: 0, y: 1, z: 1 }); + const parent11 = tileset.getParentIndex({ x: 1, y: 1, z: 1 }); + + expect(parent00).toEqual({ x: 0, y: 0, z: 0 }); + expect(parent10).toEqual({ x: 0, y: 0, z: 0 }); + expect(parent01).toEqual({ x: 0, y: 0, z: 0 }); + expect(parent11).toEqual({ x: 0, y: 0, z: 0 }); + }); + + it("getParentIndex correctly handles 1:1 spatial mapping when tileWidth doubles", () => { + // Sentinel-2-like TMS: last overview doubles tileWidth while halving cellSize, + // so parent and child tiles cover the exact same spatial footprint. + // decimation should be 1 (each child maps to the parent at the same x,y). + const sentinel2TMS = { + id: "s2", + crs: { uri: "http://www.opengis.net/def/crs/EPSG/0/32618" }, + boundingBox: { + lowerLeft: [499980, 4490220] as [number, number], + upperRight: [609780, 4600020] as [number, number], + }, + tileMatrices: [ + // z=3: cellSize=20, tileWidth=512 → footprint = 10240m + { + id: "3", + scaleDenominator: 71428.57, + cellSize: 20, + cornerOfOrigin: "topLeft" as const, + pointOfOrigin: [499980, 4600020] as [number, number], + tileWidth: 512, + tileHeight: 512, + matrixWidth: 11, + matrixHeight: 11, + }, + // z=4: cellSize=10, tileWidth=1024 → footprint = 10240m (same as z=3!) + { + id: "4", + scaleDenominator: 35714.29, + cellSize: 10, + cornerOfOrigin: "topLeft" as const, + pointOfOrigin: [499980, 4600020] as [number, number], + tileWidth: 1024, + tileHeight: 1024, + matrixWidth: 11, + matrixHeight: 11, + }, + ], + }; + + const tileset = new ControlledTileset( + { getTileData: () => new Promise(() => {}) }, + sentinel2TMS as any, + { projectTo4326: identity, projectTo3857: identity }, + ); + + // Every z=4 tile should map 1:1 to the z=3 tile at the same x,y. + expect(tileset.getParentIndex({ x: 0, y: 0, z: 1 })).toEqual({ x: 0, y: 0, z: 0 }); + expect(tileset.getParentIndex({ x: 5, y: 5, z: 1 })).toEqual({ x: 5, y: 5, z: 0 }); + expect(tileset.getParentIndex({ x: 10, y: 10, z: 1 })).toEqual({ x: 10, y: 10, z: 0 }); + }); + + it("getTileMetadata includes a bbox in GeoBoundingBox format", () => { + const tileset = makeTileset(); + + const meta = tileset.getTileMetadata({ x: 0, y: 0, z: 0 }); + + expect(meta.bbox).toBeDefined(); + const { bbox } = meta as any; + expect(typeof bbox.west).toBe("number"); + expect(typeof bbox.south).toBe("number"); + expect(typeof bbox.east).toBe("number"); + expect(typeof bbox.north).toBe("number"); + expect(bbox.west).toBeLessThan(bbox.east); + expect(bbox.south).toBeLessThan(bbox.north); + }); +}); From dda38d52e20e819a6ce6cbd04cc46dc751c9b2f6 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 18 Mar 2026 19:30:39 -0400 Subject: [PATCH 2/9] fmt --- .../src/raster-tileset/raster-tileset-2d.ts | 7 +++-- .../tests/tileset-refinement.test.ts | 26 +++++++++++++++---- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts index 8a27acb8..2a9e4664 100644 --- a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts +++ b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts @@ -114,8 +114,7 @@ export class TileMatrixSetTileset extends Tileset2D { // because tileWidth can change between levels (e.g. the last Sentinel-2 // overview doubles tileWidth while halving cellSize, giving a 1:1 spatial // mapping where decimation = 1). - const parentFootprintX = - parentOverview.cellSize * parentOverview.tileWidth; + const parentFootprintX = parentOverview.cellSize * parentOverview.tileWidth; const parentFootprintY = parentOverview.cellSize * parentOverview.tileHeight; const currentFootprintX = @@ -173,8 +172,8 @@ export class TileMatrixSetTileset extends Tileset2D { // culling in filterSubLayer → isTileVisible. Without this, all tiles // would pass (or fail) the cull-rect test and the refinementStrategy // (best-available) would not show parent tiles correctly. - const corners = [topLeft, topRight, bottomLeft, bottomRight].map(([cx, cy]) => - this.projectTo4326(cx, cy), + const corners = [topLeft, topRight, bottomLeft, bottomRight].map( + ([cx, cy]) => this.projectTo4326(cx, cy), ); const bbox = { west: Math.min(...corners.map(([lon]) => lon)), diff --git a/packages/deck.gl-raster/tests/tileset-refinement.test.ts b/packages/deck.gl-raster/tests/tileset-refinement.test.ts index d13febf5..16bfc43e 100644 --- a/packages/deck.gl-raster/tests/tileset-refinement.test.ts +++ b/packages/deck.gl-raster/tests/tileset-refinement.test.ts @@ -68,7 +68,9 @@ class ControlledTileset extends TileMatrixSetTileset { this._forcedIndices = indices; } - override getTileIndices(_opts: Parameters[0]): TileIndex[] { + override getTileIndices( + _opts: Parameters[0], + ): TileIndex[] { return this._forcedIndices; } } @@ -145,7 +147,9 @@ describe("TileMatrixSetTileset – best-available refinement", () => { const childTiles = tileset.tiles.filter((t) => t.index.z === 1); expect(childTiles).toHaveLength(4); for (const child of childTiles) { - expect(child.isLoaded, `child ${child.id} should still be loading`).toBe(false); + expect(child.isLoaded, `child ${child.id} should still be loading`).toBe( + false, + ); } // The z=0 parent should still be visible as a placeholder. @@ -268,9 +272,21 @@ describe("TileMatrixSetTileset – best-available refinement", () => { ); // Every z=4 tile should map 1:1 to the z=3 tile at the same x,y. - expect(tileset.getParentIndex({ x: 0, y: 0, z: 1 })).toEqual({ x: 0, y: 0, z: 0 }); - expect(tileset.getParentIndex({ x: 5, y: 5, z: 1 })).toEqual({ x: 5, y: 5, z: 0 }); - expect(tileset.getParentIndex({ x: 10, y: 10, z: 1 })).toEqual({ x: 10, y: 10, z: 0 }); + expect(tileset.getParentIndex({ x: 0, y: 0, z: 1 })).toEqual({ + x: 0, + y: 0, + z: 0, + }); + expect(tileset.getParentIndex({ x: 5, y: 5, z: 1 })).toEqual({ + x: 5, + y: 5, + z: 0, + }); + expect(tileset.getParentIndex({ x: 10, y: 10, z: 1 })).toEqual({ + x: 10, + y: 10, + z: 0, + }); }); it("getTileMetadata includes a bbox in GeoBoundingBox format", () => { From 76180fe38f127cf740ffbafff6395bcde94f3a0a Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 19 Mar 2026 11:12:03 -0400 Subject: [PATCH 3/9] Define TileMetadata as type --- .../src/raster-tileset/raster-tileset-2d.ts | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts index 2a9e4664..2339b588 100644 --- a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts +++ b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts @@ -9,7 +9,11 @@ import type { Viewport } from "@deck.gl/core"; import type { _Tileset2DProps as Tileset2DProps } from "@deck.gl/geo-layers"; import { _Tileset2D as Tileset2D } from "@deck.gl/geo-layers"; import * as affine from "@developmentseed/affine"; -import type { BoundingBox, TileMatrixSet } from "@developmentseed/morecantile"; +import type { + BoundingBox, + TileMatrix, + TileMatrixSet, +} from "@developmentseed/morecantile"; import { tileTransform } from "@developmentseed/morecantile"; import type { Matrix4 } from "@math.gl/core"; @@ -23,6 +27,27 @@ import type { ZRange, } from "./types"; +/** Type returned by `getTileMetadata` */ +type TileMetadata = { + /** Bounding box of the tile in WGS84 coordinates */ + bbox: { west: number; south: number; east: number; north: number }; + + /** Axis-aligned bounding box of the tile in projected coordinates */ + bounds: Bounds; + + /** Bounding box of the tile in projected coordinates, represented as four corners to preserve rotation/skew info */ + projectedBounds: { + topLeft: Point; + topRight: Point; + bottomLeft: Point; + bottomRight: Point; + }; + + tileWidth: number; + tileHeight: number; + tileMatrix: TileMatrix; +}; + /** * A generic tileset implementation organized according to the OGC * [TileMatrixSet](https://docs.ogc.org/is/17-083r4/17-083r4.html) @@ -136,7 +161,7 @@ export class TileMatrixSetTileset extends Tileset2D { return index.z; } - override getTileMetadata(index: TileIndex): Record { + override getTileMetadata(index: TileIndex): TileMetadata { const { x, y, z } = index; const { tileMatrices } = this.tms; const tileMatrix = tileMatrices[z]!; From 72f7ff7b5751b24aca52d73299104b2339f8ebc5 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 19 Mar 2026 11:34:08 -0400 Subject: [PATCH 4/9] define type --- packages/deck.gl-geotiff/src/cog-layer.ts | 43 ++++++-------- packages/deck.gl-raster/src/index.ts | 1 + .../src/raster-tileset/index.ts | 1 + .../src/raster-tileset/raster-tileset-2d.ts | 59 ++++++++++++++++--- .../src/raster-tileset/types.ts | 5 +- 5 files changed, 74 insertions(+), 35 deletions(-) diff --git a/packages/deck.gl-geotiff/src/cog-layer.ts b/packages/deck.gl-geotiff/src/cog-layer.ts index 8eb1f998..a5fe3e5f 100644 --- a/packages/deck.gl-geotiff/src/cog-layer.ts +++ b/packages/deck.gl-geotiff/src/cog-layer.ts @@ -16,6 +16,7 @@ import { PathLayer } from "@deck.gl/layers"; import type { RasterLayerProps, RasterModule, + TileMetadata, } from "@developmentseed/deck.gl-raster"; import { RasterLayer, @@ -337,6 +338,7 @@ export class COGLayer< geotiff: GeoTIFF, tms: TileMatrixSet, ): Promise> { + console.log("tile in getTileData", tile); const { signal } = tile; const { x, y, z } = tile.index; @@ -407,7 +409,12 @@ export class COGLayer< inverseFrom3857: ReprojectionFns["inverseReproject"], ): Layer | LayersList | null { const { maxError, debug, debugOpacity } = this.props; - const { tile } = props; + + // Cast to include TileMetadata from raster-tileset's `getTileMetadata` + // method. + // TODO: implement generic handling of tile metadata upstream in TileLayer + const tile = props.tile as Tile2DHeader> & + TileMetadata; if (!props.data) { return null; @@ -495,35 +502,21 @@ export class COGLayer< } if (debug) { - // Get projected bounds from tile data - // getTileMetadata returns data that includes projectedBounds - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const projectedBounds: { - topLeft: [number, number]; - topRight: [number, number]; - bottomLeft: [number, number]; - bottomRight: [number, number]; - } = (tile as any)?.projectedBounds; - - if (!projectedBounds || !tms) { + console.log("tile", tile); + const { corners } = tile; + + if (!corners || !tms) { return []; } - // Project bounds from image CRS to WGS84 - const { topLeft, topRight, bottomLeft, bottomRight } = projectedBounds; - - const topLeftWgs84 = forwardTo4326(topLeft[0], topLeft[1]); - const topRightWgs84 = forwardTo4326(topRight[0], topRight[1]); - const bottomRightWgs84 = forwardTo4326(bottomRight[0], bottomRight[1]); - const bottomLeftWgs84 = forwardTo4326(bottomLeft[0], bottomLeft[1]); - // Create a closed path around the tile bounds + const { topLeft, topRight, bottomLeft, bottomRight } = corners; const path = [ - topLeftWgs84, - topRightWgs84, - bottomRightWgs84, - bottomLeftWgs84, - topLeftWgs84, // Close the path + topLeft, + topRight, + bottomRight, + bottomLeft, + topLeft, // Close the path ]; layers.push( diff --git a/packages/deck.gl-raster/src/index.ts b/packages/deck.gl-raster/src/index.ts index f8127f4d..ec71436a 100644 --- a/packages/deck.gl-raster/src/index.ts +++ b/packages/deck.gl-raster/src/index.ts @@ -1,6 +1,7 @@ export type { RasterModule } from "./gpu-modules/types.js"; export type { RasterLayerProps } from "./raster-layer.js"; export { RasterLayer } from "./raster-layer.js"; +export type { TileMetadata } from "./raster-tileset/index.js"; export { TileMatrixSetTileset } from "./raster-tileset/index.js"; import { __TEST_EXPORTS as traversalTestExports } from "./raster-tileset/raster-tile-traversal.js"; diff --git a/packages/deck.gl-raster/src/raster-tileset/index.ts b/packages/deck.gl-raster/src/raster-tileset/index.ts index f96ab6bd..4ae6612d 100644 --- a/packages/deck.gl-raster/src/raster-tileset/index.ts +++ b/packages/deck.gl-raster/src/raster-tileset/index.ts @@ -1 +1,2 @@ +export type { TileMetadata } from "./raster-tileset-2d.js"; export { TileMatrixSetTileset } from "./raster-tileset-2d.js"; diff --git a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts index 2339b588..ef4526c2 100644 --- a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts +++ b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts @@ -6,7 +6,10 @@ */ import type { Viewport } from "@deck.gl/core"; -import type { _Tileset2DProps as Tileset2DProps } from "@deck.gl/geo-layers"; +import type { + GeoBoundingBox, + _Tileset2DProps as Tileset2DProps, +} from "@deck.gl/geo-layers"; import { _Tileset2D as Tileset2D } from "@deck.gl/geo-layers"; import * as affine from "@developmentseed/affine"; import type { @@ -22,29 +25,69 @@ import type { Bounds, CornerBounds, Point, + ProjectedBoundingBox, ProjectionFunction, TileIndex, ZRange, } from "./types"; /** Type returned by `getTileMetadata` */ -type TileMetadata = { - /** Bounding box of the tile in WGS84 coordinates */ - bbox: { west: number; south: number; east: number; north: number }; +export type TileMetadata = { + /** + * **Axis-aligned** bounding box of the tile in **WGS84 coordinates**. + */ + bbox: GeoBoundingBox; - /** Axis-aligned bounding box of the tile in projected coordinates */ - bounds: Bounds; + /** + * **Axis-aligned** bounding box of the tile in **projected coordinates**. + */ + projectedBbox: ProjectedBoundingBox; - /** Bounding box of the tile in projected coordinates, represented as four corners to preserve rotation/skew info */ - projectedBounds: { + /** + * "Rotated" bounding box of the tile in **WGS84 coordinates**, represented as + * four corners. + * + * This preserves rotation/skew information that would be lost in the + * axis-aligned bbox. + */ + corners: { topLeft: Point; topRight: Point; bottomLeft: Point; bottomRight: Point; }; + /** + * "Rotated" bounding box of the tile in **projected coordinates**, + * represented as four corners. + * + * This preserves rotation/skew information that would be lost in the + * axis-aligned bbox. + */ + projectedCorners: { + topLeft: Point; + topRight: Point; + bottomLeft: Point; + bottomRight: Point; + }; + + /** + * Tile width in pixels. + * + * Note this may differ between levels in some TileMatrixSets. + */ tileWidth: number; + + /** + * Tile height in pixels. + * + * Note this may differ between levels in some TileMatrixSets. + */ tileHeight: number; + + /** + * A reference to the underlying TileMatrix. + */ tileMatrix: TileMatrix; }; diff --git a/packages/deck.gl-raster/src/raster-tileset/types.ts b/packages/deck.gl-raster/src/raster-tileset/types.ts index 597d4704..802289f9 100644 --- a/packages/deck.gl-raster/src/raster-tileset/types.ts +++ b/packages/deck.gl-raster/src/raster-tileset/types.ts @@ -8,14 +8,15 @@ export type GeoBoundingBox = { east: number; south: number; }; -export type NonGeoBoundingBox = { + +export type ProjectedBoundingBox = { left: number; top: number; right: number; bottom: number; }; -export type TileBoundingBox = NonGeoBoundingBox | GeoBoundingBox; +export type TileBoundingBox = ProjectedBoundingBox | GeoBoundingBox; export type TileLoadProps = { index: TileIndex; From e775c6cf54afe83a5a3551b39820568640b1032e Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 19 Mar 2026 13:50:15 -0400 Subject: [PATCH 5/9] depend on proj --- packages/deck.gl-raster/package.json | 1 + packages/deck.gl-raster/tsconfig.build.json | 1 + pnpm-lock.yaml | 3 +++ 3 files changed, 5 insertions(+) diff --git a/packages/deck.gl-raster/package.json b/packages/deck.gl-raster/package.json index 17d68e15..09eda4bf 100644 --- a/packages/deck.gl-raster/package.json +++ b/packages/deck.gl-raster/package.json @@ -62,6 +62,7 @@ "dependencies": { "@developmentseed/affine": "workspace:^", "@developmentseed/morecantile": "workspace:^", + "@developmentseed/proj": "workspace:^", "@developmentseed/raster-reproject": "workspace:^", "@math.gl/core": "^4.1.0", "@math.gl/culling": "^4.1.0", diff --git a/packages/deck.gl-raster/tsconfig.build.json b/packages/deck.gl-raster/tsconfig.build.json index 0e89e614..9dba05a1 100644 --- a/packages/deck.gl-raster/tsconfig.build.json +++ b/packages/deck.gl-raster/tsconfig.build.json @@ -9,6 +9,7 @@ "exclude": ["node_modules", "dist", "tests"], "references": [ { "path": "../morecantile/tsconfig.build.json" }, + { "path": "../proj/tsconfig.build.json" }, { "path": "../raster-reproject/tsconfig.build.json" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 992654d0..e9842e03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -351,6 +351,9 @@ importers: '@developmentseed/morecantile': specifier: workspace:^ version: link:../morecantile + '@developmentseed/proj': + specifier: workspace:^ + version: link:../proj '@developmentseed/raster-reproject': specifier: workspace:^ version: link:../raster-reproject From b0a3ed0c61123ed8f444e85ed9d1dcfdbde07939 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 19 Mar 2026 13:50:30 -0400 Subject: [PATCH 6/9] return densified bounds --- .../src/raster-tileset/raster-tileset-2d.ts | 44 ++++++++----------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts index ef4526c2..13e38172 100644 --- a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts +++ b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts @@ -18,8 +18,8 @@ import type { TileMatrixSet, } from "@developmentseed/morecantile"; import { tileTransform } from "@developmentseed/morecantile"; +import { transformBounds } from "@developmentseed/proj"; import type { Matrix4 } from "@math.gl/core"; - import { getTileIndices } from "./raster-tile-traversal"; import type { Bounds, @@ -43,20 +43,6 @@ export type TileMetadata = { */ projectedBbox: ProjectedBoundingBox; - /** - * "Rotated" bounding box of the tile in **WGS84 coordinates**, represented as - * four corners. - * - * This preserves rotation/skew information that would be lost in the - * axis-aligned bbox. - */ - corners: { - topLeft: Point; - topRight: Point; - bottomLeft: Point; - bottomRight: Point; - }; - /** * "Rotated" bounding box of the tile in **projected coordinates**, * represented as four corners. @@ -220,7 +206,7 @@ export class TileMatrixSetTileset extends Tileset2D { // Return the projected bounds as four corners // This preserves rotation/skew information - const projectedBounds = { + const projectedCorners = { topLeft, topRight, bottomLeft, @@ -228,32 +214,38 @@ export class TileMatrixSetTileset extends Tileset2D { }; // Also compute axis-aligned bounding box for compatibility - const bounds: Bounds = [ + const projectedBounds: Bounds = [ Math.min(topLeft[0], topRight[0], bottomLeft[0], bottomRight[0]), Math.min(topLeft[1], topRight[1], bottomLeft[1], bottomRight[1]), Math.max(topLeft[0], topRight[0], bottomLeft[0], bottomRight[0]), Math.max(topLeft[1], topRight[1], bottomLeft[1], bottomRight[1]), ]; - // Project all four corners to WGS84 and compute geographic bounding box. // deck.gl's Tile2DHeader uses `bbox` (GeoBoundingBox) for screen-space // culling in filterSubLayer → isTileVisible. Without this, all tiles // would pass (or fail) the cull-rect test and the refinementStrategy // (best-available) would not show parent tiles correctly. - const corners = [topLeft, topRight, bottomLeft, bottomRight].map( - ([cx, cy]) => this.projectTo4326(cx, cy), + const [west, south, east, north] = transformBounds( + this.projectTo4326, + ...projectedBounds, ); const bbox = { - west: Math.min(...corners.map(([lon]) => lon)), - south: Math.min(...corners.map(([, lat]) => lat)), - east: Math.max(...corners.map(([lon]) => lon)), - north: Math.max(...corners.map(([, lat]) => lat)), + west, + south, + east, + north, + }; + const projectedBbox: ProjectedBoundingBox = { + left: projectedBounds[0], + bottom: projectedBounds[1], + right: projectedBounds[2], + top: projectedBounds[3], }; return { bbox, - bounds, - projectedBounds, + projectedBbox, + projectedCorners, tileWidth, tileHeight, tileMatrix, From 58c7f0c12dd39dcb69a2ad1faf86c1c4fdc3fce3 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 19 Mar 2026 13:55:55 -0400 Subject: [PATCH 7/9] clean up --- packages/deck.gl-geotiff/src/cog-layer.ts | 34 +++++++++++++++-------- 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/packages/deck.gl-geotiff/src/cog-layer.ts b/packages/deck.gl-geotiff/src/cog-layer.ts index a5fe3e5f..60775d04 100644 --- a/packages/deck.gl-geotiff/src/cog-layer.ts +++ b/packages/deck.gl-geotiff/src/cog-layer.ts @@ -338,7 +338,6 @@ export class COGLayer< geotiff: GeoTIFF, tms: TileMatrixSet, ): Promise> { - console.log("tile in getTileData", tile); const { signal } = tile; const { x, y, z } = tile.index; @@ -502,21 +501,34 @@ export class COGLayer< } if (debug) { - console.log("tile", tile); - const { corners } = tile; + const { projectedCorners } = tile; - if (!corners || !tms) { + if (!projectedCorners || !tms) { return []; } - // Create a closed path around the tile bounds - const { topLeft, topRight, bottomLeft, bottomRight } = corners; + // Create a closed path in WGS84 projection around the tile bounds + // + // The tile has a `bbox` field which is already the bounding box in WGS84, + // but that uses `transformBounds` and densifies edges. So the corners of + // the bounding boxes don't line up with each other. + // + // In this case in the debug mode, it looks better if we ignore the actual + // non-linearities of the edges and just draw a box connecting the + // reprojected corners. In any case, the _image itself_ will be densified + // on the edges as a feature of the mesh generation. + const { topLeft, topRight, bottomRight, bottomLeft } = projectedCorners; + const topLeftWgs84 = forwardTo4326(topLeft[0], topLeft[1]); + const topRightWgs84 = forwardTo4326(topRight[0], topRight[1]); + const bottomRightWgs84 = forwardTo4326(bottomRight[0], bottomRight[1]); + const bottomLeftWgs84 = forwardTo4326(bottomLeft[0], bottomLeft[1]); + const path = [ - topLeft, - topRight, - bottomRight, - bottomLeft, - topLeft, // Close the path + topLeftWgs84, + topRightWgs84, + bottomRightWgs84, + bottomLeftWgs84, + topLeftWgs84, ]; layers.push( From 1070405c9a18a29d6afa24190c01a47921415f23 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 19 Mar 2026 13:57:35 -0400 Subject: [PATCH 8/9] cleaner --- .../src/raster-tileset/raster-tileset-2d.ts | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts index 13e38172..48aa9ffe 100644 --- a/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts +++ b/packages/deck.gl-raster/src/raster-tileset/raster-tileset-2d.ts @@ -229,22 +229,20 @@ export class TileMatrixSetTileset extends Tileset2D { this.projectTo4326, ...projectedBounds, ); - const bbox = { - west, - south, - east, - north, - }; - const projectedBbox: ProjectedBoundingBox = { - left: projectedBounds[0], - bottom: projectedBounds[1], - right: projectedBounds[2], - top: projectedBounds[3], - }; return { - bbox, - projectedBbox, + bbox: { + west, + south, + east, + north, + }, + projectedBbox: { + left: projectedBounds[0], + bottom: projectedBounds[1], + right: projectedBounds[2], + top: projectedBounds[3], + }, projectedCorners, tileWidth, tileHeight, From 5194e7a7b198e882b6ed7947845cf3fda8cb1e14 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Thu, 19 Mar 2026 14:04:22 -0400 Subject: [PATCH 9/9] clean up tests --- .../tests/tileset-refinement.test.ts | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/packages/deck.gl-raster/tests/tileset-refinement.test.ts b/packages/deck.gl-raster/tests/tileset-refinement.test.ts index 16bfc43e..c62a3896 100644 --- a/packages/deck.gl-raster/tests/tileset-refinement.test.ts +++ b/packages/deck.gl-raster/tests/tileset-refinement.test.ts @@ -7,6 +7,7 @@ import type { Viewport } from "@deck.gl/core"; import type { _Tileset2DProps as Tileset2DProps } from "@deck.gl/geo-layers"; +import type { TileMatrixSet } from "@developmentseed/morecantile"; import { describe, expect, it } from "vitest"; import { TileMatrixSetTileset } from "../src/raster-tileset/raster-tileset-2d.js"; import type { TileIndex } from "../src/raster-tileset/types.js"; @@ -20,12 +21,12 @@ import type { TileIndex } from "../src/raster-tileset/types.js"; // // cellSize ratio is 2 (standard power-of-2 pyramid). -const MOCK_TMS = { +const MOCK_TMS: TileMatrixSet = { id: "test", crs: { uri: "http://www.opengis.net/def/crs/EPSG/0/4326" }, boundingBox: { - lowerLeft: [0, 0] as [number, number], - upperRight: [1, 1] as [number, number], + lowerLeft: [0, 0], + upperRight: [1, 1], }, tileMatrices: [ { @@ -33,7 +34,7 @@ const MOCK_TMS = { scaleDenominator: 1000, cellSize: 0.02, cornerOfOrigin: "topLeft" as const, - pointOfOrigin: [0, 1] as [number, number], + pointOfOrigin: [0, 1], tileWidth: 64, tileHeight: 64, matrixWidth: 1, @@ -44,7 +45,7 @@ const MOCK_TMS = { scaleDenominator: 500, cellSize: 0.01, cornerOfOrigin: "topLeft" as const, - pointOfOrigin: [0, 1] as [number, number], + pointOfOrigin: [0, 1], tileWidth: 64, tileHeight: 64, matrixWidth: 2, @@ -100,7 +101,7 @@ function makeTileset(opts?: Partial): ControlledTileset { getTileData: () => new Promise(() => {}), // never resolves ...opts, }, - MOCK_TMS as any, + MOCK_TMS, { projectTo4326: identity, projectTo3857: identity }, ); } @@ -121,11 +122,15 @@ describe("TileMatrixSetTileset – best-available refinement", () => { const parentTile = tileset.tiles.find( (t) => t.index.x === 0 && t.index.y === 0 && t.index.z === 0, ); - expect(parentTile, "parent tile should be in cache").toBeDefined(); + + if (!parentTile) { + expect.fail("parent tile should be in cache"); + } // Simulate the tile loading successfully by injecting content. - (parentTile as any).content = { width: 64, height: 64 }; - (parentTile as any)._isLoaded = true; + parentTile.content = { width: 64, height: 64 }; + // @ts-expect-error _isLoaded is private + parentTile._isLoaded = true; // Run another update so the tileset sees the loaded state. tileset.setForcedIndices([{ x: 0, y: 0, z: 0 }]); @@ -175,8 +180,9 @@ describe("TileMatrixSetTileset – best-available refinement", () => { tileset.update(makeViewport(), { zRange: null, modelMatrix: null }); const parentTile = tileset.tiles.find((t) => t.index.z === 0)!; - (parentTile as any).content = { width: 64, height: 64 }; - (parentTile as any)._isLoaded = true; + parentTile.content = { width: 64, height: 64 }; + // @ts-expect-error _isLoaded is private + parentTile._isLoaded = true; // Zoom in tileset.setForcedIndices([ @@ -190,8 +196,9 @@ describe("TileMatrixSetTileset – best-available refinement", () => { // Load all children const childTiles = tileset.tiles.filter((t) => t.index.z === 1); for (const child of childTiles) { - (child as any).content = { width: 64, height: 64 }; - (child as any)._isLoaded = true; + child.content = { width: 64, height: 64 }; + // @ts-expect-error _isLoaded is private + child._isLoaded = true; } tileset.update(makeViewport(), { zRange: null, modelMatrix: null }); @@ -230,12 +237,12 @@ describe("TileMatrixSetTileset – best-available refinement", () => { // Sentinel-2-like TMS: last overview doubles tileWidth while halving cellSize, // so parent and child tiles cover the exact same spatial footprint. // decimation should be 1 (each child maps to the parent at the same x,y). - const sentinel2TMS = { + const sentinel2TMS: TileMatrixSet = { id: "s2", crs: { uri: "http://www.opengis.net/def/crs/EPSG/0/32618" }, boundingBox: { - lowerLeft: [499980, 4490220] as [number, number], - upperRight: [609780, 4600020] as [number, number], + lowerLeft: [499980, 4490220], + upperRight: [609780, 4600020], }, tileMatrices: [ // z=3: cellSize=20, tileWidth=512 → footprint = 10240m @@ -244,7 +251,7 @@ describe("TileMatrixSetTileset – best-available refinement", () => { scaleDenominator: 71428.57, cellSize: 20, cornerOfOrigin: "topLeft" as const, - pointOfOrigin: [499980, 4600020] as [number, number], + pointOfOrigin: [499980, 4600020], tileWidth: 512, tileHeight: 512, matrixWidth: 11, @@ -256,7 +263,7 @@ describe("TileMatrixSetTileset – best-available refinement", () => { scaleDenominator: 35714.29, cellSize: 10, cornerOfOrigin: "topLeft" as const, - pointOfOrigin: [499980, 4600020] as [number, number], + pointOfOrigin: [499980, 4600020], tileWidth: 1024, tileHeight: 1024, matrixWidth: 11, @@ -295,7 +302,7 @@ describe("TileMatrixSetTileset – best-available refinement", () => { const meta = tileset.getTileMetadata({ x: 0, y: 0, z: 0 }); expect(meta.bbox).toBeDefined(); - const { bbox } = meta as any; + const { bbox } = meta; expect(typeof bbox.west).toBe("number"); expect(typeof bbox.south).toBe("number"); expect(typeof bbox.east).toBe("number");