From 0f685ad349f8aad36ac1de9662aec94ea3bbdf7e Mon Sep 17 00:00:00 2001 From: Shane Grigsby Date: Wed, 25 Feb 2026 09:55:26 -0800 Subject: [PATCH 1/7] Add baseline tests for tile traversal code Expand __TEST_EXPORTS to expose helper functions (getOverlappingChildRange, getMetersPerPixel, rescaleEPSG3857ToCommonSpace, sampleReferencePointsInEPSG3857) and add 21 tests covering: - computeProjectedTileBounds for WebMercatorQuad and UTM31 - rescaleEPSG3857ToCommonSpace coordinate mapping and clamping - sampleReferencePointsInEPSG3857 interpolation - getOverlappingChildRange parent-child tile relationships - getMetersPerPixel zoom/latitude behavior - RasterTileNode.insideBounds AABB overlap logic - RasterTileNode.getBoundingVolume OBB computation (Mercator path) - RasterTileNode.children quadtree traversal Establishes regression baseline before globe view changes. --- .../raster-tileset/raster-tile-traversal.ts | 5 + .../tests/raster-tile-traversal.test.ts | 340 ++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 packages/deck.gl-raster/tests/raster-tile-traversal.test.ts diff --git a/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts b/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts index fbcbe026..38239b2f 100644 --- a/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts +++ b/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts @@ -780,5 +780,10 @@ function getMetersPerPixelAtBoundingVolume( */ export const __TEST_EXPORTS = { computeProjectedTileBounds, + getOverlappingChildRange, + getMetersPerPixel, + getMetersPerPixelAtBoundingVolume, + rescaleEPSG3857ToCommonSpace, + sampleReferencePointsInEPSG3857, RasterTileNode, }; diff --git a/packages/deck.gl-raster/tests/raster-tile-traversal.test.ts b/packages/deck.gl-raster/tests/raster-tile-traversal.test.ts new file mode 100644 index 00000000..96149c4e --- /dev/null +++ b/packages/deck.gl-raster/tests/raster-tile-traversal.test.ts @@ -0,0 +1,340 @@ +import { describe, expect, it } from "vitest"; +import _WebMercator from "../../morecantile/spec/schemas/tms/2.0/json/examples/tilematrixset/WebMercatorQuad.json"; +import _UTM31 from "../../morecantile/spec/schemas/tms/2.0/json/examples/tilematrixset/UTM31WGS84Quad.json"; +import type { TileMatrix, TileMatrixSet } from "../../morecantile/src/types/index"; +import { __TEST_EXPORTS, getTileIndices } from "../src/raster-tileset/raster-tile-traversal"; +import type { ProjectionFunction } from "../src/raster-tileset/types"; + +const { + computeProjectedTileBounds, + getOverlappingChildRange, + getMetersPerPixel, + rescaleEPSG3857ToCommonSpace, + sampleReferencePointsInEPSG3857, + RasterTileNode, +} = __TEST_EXPORTS; + +const WebMercator = _WebMercator as TileMatrixSet; +const UTM31 = _UTM31 as TileMatrixSet; + +function findMatrix(tms: TileMatrixSet, id: string): TileMatrix { + const m = tms.tileMatrices.find((m) => m.id === id); + if (!m) throw new Error(`no matrix with id "${id}"`); + return m; +} + +// --------------------------------------------------------------------------- +// computeProjectedTileBounds +// --------------------------------------------------------------------------- +describe("computeProjectedTileBounds", () => { + it("returns correct bounds for WebMercatorQuad zoom 0 tile (0,0)", () => { + const matrix = findMatrix(WebMercator, "0"); + const bounds = computeProjectedTileBounds(matrix, { x: 0, y: 0 }); + // WebMercatorQuad zoom 0 has one tile covering the entire world + // EPSG:3857 full extent: ~[-20037508, -20037508, 20037508, 20037508] + const halfCirc = Math.PI * 6378137; + expect(bounds[0]).toBeCloseTo(-halfCirc, 0); + expect(bounds[1]).toBeCloseTo(-halfCirc, 0); + expect(bounds[2]).toBeCloseTo(halfCirc, 0); + expect(bounds[3]).toBeCloseTo(halfCirc, 0); + }); + + it("returns correct bounds for WebMercatorQuad zoom 1 tile (0,0)", () => { + const matrix = findMatrix(WebMercator, "1"); + const bounds = computeProjectedTileBounds(matrix, { x: 0, y: 0 }); + const halfCirc = Math.PI * 6378137; + // Top-left quadrant: [-halfCirc, 0, 0, halfCirc] + expect(bounds[0]).toBeCloseTo(-halfCirc, 0); + expect(bounds[1]).toBeCloseTo(0, 0); + expect(bounds[2]).toBeCloseTo(0, 0); + expect(bounds[3]).toBeCloseTo(halfCirc, 0); + }); + + it("returns correct bounds for UTM31 tile", () => { + // UTM31 matrix IDs start at "1", not "0" + const matrix = findMatrix(UTM31, "1"); + const bounds = computeProjectedTileBounds(matrix, { x: 0, y: 0 }); + // UTM31 should have bounds in meters, origin around (166021, ~9329005) + // Just verify it returns 4 finite numbers with min < max + expect(bounds).toHaveLength(4); + expect(bounds[0]).toBeLessThan(bounds[2]); // minX < maxX + expect(bounds[1]).toBeLessThan(bounds[3]); // minY < maxY + expect(Number.isFinite(bounds[0])).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// rescaleEPSG3857ToCommonSpace +// --------------------------------------------------------------------------- +describe("rescaleEPSG3857ToCommonSpace", () => { + it("maps origin (0,0) in EPSG:3857 to center (256,256) in common space", () => { + const [x, y] = rescaleEPSG3857ToCommonSpace([0, 0]); + expect(x).toBeCloseTo(256, 5); + expect(y).toBeCloseTo(256, 5); + }); + + it("maps EPSG:3857 full extent to [0,512] range", () => { + const halfCirc = Math.PI * 6378137; + const [xMin, yMin] = rescaleEPSG3857ToCommonSpace([-halfCirc, -halfCirc]); + const [xMax, yMax] = rescaleEPSG3857ToCommonSpace([halfCirc, halfCirc]); + expect(xMin).toBeCloseTo(0, 5); + expect(yMin).toBeCloseTo(0, 5); + expect(xMax).toBeCloseTo(512, 5); + expect(yMax).toBeCloseTo(512, 5); + }); + + it("clamps Y values beyond Web Mercator bounds", () => { + const halfCirc = Math.PI * 6378137; + const beyondBounds = halfCirc * 2; + const [, yBeyond] = rescaleEPSG3857ToCommonSpace([0, beyondBounds]); + const [, yMax] = rescaleEPSG3857ToCommonSpace([0, halfCirc]); + // Should be clamped to the same value as halfCirc + expect(yBeyond).toBeCloseTo(yMax, 5); + }); +}); + +// --------------------------------------------------------------------------- +// sampleReferencePointsInEPSG3857 +// --------------------------------------------------------------------------- +describe("sampleReferencePointsInEPSG3857", () => { + it("identity projection returns input coordinates unchanged", () => { + const identity: ProjectionFunction = (x, y) => [x, y]; + const tileBounds: [number, number, number, number] = [100, 200, 300, 400]; + const refPoints: [number, number][] = [ + [0, 0], // lower-left corner + [1, 1], // upper-right corner + [0.5, 0.5], // center + ]; + const result = sampleReferencePointsInEPSG3857( + refPoints, + tileBounds, + identity, + ); + expect(result).toHaveLength(3); + // [0,0] → (100, 200) + expect(result[0]![0]).toBeCloseTo(100, 5); + expect(result[0]![1]).toBeCloseTo(200, 5); + // [1,1] → (300, 400) + expect(result[1]![0]).toBeCloseTo(300, 5); + expect(result[1]![1]).toBeCloseTo(400, 5); + // [0.5,0.5] → (200, 300) + expect(result[2]![0]).toBeCloseTo(200, 5); + expect(result[2]![1]).toBeCloseTo(300, 5); + }); +}); + +// --------------------------------------------------------------------------- +// getOverlappingChildRange +// --------------------------------------------------------------------------- +describe("getOverlappingChildRange", () => { + it("quadtree-like refinement: parent (0,0,z=0) covers 4 children", () => { + // WebMercatorQuad: z=0 is 1x1 tile, z=1 is 2x2 tiles + const parentMatrix = findMatrix(WebMercator, "0"); + const childMatrix = findMatrix(WebMercator, "1"); + const parentBounds = computeProjectedTileBounds(parentMatrix, { + x: 0, + y: 0, + }); + const range = getOverlappingChildRange(parentBounds, childMatrix); + expect(range.minCol).toBe(0); + expect(range.maxCol).toBe(1); + expect(range.minRow).toBe(0); + expect(range.maxRow).toBe(1); + }); + + it("quadtree-like refinement: z=1 tile (0,0) maps to z=2 quadrant", () => { + const parentMatrix = findMatrix(WebMercator, "1"); + const childMatrix = findMatrix(WebMercator, "2"); + const parentBounds = computeProjectedTileBounds(parentMatrix, { + x: 0, + y: 0, + }); + const range = getOverlappingChildRange(parentBounds, childMatrix); + expect(range.minCol).toBe(0); + // maxCol is 2 (not 1) because the parent boundary lands exactly on the + // child tile boundary, and Math.floor maps that to index 2 + expect(range.maxCol).toBe(2); + expect(range.minRow).toBe(0); + expect(range.maxRow).toBe(2); + }); + + it("z=1 tile (1,1) maps to z=2 lower-right quadrant", () => { + const parentMatrix = findMatrix(WebMercator, "1"); + const childMatrix = findMatrix(WebMercator, "2"); + const parentBounds = computeProjectedTileBounds(parentMatrix, { + x: 1, + y: 1, + }); + const range = getOverlappingChildRange(parentBounds, childMatrix); + expect(range.minCol).toBe(2); + expect(range.maxCol).toBe(3); + expect(range.minRow).toBe(2); + expect(range.maxRow).toBe(3); + }); +}); + +// --------------------------------------------------------------------------- +// getMetersPerPixel +// --------------------------------------------------------------------------- +describe("getMetersPerPixel", () => { + it("returns expected value at equator zoom 0", () => { + const earthCircumference = 40075016.686; + const expected = earthCircumference / 2 ** 8; // zoom 0, 2^(0+8) = 256 + const result = getMetersPerPixel(0, 0); + expect(result).toBeCloseTo(expected, 1); + }); + + it("decreases with increasing zoom", () => { + const z0 = getMetersPerPixel(0, 0); + const z1 = getMetersPerPixel(0, 1); + const z10 = getMetersPerPixel(0, 10); + expect(z0).toBeGreaterThan(z1); + expect(z1).toBeGreaterThan(z10); + // Each zoom level halves the meters per pixel + expect(z0 / z1).toBeCloseTo(2, 5); + }); + + it("decreases with increasing latitude (toward poles)", () => { + const equator = getMetersPerPixel(0, 5); + const lat60 = getMetersPerPixel(60, 5); + const lat80 = getMetersPerPixel(80, 5); + expect(equator).toBeGreaterThan(lat60); + expect(lat60).toBeGreaterThan(lat80); + // At 60° latitude, meters per pixel should be ~half of equator + expect(lat60 / equator).toBeCloseTo(0.5, 1); + }); +}); + +// --------------------------------------------------------------------------- +// RasterTileNode — insideBounds +// --------------------------------------------------------------------------- +describe("RasterTileNode.insideBounds", () => { + const identity: ProjectionFunction = (x, y) => [x, y]; + function makeNode(x: number, y: number, z: number): InstanceType { + return new RasterTileNode(x, y, z, { + metadata: WebMercator, + projectTo3857: identity, + }); + } + + it("returns true for overlapping bounds", () => { + const node = makeNode(0, 0, 0); + const bounds = [0, 0, 300, 300] as [number, number, number, number]; + const commonSpaceBounds = [100, 100, 400, 400] as [number, number, number, number]; + expect(node.insideBounds(bounds, commonSpaceBounds)).toBe(true); + }); + + it("returns false for non-overlapping bounds", () => { + const node = makeNode(0, 0, 0); + const bounds = [0, 0, 50, 50] as [number, number, number, number]; + const commonSpaceBounds = [100, 100, 400, 400] as [number, number, number, number]; + expect(node.insideBounds(bounds, commonSpaceBounds)).toBe(false); + }); + + it("returns true for touching bounds (edge overlap)", () => { + const node = makeNode(0, 0, 0); + // Bounds touch at x=100: bounds goes up to 100, tile starts at 99 + const bounds = [0, 0, 100, 100] as [number, number, number, number]; + const commonSpaceBounds = [99, 0, 200, 200] as [number, number, number, number]; + expect(node.insideBounds(bounds, commonSpaceBounds)).toBe(true); + }); + + it("returns false for bounds that touch at exactly one edge (not overlapping)", () => { + const node = makeNode(0, 0, 0); + // tile starts exactly where bounds end — no overlap (< not <=) + const bounds = [0, 0, 100, 100] as [number, number, number, number]; + const commonSpaceBounds = [100, 0, 200, 200] as [number, number, number, number]; + expect(node.insideBounds(bounds, commonSpaceBounds)).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// RasterTileNode — getBoundingVolume (Mercator path) +// --------------------------------------------------------------------------- +describe("RasterTileNode.getBoundingVolume (Mercator)", () => { + it("computes a bounding volume for WebMercatorQuad zoom 0", () => { + // Use identity projection (pretend tile CRS is already EPSG:3857) + const identity: ProjectionFunction = (x, y) => [x, y]; + const node = new RasterTileNode(0, 0, 0, { + metadata: WebMercator, + projectTo3857: identity, + }); + + const zRange: [number, number] = [0, 0]; + const { boundingVolume, commonSpaceBounds } = node.getBoundingVolume( + zRange, + null, + ); + + // Should have a valid OrientedBoundingBox + expect(boundingVolume).toBeDefined(); + expect(boundingVolume.center).toBeDefined(); + expect(boundingVolume.halfAxes).toBeDefined(); + + // Common space bounds should span most of [0, 512] + const [minX, minY, maxX, maxY] = commonSpaceBounds; + expect(maxX - minX).toBeGreaterThan(400); // Should be ~512 wide + expect(maxY - minY).toBeGreaterThan(400); // Should be ~512 tall + }); + + it("z=1 tiles have smaller bounding volumes than z=0", () => { + const identity: ProjectionFunction = (x, y) => [x, y]; + + const nodeZ0 = new RasterTileNode(0, 0, 0, { + metadata: WebMercator, + projectTo3857: identity, + }); + const nodeZ1 = new RasterTileNode(0, 0, 1, { + metadata: WebMercator, + projectTo3857: identity, + }); + + const zRange: [number, number] = [0, 0]; + const { commonSpaceBounds: csZ0 } = nodeZ0.getBoundingVolume(zRange, null); + const { commonSpaceBounds: csZ1 } = nodeZ1.getBoundingVolume(zRange, null); + + const widthZ0 = csZ0[2] - csZ0[0]; + const widthZ1 = csZ1[2] - csZ1[0]; + expect(widthZ0).toBeGreaterThan(widthZ1); + }); +}); + +// --------------------------------------------------------------------------- +// RasterTileNode — children +// --------------------------------------------------------------------------- +describe("RasterTileNode.children", () => { + it("WebMercatorQuad z=0 tile has 4 children at z=1", () => { + const identity: ProjectionFunction = (x, y) => [x, y]; + const node = new RasterTileNode(0, 0, 0, { + metadata: WebMercator, + projectTo3857: identity, + }); + + const children = node.children; + expect(children).not.toBeNull(); + expect(children).toHaveLength(4); + + // Children should be at z=1 + for (const child of children!) { + expect(child.z).toBe(1); + } + + // Should cover all 4 quadrants + const coords = children!.map((c) => [c.x, c.y]); + expect(coords).toContainEqual([0, 0]); + expect(coords).toContainEqual([1, 0]); + expect(coords).toContainEqual([0, 1]); + expect(coords).toContainEqual([1, 1]); + }); + + it("finest zoom level has no children", () => { + const identity: ProjectionFunction = (x, y) => [x, y]; + const maxZ = WebMercator.tileMatrices.length - 1; + const node = new RasterTileNode(0, 0, maxZ, { + metadata: WebMercator, + projectTo3857: identity, + }); + + expect(node.children).toBeNull(); + }); +}); From a70b4ef2aa90ef966c3d665e5b6ae35f3f262d9c Mon Sep 17 00:00:00 2001 From: Shane Grigsby Date: Wed, 25 Feb 2026 09:59:54 -0800 Subject: [PATCH 2/7] Implement globe view support for tile traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the assert(false, "TODO") blocker in getBoundingVolume() and implement full globe view support in the tile traversal code: - Thread projectTo4326 through TileMatrixSetTileset → getTileIndices() → RasterTileNode (alongside existing projectTo3857) - Add _getGlobeBoundingVolume() which samples reference points in WGS84 and projects them into globe common space via viewport.projectPosition - Add sampleReferencePointsInWgs84() helper (parallel to the existing sampleReferencePointsInEPSG3857) - Return centerLatitude from getBoundingVolume() so LOD computation works in both Mercator and Globe views without calling worldToLngLat() - Handle globe-mode bounds conversion in getTileIndices() by projecting WGS84 bounds corners through the globe project function instead of lngLatToWorld() - Remove unused assert import Closes #82 --- .../raster-tileset/raster-tile-traversal.ts | 182 +++++++++++++++--- .../src/raster-tileset/raster-tileset-2d.ts | 3 + .../tests/raster-tile-traversal.test.ts | 119 ++++++++++++ 3 files changed, 278 insertions(+), 26 deletions(-) diff --git a/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts b/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts index 38239b2f..214f944d 100644 --- a/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts +++ b/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts @@ -16,7 +16,7 @@ */ import type { Viewport } from "@deck.gl/core"; -import { _GlobeViewport, assert } from "@deck.gl/core"; +import { _GlobeViewport } from "@deck.gl/core"; import type { TileMatrix, TileMatrixSet } from "@developmentseed/morecantile"; import { xy_bounds } from "@developmentseed/morecantile"; import type { OrientedBoundingBox } from "@math.gl/culling"; @@ -141,6 +141,7 @@ export class RasterTileNode { private _children?: RasterTileNode[] | null; private projectTo3857: ProjectionFunction; + private projectTo4326: ProjectionFunction; constructor( x: number, @@ -149,13 +150,19 @@ export class RasterTileNode { { metadata, projectTo3857, - }: { metadata: TileMatrixSet; projectTo3857: ProjectionFunction }, + projectTo4326, + }: { + metadata: TileMatrixSet; + projectTo3857: ProjectionFunction; + projectTo4326: ProjectionFunction; + }, ) { this.x = x; this.y = y; this.z = z; this.metadata = metadata; this.projectTo3857 = projectTo3857; + this.projectTo4326 = projectTo4326; } /** Get overview info for this tile's z level */ @@ -198,13 +205,14 @@ export class RasterTileNode { const children: RasterTileNode[] = []; - const { metadata, projectTo3857 } = this; + const { metadata, projectTo3857, projectTo4326 } = this; for (let y = minRow; y <= maxRow; y++) { for (let x = minCol; x <= maxCol; x++) { children.push( new RasterTileNode(x, y, childZ, { metadata, projectTo3857, + projectTo4326, }), ); } @@ -263,10 +271,8 @@ export class RasterTileNode { } = params; // Get bounding volume for this tile - const { boundingVolume, commonSpaceBounds } = this.getBoundingVolume( - elevationBounds, - project, - ); + const { boundingVolume, commonSpaceBounds, centerLatitude } = + this.getBoundingVolume(elevationBounds, project); // Step 1: Bounds checking // If geographic bounds are specified, reject tiles outside those bounds @@ -288,8 +294,8 @@ export class RasterTileNode { // Only select this tile if no child is visible (prevents overlapping tiles) // “When pitch is low, force selection at maxZ.” if (!this.childVisible && this.z >= minZ) { - const metersPerScreenPixel = getMetersPerPixelAtBoundingVolume( - boundingVolume, + const metersPerScreenPixel = getMetersPerPixel( + centerLatitude, viewport.zoom, ); // console.log("metersPerScreenPixel", metersPerScreenPixel); @@ -396,13 +402,15 @@ export class RasterTileNode { getBoundingVolume( zRange: ZRange, project: ((xyz: number[]) => number[]) | null, - ): { boundingVolume: OrientedBoundingBox; commonSpaceBounds: Bounds } { - // Case 1: Globe view - need to construct an oriented bounding box from - // reprojected sample points, but also using the `project` param + ): { + boundingVolume: OrientedBoundingBox; + commonSpaceBounds: Bounds; + centerLatitude: number; + } { + // Case 1: Globe view - construct an oriented bounding box from sample + // points projected into globe common space via viewport.projectPosition if (project) { - assert(false, "TODO: implement getBoundingVolume in Globe view"); - // Reproject positions to wgs84 instead, then pass them into `project` - // return makeOrientedBoundingBoxFromPoints(refPointPositions); + return this._getGlobeBoundingVolume(zRange, project); } // (Future) Case 2: Web Mercator input image, can directly compute AABB in @@ -425,6 +433,7 @@ export class RasterTileNode { private _getGenericBoundingVolume(zRange: ZRange): { boundingVolume: OrientedBoundingBox; commonSpaceBounds: Bounds; + centerLatitude: number; } { const tileMatrix = this.tileMatrix; const [minZ, maxZ] = zRange; @@ -469,9 +478,82 @@ export class RasterTileNode { } const commonSpaceBounds: Bounds = [minX, minY, maxX, maxY]; + const boundingVolume = + makeOrientedBoundingBoxFromPoints(refPointPositions); + const [, centerLatitude] = worldToLngLat(boundingVolume.center); + + return { + boundingVolume, + commonSpaceBounds, + centerLatitude, + }; + } + + /** + * Globe view bounding volume. + * + * Sample reference points, reproject to WGS84, then project into globe + * common space via viewport.projectPosition. + */ + private _getGlobeBoundingVolume( + zRange: ZRange, + project: (xyz: number[]) => number[], + ): { + boundingVolume: OrientedBoundingBox; + commonSpaceBounds: Bounds; + centerLatitude: number; + } { + const tileMatrix = this.tileMatrix; + const [minZ, maxZ] = zRange; + + const tileCrsBounds = computeProjectedTileBounds(tileMatrix, { + x: this.x, + y: this.y, + }); + + // Sample reference points in WGS84 (not EPSG:3857) + const refPointsWgs84 = sampleReferencePointsInWgs84( + REF_POINTS_9, + tileCrsBounds, + this.projectTo4326, + ); + + // Project WGS84 points into globe common space via viewport.projectPosition + const refPointPositions: [number, number, number][] = []; + let csMinX = Number.POSITIVE_INFINITY; + let csMinY = Number.POSITIVE_INFINITY; + let csMaxX = Number.NEGATIVE_INFINITY; + let csMaxY = Number.NEGATIVE_INFINITY; + let latSum = 0; + + for (const [lng, lat] of refPointsWgs84) { + latSum += lat; + const posMin = project([lng, lat, minZ]); + refPointPositions.push([posMin[0]!, posMin[1]!, posMin[2]!]); + + if (posMin[0]! < csMinX) csMinX = posMin[0]!; + if (posMin[1]! < csMinY) csMinY = posMin[1]!; + if (posMin[0]! > csMaxX) csMaxX = posMin[0]!; + if (posMin[1]! > csMaxY) csMaxY = posMin[1]!; + + if (minZ !== maxZ) { + const posMax = project([lng, lat, maxZ]); + refPointPositions.push([posMax[0]!, posMax[1]!, posMax[2]!]); + + if (posMax[0]! < csMinX) csMinX = posMax[0]!; + if (posMax[1]! < csMinY) csMinY = posMax[1]!; + if (posMax[0]! > csMaxX) csMaxX = posMax[0]!; + if (posMax[1]! > csMaxY) csMaxY = posMax[1]!; + } + } + + const commonSpaceBounds: Bounds = [csMinX, csMinY, csMaxX, csMaxY]; + const centerLatitude = latSum / refPointsWgs84.length; + return { boundingVolume: makeOrientedBoundingBoxFromPoints(refPointPositions), commonSpaceBounds, + centerLatitude, }; } } @@ -534,6 +616,35 @@ function sampleReferencePointsInEPSG3857( return refPointPositions; } +/** + * Sample the selected reference points in WGS84 (EPSG:4326) + * + * Used for Globe view bounding volume computation where we need WGS84 + * coordinates instead of EPSG:3857. + * + * @param refPoints selected reference points. Each coordinate should be in [0-1] + * @param tileBounds the bounds of the tile in **tile CRS** [minX, minY, maxX, maxY] + * @param projectTo4326 projection function from tile CRS to WGS84 + */ +function sampleReferencePointsInWgs84( + refPoints: [number, number][], + tileBounds: [number, number, number, number], + projectTo4326: ProjectionFunction, +): [number, number][] { + const [minX, minY, maxX, maxY] = tileBounds; + const refPointPositions: [number, number][] = []; + + for (const [relX, relY] of refPoints) { + const geoX = minX + relX * (maxX - minX); + const geoY = minY + relY * (maxY - minY); + + const projected = projectTo4326(geoX, geoY); + refPointPositions.push(projected); + } + + return refPointPositions; +} + /** * Rescale positions from EPSG:3857 into deck.gl's common space * @@ -647,6 +758,7 @@ export function getTileIndices( maxZ: number; zRange: ZRange | null; projectTo3857: ProjectionFunction; + projectTo4326: ProjectionFunction; wgs84Bounds: CornerBounds; }, ): TileIndex[] { @@ -696,17 +808,33 @@ export function getTileIndices( // minZ to 0 const minZ = 0; - const { lowerLeft, upperRight } = wgs84Bounds; - const [minLng, minLat] = lowerLeft; - const [maxLng, maxLat] = upperRight; - const bottomLeft = lngLatToWorld([minLng, minLat]); - const topRight = lngLatToWorld([maxLng, maxLat]); - const bounds: Bounds = [ - bottomLeft[0], - bottomLeft[1], - topRight[0], - topRight[1], - ]; + // Convert WGS84 bounds to the appropriate common space for bounds filtering + let bounds: Bounds; + + if (project) { + // Globe view: project WGS84 bounds corners into globe common space + const { lowerLeft, upperRight } = wgs84Bounds; + const [minLng, minLat] = lowerLeft; + const [maxLng, maxLat] = upperRight; + const bl = project([minLng, minLat, 0]); + const tr = project([maxLng, maxLat, 0]); + const br = project([maxLng, minLat, 0]); + const tl = project([minLng, maxLat, 0]); + bounds = [ + Math.min(bl[0]!, tl[0]!, br[0]!, tr[0]!), + Math.min(bl[1]!, tl[1]!, br[1]!, tr[1]!), + Math.max(bl[0]!, tl[0]!, br[0]!, tr[0]!), + Math.max(bl[1]!, tl[1]!, br[1]!, tr[1]!), + ]; + } else { + // Mercator view: existing code + const { lowerLeft, upperRight } = wgs84Bounds; + const [minLng, minLat] = lowerLeft; + const [maxLng, maxLat] = upperRight; + const bottomLeft = lngLatToWorld([minLng, minLat]); + const topRight = lngLatToWorld([maxLng, maxLat]); + bounds = [bottomLeft[0], bottomLeft[1], topRight[0], topRight[1]]; + } // Start from coarsest overview const rootMatrix = metadata.tileMatrices[0]!; @@ -721,6 +849,7 @@ export function getTileIndices( new RasterTileNode(x, y, 0, { metadata, projectTo3857: opts.projectTo3857, + projectTo4326: opts.projectTo4326, }), ); } @@ -785,5 +914,6 @@ export const __TEST_EXPORTS = { getMetersPerPixelAtBoundingVolume, rescaleEPSG3857ToCommonSpace, sampleReferencePointsInEPSG3857, + sampleReferencePointsInWgs84, RasterTileNode, }; 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 8d3a3b61..66c7d85a 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 @@ -32,6 +32,7 @@ export class TileMatrixSetTileset extends Tileset2D { private tms: TileMatrixSet; private wgs84Bounds: CornerBounds; private projectTo3857: ProjectionFunction; + private projectTo4326: ProjectionFunction; constructor( opts: Tileset2DProps, @@ -47,6 +48,7 @@ export class TileMatrixSetTileset extends Tileset2D { super(opts); this.tms = tms; this.projectTo3857 = projectTo3857; + this.projectTo4326 = projectTo4326; if (!tms.boundingBox) { throw new Error( @@ -86,6 +88,7 @@ export class TileMatrixSetTileset extends Tileset2D { zRange: opts.zRange ?? null, wgs84Bounds: this.wgs84Bounds, projectTo3857: this.projectTo3857, + projectTo4326: this.projectTo4326, }); return tileIndices; diff --git a/packages/deck.gl-raster/tests/raster-tile-traversal.test.ts b/packages/deck.gl-raster/tests/raster-tile-traversal.test.ts index 96149c4e..789f1c24 100644 --- a/packages/deck.gl-raster/tests/raster-tile-traversal.test.ts +++ b/packages/deck.gl-raster/tests/raster-tile-traversal.test.ts @@ -11,6 +11,7 @@ const { getMetersPerPixel, rescaleEPSG3857ToCommonSpace, sampleReferencePointsInEPSG3857, + sampleReferencePointsInWgs84, RasterTileNode, } = __TEST_EXPORTS; @@ -214,6 +215,7 @@ describe("RasterTileNode.insideBounds", () => { return new RasterTileNode(x, y, z, { metadata: WebMercator, projectTo3857: identity, + projectTo4326: identity, }); } @@ -258,6 +260,7 @@ describe("RasterTileNode.getBoundingVolume (Mercator)", () => { const node = new RasterTileNode(0, 0, 0, { metadata: WebMercator, projectTo3857: identity, + projectTo4326: identity, }); const zRange: [number, number] = [0, 0]; @@ -283,10 +286,12 @@ describe("RasterTileNode.getBoundingVolume (Mercator)", () => { const nodeZ0 = new RasterTileNode(0, 0, 0, { metadata: WebMercator, projectTo3857: identity, + projectTo4326: identity, }); const nodeZ1 = new RasterTileNode(0, 0, 1, { metadata: WebMercator, projectTo3857: identity, + projectTo4326: identity, }); const zRange: [number, number] = [0, 0]; @@ -308,6 +313,7 @@ describe("RasterTileNode.children", () => { const node = new RasterTileNode(0, 0, 0, { metadata: WebMercator, projectTo3857: identity, + projectTo4326: identity, }); const children = node.children; @@ -333,8 +339,121 @@ describe("RasterTileNode.children", () => { const node = new RasterTileNode(0, 0, maxZ, { metadata: WebMercator, projectTo3857: identity, + projectTo4326: identity, }); expect(node.children).toBeNull(); }); }); + +// --------------------------------------------------------------------------- +// sampleReferencePointsInWgs84 +// --------------------------------------------------------------------------- +describe("sampleReferencePointsInWgs84", () => { + it("identity projection returns input coordinates unchanged", () => { + const identity: ProjectionFunction = (x, y) => [x, y]; + const tileBounds: [number, number, number, number] = [100, 200, 300, 400]; + const refPoints: [number, number][] = [ + [0, 0], + [1, 1], + [0.5, 0.5], + ]; + const result = sampleReferencePointsInWgs84( + refPoints, + tileBounds, + identity, + ); + expect(result).toHaveLength(3); + expect(result[0]![0]).toBeCloseTo(100, 5); + expect(result[0]![1]).toBeCloseTo(200, 5); + expect(result[1]![0]).toBeCloseTo(300, 5); + expect(result[1]![1]).toBeCloseTo(400, 5); + expect(result[2]![0]).toBeCloseTo(200, 5); + expect(result[2]![1]).toBeCloseTo(300, 5); + }); +}); + +// --------------------------------------------------------------------------- +// RasterTileNode — getBoundingVolume (Globe path) +// --------------------------------------------------------------------------- +describe("RasterTileNode.getBoundingVolume (Globe)", () => { + it("computes a bounding volume using the project function", () => { + const identity: ProjectionFunction = (x, y) => [x, y]; + + // Mock globe project function that maps [lng, lat, z] to 3D common space + // Simple sphere: x = cos(lat)*cos(lng), y = cos(lat)*sin(lng), z = sin(lat) + // But for testing, a simple linear transform is sufficient to verify + // the plumbing works + const mockProject = (xyz: number[]): number[] => { + return [xyz[0]! * 10, xyz[1]! * 10, xyz[2]! || 0]; + }; + + const node = new RasterTileNode(0, 0, 0, { + metadata: WebMercator, + projectTo3857: identity, + projectTo4326: identity, + }); + + const zRange: [number, number] = [0, 0]; + const { boundingVolume, commonSpaceBounds, centerLatitude } = + node.getBoundingVolume(zRange, mockProject); + + // Should have a valid OrientedBoundingBox + expect(boundingVolume).toBeDefined(); + expect(boundingVolume.center).toBeDefined(); + expect(boundingVolume.halfAxes).toBeDefined(); + + // Common space bounds should be defined + const [minX, minY, maxX, maxY] = commonSpaceBounds; + expect(Number.isFinite(minX)).toBe(true); + expect(Number.isFinite(minY)).toBe(true); + expect(maxX).toBeGreaterThan(minX); + expect(maxY).toBeGreaterThan(minY); + + // Center latitude should be finite + expect(Number.isFinite(centerLatitude)).toBe(true); + }); + + it("produces different bounding volumes than the Mercator path", () => { + const identity: ProjectionFunction = (x, y) => [x, y]; + const mockProject = (xyz: number[]): number[] => { + return [xyz[0]! * 5, xyz[1]! * 5, 0]; + }; + + const node = new RasterTileNode(0, 0, 0, { + metadata: WebMercator, + projectTo3857: identity, + projectTo4326: identity, + }); + + const zRange: [number, number] = [0, 0]; + const mercator = node.getBoundingVolume(zRange, null); + const globe = node.getBoundingVolume(zRange, mockProject); + + // The bounding volumes should differ because the common spaces differ + expect(globe.commonSpaceBounds[0]).not.toBeCloseTo( + mercator.commonSpaceBounds[0], + 1, + ); + }); + + it("returns centerLatitude from WGS84 reference points", () => { + // projectTo4326 that always returns lng=0, lat=45 + const mockTo4326: ProjectionFunction = (_x, _y) => [0, 45]; + const identity: ProjectionFunction = (x, y) => [x, y]; + const mockProject = (xyz: number[]): number[] => [ + xyz[0]!, + xyz[1]!, + xyz[2]! || 0, + ]; + + const node = new RasterTileNode(0, 0, 0, { + metadata: WebMercator, + projectTo3857: identity, + projectTo4326: mockTo4326, + }); + + const { centerLatitude } = node.getBoundingVolume([0, 0], mockProject); + expect(centerLatitude).toBeCloseTo(45, 5); + }); +}); From eec6854f51a736a1831a51f5c11503ce7ba5aad1 Mon Sep 17 00:00:00 2001 From: Shane Grigsby Date: Wed, 25 Feb 2026 10:48:46 -0800 Subject: [PATCH 3/7] Add globe-view example demonstrating COGLayer in GlobeView Provides a minimal example using deck.gl's GlobeView with COGLayer for visualizing COG imagery on a 3D globe. Includes a dark background sphere, debug mesh controls, and commented URLs for both mid-latitude and polar datasets. --- examples/globe-view/index.html | 22 +++++ examples/globe-view/package.json | 31 +++++++ examples/globe-view/src/App.tsx | 137 +++++++++++++++++++++++++++++ examples/globe-view/src/main.tsx | 9 ++ examples/globe-view/tsconfig.json | 24 +++++ examples/globe-view/vite.config.ts | 10 +++ 6 files changed, 233 insertions(+) create mode 100644 examples/globe-view/index.html create mode 100644 examples/globe-view/package.json create mode 100644 examples/globe-view/src/App.tsx create mode 100644 examples/globe-view/src/main.tsx create mode 100644 examples/globe-view/tsconfig.json create mode 100644 examples/globe-view/vite.config.ts diff --git a/examples/globe-view/index.html b/examples/globe-view/index.html new file mode 100644 index 00000000..3a402509 --- /dev/null +++ b/examples/globe-view/index.html @@ -0,0 +1,22 @@ + + + + + + COGLayer Globe View Example + + + +
+ + + diff --git a/examples/globe-view/package.json b/examples/globe-view/package.json new file mode 100644 index 00000000..3c4dbcd9 --- /dev/null +++ b/examples/globe-view/package.json @@ -0,0 +1,31 @@ +{ + "name": "deck.gl-cog-globe-example", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@deck.gl/core": "^9.2.7", + "@deck.gl/geo-layers": "^9.2.7", + "@deck.gl/layers": "^9.2.7", + "@deck.gl/mesh-layers": "^9.2.7", + "@deck.gl/react": "^9.2.7", + "@developmentseed/geotiff": "workspace:^", + "@developmentseed/deck.gl-geotiff": "workspace:^", + "@developmentseed/deck.gl-raster": "workspace:^", + "@luma.gl/core": "9.2.6", + "@luma.gl/shadertools": "9.2.6", + "proj4": "^2.20.2", + "react": "^19.2.4", + "react-dom": "^19.2.4" + }, + "devDependencies": { + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.2", + "vite": "^7.3.1" + } +} diff --git a/examples/globe-view/src/App.tsx b/examples/globe-view/src/App.tsx new file mode 100644 index 00000000..e615ffb3 --- /dev/null +++ b/examples/globe-view/src/App.tsx @@ -0,0 +1,137 @@ +import { _GlobeView as GlobeView } from "@deck.gl/core"; +import { DeckGL } from "@deck.gl/react"; +import { SolidPolygonLayer } from "@deck.gl/layers"; +import { COGLayer } from "@developmentseed/deck.gl-geotiff"; +import { useState } from "react"; + +// New Zealand imagery (NZTM2000 projection) +const COG_URL = + "https://nz-imagery.s3-ap-southeast-2.amazonaws.com/new-zealand/new-zealand_2024-2025_10m/rgb/2193/CC11.tiff"; + +// Antarctic sea ice (polar stereographic) +// const COG_URL = +// "https://data.source.coop/ausantarctic/ghrsst-mur-v2/2020/12/12/20201212090000-JPL-L4_GHRSST-SSTfnd-MUR-GLOB-v02.0-fv04.1_sea_ice_fraction.tif"; + +const INITIAL_VIEW_STATE = { + longitude: 170, + latitude: -42, + zoom: 3, +}; + +export default function App() { + const [debug, setDebug] = useState(false); + const [debugOpacity, setDebugOpacity] = useState(0.25); + + const layers = [ + // Dark background sphere + new SolidPolygonLayer({ + id: "background", + data: [ + [ + [-180, 90], + [0, 90], + [180, 90], + [180, -90], + [0, -90], + [-180, -90], + ], + ], + getPolygon: (d) => d, + stroked: false, + filled: true, + getFillColor: [10, 20, 40], + }), + new COGLayer({ + id: "cog-layer", + geotiff: COG_URL, + debug, + debugOpacity, + }), + ]; + + return ( +
+ + + {/* UI Controls */} +
+

+ COGLayer Globe View +

+

+ Displaying COG imagery on a 3D globe +

+ +
+ + + {debug && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/examples/globe-view/src/main.tsx b/examples/globe-view/src/main.tsx new file mode 100644 index 00000000..f8fc6f51 --- /dev/null +++ b/examples/globe-view/src/main.tsx @@ -0,0 +1,9 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/examples/globe-view/tsconfig.json b/examples/globe-view/tsconfig.json new file mode 100644 index 00000000..f0a23505 --- /dev/null +++ b/examples/globe-view/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/examples/globe-view/vite.config.ts b/examples/globe-view/vite.config.ts new file mode 100644 index 00000000..26bf8080 --- /dev/null +++ b/examples/globe-view/vite.config.ts @@ -0,0 +1,10 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + base: "/deck.gl-raster/examples/globe-view/", + server: { + port: 3001, + }, +}); From 45cdf4e223db379665eade1fa8c1ad8cbd1656ce Mon Sep 17 00:00:00 2001 From: Shane Grigsby Date: Thu, 26 Feb 2026 13:14:05 -0800 Subject: [PATCH 4/7] Fix lint and test issues for globe view --- examples/globe-view/src/App.tsx | 6 +- .../raster-tileset/raster-tile-traversal.ts | 3 +- .../tests/raster-tile-traversal.test.ts | 43 +++++++-- pnpm-lock.yaml | 87 +++++++++++++++++++ 4 files changed, 125 insertions(+), 14 deletions(-) diff --git a/examples/globe-view/src/App.tsx b/examples/globe-view/src/App.tsx index e615ffb3..aa934757 100644 --- a/examples/globe-view/src/App.tsx +++ b/examples/globe-view/src/App.tsx @@ -1,6 +1,6 @@ import { _GlobeView as GlobeView } from "@deck.gl/core"; -import { DeckGL } from "@deck.gl/react"; import { SolidPolygonLayer } from "@deck.gl/layers"; +import { DeckGL } from "@deck.gl/react"; import { COGLayer } from "@developmentseed/deck.gl-geotiff"; import { useState } from "react"; @@ -122,9 +122,7 @@ export default function App() { max="1" step="0.01" value={debugOpacity} - onChange={(e) => - setDebugOpacity(parseFloat(e.target.value)) - } + onChange={(e) => setDebugOpacity(parseFloat(e.target.value))} style={{ width: "100%", cursor: "pointer" }} /> diff --git a/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts b/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts index 214f944d..78b639dd 100644 --- a/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts +++ b/packages/deck.gl-raster/src/raster-tileset/raster-tile-traversal.ts @@ -478,8 +478,7 @@ export class RasterTileNode { } const commonSpaceBounds: Bounds = [minX, minY, maxX, maxY]; - const boundingVolume = - makeOrientedBoundingBoxFromPoints(refPointPositions); + const boundingVolume = makeOrientedBoundingBoxFromPoints(refPointPositions); const [, centerLatitude] = worldToLngLat(boundingVolume.center); return { diff --git a/packages/deck.gl-raster/tests/raster-tile-traversal.test.ts b/packages/deck.gl-raster/tests/raster-tile-traversal.test.ts index 789f1c24..5ace8aaa 100644 --- a/packages/deck.gl-raster/tests/raster-tile-traversal.test.ts +++ b/packages/deck.gl-raster/tests/raster-tile-traversal.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from "vitest"; -import _WebMercator from "../../morecantile/spec/schemas/tms/2.0/json/examples/tilematrixset/WebMercatorQuad.json"; import _UTM31 from "../../morecantile/spec/schemas/tms/2.0/json/examples/tilematrixset/UTM31WGS84Quad.json"; -import type { TileMatrix, TileMatrixSet } from "../../morecantile/src/types/index"; -import { __TEST_EXPORTS, getTileIndices } from "../src/raster-tileset/raster-tile-traversal"; +import _WebMercator from "../../morecantile/spec/schemas/tms/2.0/json/examples/tilematrixset/WebMercatorQuad.json"; +import type { + TileMatrix, + TileMatrixSet, +} from "../../morecantile/src/types/index"; +import { __TEST_EXPORTS } from "../src/raster-tileset/raster-tile-traversal"; import type { ProjectionFunction } from "../src/raster-tileset/types"; const { @@ -211,7 +214,11 @@ describe("getMetersPerPixel", () => { // --------------------------------------------------------------------------- describe("RasterTileNode.insideBounds", () => { const identity: ProjectionFunction = (x, y) => [x, y]; - function makeNode(x: number, y: number, z: number): InstanceType { + function makeNode( + x: number, + y: number, + z: number, + ): InstanceType { return new RasterTileNode(x, y, z, { metadata: WebMercator, projectTo3857: identity, @@ -222,14 +229,24 @@ describe("RasterTileNode.insideBounds", () => { it("returns true for overlapping bounds", () => { const node = makeNode(0, 0, 0); const bounds = [0, 0, 300, 300] as [number, number, number, number]; - const commonSpaceBounds = [100, 100, 400, 400] as [number, number, number, number]; + const commonSpaceBounds = [100, 100, 400, 400] as [ + number, + number, + number, + number, + ]; expect(node.insideBounds(bounds, commonSpaceBounds)).toBe(true); }); it("returns false for non-overlapping bounds", () => { const node = makeNode(0, 0, 0); const bounds = [0, 0, 50, 50] as [number, number, number, number]; - const commonSpaceBounds = [100, 100, 400, 400] as [number, number, number, number]; + const commonSpaceBounds = [100, 100, 400, 400] as [ + number, + number, + number, + number, + ]; expect(node.insideBounds(bounds, commonSpaceBounds)).toBe(false); }); @@ -237,7 +254,12 @@ describe("RasterTileNode.insideBounds", () => { const node = makeNode(0, 0, 0); // Bounds touch at x=100: bounds goes up to 100, tile starts at 99 const bounds = [0, 0, 100, 100] as [number, number, number, number]; - const commonSpaceBounds = [99, 0, 200, 200] as [number, number, number, number]; + const commonSpaceBounds = [99, 0, 200, 200] as [ + number, + number, + number, + number, + ]; expect(node.insideBounds(bounds, commonSpaceBounds)).toBe(true); }); @@ -245,7 +267,12 @@ describe("RasterTileNode.insideBounds", () => { const node = makeNode(0, 0, 0); // tile starts exactly where bounds end — no overlap (< not <=) const bounds = [0, 0, 100, 100] as [number, number, number, number]; - const commonSpaceBounds = [100, 0, 200, 200] as [number, number, number, number]; + const commonSpaceBounds = [100, 0, 200, 200] as [ + number, + number, + number, + number, + ]; expect(node.insideBounds(bounds, commonSpaceBounds)).toBe(false); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c62c2090..0ded96ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,6 +102,61 @@ importers: specifier: ^7.3.1 version: 7.3.1(@types/node@25.1.0)(tsx@4.21.0) + examples/globe-view: + dependencies: + '@deck.gl/core': + specifier: ^9.2.8 + version: 9.2.8 + '@deck.gl/geo-layers': + specifier: ^9.2.8 + version: 9.2.8(@deck.gl/core@9.2.8)(@deck.gl/extensions@9.2.5(@deck.gl/core@9.2.8)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))))(@deck.gl/layers@9.2.8(@deck.gl/core@9.2.8)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))))(@deck.gl/mesh-layers@9.2.8(@deck.gl/core@9.2.8)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/gltf@9.2.6(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@loaders.gl/core@4.3.4)(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))) + '@deck.gl/layers': + specifier: ^9.2.8 + version: 9.2.8(@deck.gl/core@9.2.8)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6))) + '@deck.gl/mesh-layers': + specifier: ^9.2.8 + version: 9.2.8(@deck.gl/core@9.2.8)(@loaders.gl/core@4.3.4)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/gltf@9.2.6(@luma.gl/constants@9.2.6)(@luma.gl/core@9.2.6)(@luma.gl/engine@9.2.6(@luma.gl/core@9.2.6)(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)))(@luma.gl/shadertools@9.2.6(@luma.gl/core@9.2.6)) + '@deck.gl/react': + specifier: ^9.2.7 + version: 9.2.9(@deck.gl/core@9.2.8)(@deck.gl/widgets@9.2.9(@deck.gl/core@9.2.8)(@luma.gl/core@9.2.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@developmentseed/deck.gl-geotiff': + specifier: workspace:^ + version: link:../../packages/deck.gl-geotiff + '@developmentseed/deck.gl-raster': + specifier: workspace:^ + version: link:../../packages/deck.gl-raster + '@developmentseed/geotiff': + specifier: workspace:^ + version: link:../../packages/geotiff + '@luma.gl/core': + specifier: ^9.2.6 + version: 9.2.6 + '@luma.gl/shadertools': + specifier: ^9.2.6 + version: 9.2.6(@luma.gl/core@9.2.6) + proj4: + specifier: ^2.20.2 + version: 2.20.2 + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + devDependencies: + '@types/react': + specifier: ^19.2.10 + version: 19.2.10 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.10) + '@vitejs/plugin-react': + specifier: ^5.1.2 + version: 5.1.2(vite@7.3.1(@types/node@25.1.0)(tsx@4.21.0)) + vite: + specifier: ^7.3.1 + version: 7.3.1(@types/node@25.1.0)(tsx@4.21.0) + examples/land-cover: dependencies: '@deck.gl/core': @@ -734,6 +789,20 @@ packages: '@luma.gl/gltf': ^9.2.6 '@luma.gl/shadertools': ^9.2.6 + '@deck.gl/react@9.2.9': + resolution: {integrity: sha512-ZADiRJhT8dI1z6NfC3cJ62j8nt4j/5AfUmXSHNn6hnOTJFJB1YScERnmM2lz9LYBwjlWTWEeTDIxQ66S4nClYg==} + peerDependencies: + '@deck.gl/core': ^9.2.8 + '@deck.gl/widgets': ^9.2.8 + react: '>=16.3.0' + react-dom: '>=16.3.0' + + '@deck.gl/widgets@9.2.9': + resolution: {integrity: sha512-dFIT1sJZ8gxZE7l+b62TY5LH/92ABcsukK7jH3L3T6PYlbFOnNCby6dkZRySYbhWPE77lykXXsx1V/NQQlqs8Q==} + peerDependencies: + '@deck.gl/core': ^9.2.8 + '@luma.gl/core': ^9.2.6 + '@developmentseed/lzw-tiff-decoder@0.2.2': resolution: {integrity: sha512-bsBIdV1LyqcrtnYtIYu3C/297X2wxeYkmmhHzR02OOX823sGxT8GLGdenTmwoXvNWkozk3b+ptXtJeSUmNPaTA==} @@ -2178,6 +2247,9 @@ packages: potpack@2.1.0: resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==} + preact@10.28.4: + resolution: {integrity: sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ==} + prettier@3.8.1: resolution: {integrity: sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==} engines: {node: '>=14'} @@ -2955,6 +3027,19 @@ snapshots: transitivePeerDependencies: - '@loaders.gl/core' + '@deck.gl/react@9.2.9(@deck.gl/core@9.2.8)(@deck.gl/widgets@9.2.9(@deck.gl/core@9.2.8)(@luma.gl/core@9.2.6))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@deck.gl/core': 9.2.8 + '@deck.gl/widgets': 9.2.9(@deck.gl/core@9.2.8)(@luma.gl/core@9.2.6) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + '@deck.gl/widgets@9.2.9(@deck.gl/core@9.2.8)(@luma.gl/core@9.2.6)': + dependencies: + '@deck.gl/core': 9.2.8 + '@luma.gl/core': 9.2.6 + preact: 10.28.4 + '@developmentseed/lzw-tiff-decoder@0.2.2': {} '@esbuild/aix-ppc64@0.27.2': @@ -4342,6 +4427,8 @@ snapshots: potpack@2.1.0: {} + preact@10.28.4: {} + prettier@3.8.1: {} process-nextick-args@2.0.1: {} From b6cbf4e25808b9332b6e89edb8c9c13a4b071442 Mon Sep 17 00:00:00 2001 From: Shane Grigsby Date: Wed, 25 Feb 2026 12:05:47 -0800 Subject: [PATCH 5/7] fixing global viewer to have context map --- examples/globe-view/package.json | 1 + examples/globe-view/src/App.tsx | 60 +++++++++++++++++++++++++++----- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/examples/globe-view/package.json b/examples/globe-view/package.json index 3c4dbcd9..3d75e7e8 100644 --- a/examples/globe-view/package.json +++ b/examples/globe-view/package.json @@ -18,6 +18,7 @@ "@developmentseed/deck.gl-raster": "workspace:^", "@luma.gl/core": "9.2.6", "@luma.gl/shadertools": "9.2.6", + "@luma.gl/webgl": "9.2.6", "proj4": "^2.20.2", "react": "^19.2.4", "react-dom": "^19.2.4" diff --git a/examples/globe-view/src/App.tsx b/examples/globe-view/src/App.tsx index aa934757..c2a2c11a 100644 --- a/examples/globe-view/src/App.tsx +++ b/examples/globe-view/src/App.tsx @@ -1,8 +1,14 @@ import { _GlobeView as GlobeView } from "@deck.gl/core"; -import { SolidPolygonLayer } from "@deck.gl/layers"; +import { GeoJsonLayer, SolidPolygonLayer } from "@deck.gl/layers"; import { DeckGL } from "@deck.gl/react"; import { COGLayer } from "@developmentseed/deck.gl-geotiff"; -import { useState } from "react"; +import { luma } from "@luma.gl/core"; +import { webgl2Adapter } from "@luma.gl/webgl"; +import { useCallback, useState } from "react"; + +// Register WebGL adapter — required when DeckGL creates its own context +// (unlike MapboxOverlay which reuses MaplibreGL's existing context) +luma.registerAdapters([webgl2Adapter]); // New Zealand imagery (NZTM2000 projection) const COG_URL = @@ -12,15 +18,37 @@ const COG_URL = // const COG_URL = // "https://data.source.coop/ausantarctic/ghrsst-mur-v2/2020/12/12/20201212090000-JPL-L4_GHRSST-SSTfnd-MUR-GLOB-v02.0-fv04.1_sea_ice_fraction.tif"; -const INITIAL_VIEW_STATE = { - longitude: 170, - latitude: -42, - zoom: 3, -}; - export default function App() { const [debug, setDebug] = useState(false); const [debugOpacity, setDebugOpacity] = useState(0.25); + const [viewState, setViewState] = useState({ + longitude: 0, + latitude: 0, + zoom: 1, + }); + + const onGeoTIFFLoad = useCallback( + ( + _tiff: unknown, + options: { + geographicBounds: { + west: number; + south: number; + east: number; + north: number; + }; + }, + ) => { + const { west, south, east, north } = options.geographicBounds; + console.log("onGeoTIFFLoad fired:", { west, south, east, north }); + setViewState({ + longitude: (west + east) / 2, + latitude: (south + north) / 2, + zoom: 3, + }); + }, + [], + ); const layers = [ // Dark background sphere @@ -41,11 +69,22 @@ export default function App() { filled: true, getFillColor: [10, 20, 40], }), + // Land masses basemap (Natural Earth via deck.gl CDN) + new GeoJsonLayer({ + id: "basemap", + data: "https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_50m_admin_0_scale_rank.geojson", + stroked: true, + filled: true, + lineWidthMinPixels: 1, + getLineColor: [40, 60, 90], + getFillColor: [25, 40, 70], + }), new COGLayer({ id: "cog-layer", geotiff: COG_URL, debug, debugOpacity, + onGeoTIFFLoad, }), ]; @@ -53,7 +92,10 @@ export default function App() {
+ setViewState(vs as typeof viewState) + } controller={true} layers={layers} /> From 57270b76b4930c2076666e8ac5e89747f025c6f6 Mon Sep 17 00:00:00 2001 From: Shane Grigsby Date: Wed, 25 Feb 2026 12:20:08 -0800 Subject: [PATCH 6/7] switching to calculated zoom instead of hard coded zoom level 3 --- examples/globe-view/src/App.tsx | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/examples/globe-view/src/App.tsx b/examples/globe-view/src/App.tsx index c2a2c11a..991b4f87 100644 --- a/examples/globe-view/src/App.tsx +++ b/examples/globe-view/src/App.tsx @@ -21,7 +21,10 @@ const COG_URL = export default function App() { const [debug, setDebug] = useState(false); const [debugOpacity, setDebugOpacity] = useState(0.25); - const [viewState, setViewState] = useState({ + + // Use initialViewState (uncontrolled) so deck.gl manages view state internally. + // Updating the object reference triggers deck.gl to transition to the new view. + const [initialViewState, setInitialViewState] = useState({ longitude: 0, latitude: 0, zoom: 1, @@ -40,11 +43,15 @@ export default function App() { }, ) => { const { west, south, east, north } = options.geographicBounds; - console.log("onGeoTIFFLoad fired:", { west, south, east, north }); - setViewState({ + const lonSpan = east - west; + const latSpan = north - south; + const maxSpan = Math.max(lonSpan, latSpan); + // At zoom N, ~360/2^N degrees are visible; subtract 1 for padding + const zoom = Math.log2(360 / maxSpan) - 1; + setInitialViewState({ longitude: (west + east) / 2, latitude: (south + north) / 2, - zoom: 3, + zoom, }); }, [], @@ -92,10 +99,7 @@ export default function App() {
- setViewState(vs as typeof viewState) - } + initialViewState={initialViewState} controller={true} layers={layers} /> From 3f49bc9d35b55740e9a4d57e397bfd0f875637c0 Mon Sep 17 00:00:00 2001 From: Shane Grigsby Date: Thu, 26 Feb 2026 13:14:54 -0800 Subject: [PATCH 7/7] Remove unnecessary @luma.gl/webgl adapter registration --- examples/globe-view/package.json | 1 - examples/globe-view/src/App.tsx | 6 ------ 2 files changed, 7 deletions(-) diff --git a/examples/globe-view/package.json b/examples/globe-view/package.json index 3d75e7e8..3c4dbcd9 100644 --- a/examples/globe-view/package.json +++ b/examples/globe-view/package.json @@ -18,7 +18,6 @@ "@developmentseed/deck.gl-raster": "workspace:^", "@luma.gl/core": "9.2.6", "@luma.gl/shadertools": "9.2.6", - "@luma.gl/webgl": "9.2.6", "proj4": "^2.20.2", "react": "^19.2.4", "react-dom": "^19.2.4" diff --git a/examples/globe-view/src/App.tsx b/examples/globe-view/src/App.tsx index 991b4f87..ec4630f3 100644 --- a/examples/globe-view/src/App.tsx +++ b/examples/globe-view/src/App.tsx @@ -2,14 +2,8 @@ import { _GlobeView as GlobeView } from "@deck.gl/core"; import { GeoJsonLayer, SolidPolygonLayer } from "@deck.gl/layers"; import { DeckGL } from "@deck.gl/react"; import { COGLayer } from "@developmentseed/deck.gl-geotiff"; -import { luma } from "@luma.gl/core"; -import { webgl2Adapter } from "@luma.gl/webgl"; import { useCallback, useState } from "react"; -// Register WebGL adapter — required when DeckGL creates its own context -// (unlike MapboxOverlay which reuses MaplibreGL's existing context) -luma.registerAdapters([webgl2Adapter]); - // New Zealand imagery (NZTM2000 projection) const COG_URL = "https://nz-imagery.s3-ap-southeast-2.amazonaws.com/new-zealand/new-zealand_2024-2025_10m/rgb/2193/CC11.tiff";