From 842fe4b31a9e705bdfd9448a5f8d658a0f148558 Mon Sep 17 00:00:00 2001 From: Shane Grigsby Date: Wed, 25 Feb 2026 09:55:26 -0800 Subject: [PATCH 1/9] 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 f9f360a35f968eb629fb653383371ff8a243e15f Mon Sep 17 00:00:00 2001 From: Shane Grigsby Date: Wed, 25 Feb 2026 09:59:54 -0800 Subject: [PATCH 2/9] 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 7b909c5a80f76303ffe94f6d1e60b3f31f2fac1c Mon Sep 17 00:00:00 2001 From: Shane Grigsby Date: Wed, 25 Feb 2026 10:08:52 -0800 Subject: [PATCH 3/9] Add antimeridian crossing support to RasterReprojector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect when a tile's output positions span the ±180° meridian and normalize longitudes to a continuous range (e.g., [170, 190] instead of [170, -170]). This prevents mesh triangles from spanning 340° of longitude and ensures correct GPU-side interpolation for tiles near the date line. --- packages/raster-reproject/src/delatin.ts | 71 +++++++++- .../tests/antimeridian.test.ts | 126 ++++++++++++++++++ 2 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 packages/raster-reproject/tests/antimeridian.test.ts diff --git a/packages/raster-reproject/src/delatin.ts b/packages/raster-reproject/src/delatin.ts index 18588f2a..1df69c65 100644 --- a/packages/raster-reproject/src/delatin.ts +++ b/packages/raster-reproject/src/delatin.ts @@ -70,9 +70,27 @@ export class RasterReprojector { /** * XY Positions in output CRS, computed via exact forward reprojection. + * + * When the tile crosses the antimeridian, longitude values are normalized + * to a continuous range (e.g., [170, 190] instead of [170, -170]) by + * applying `_lngOffset` to negative longitudes. */ exactOutputPositions: number[]; + /** + * Whether this tile's output positions cross the antimeridian (±180°). + * When true, longitudes in `exactOutputPositions` have been shifted + * to maintain continuity. + */ + crossesAntimeridian: boolean = false; + + /** + * Longitude offset applied to normalize antimeridian-crossing tiles. + * When `crossesAntimeridian` is true, this is 360 (negative longitudes + * are shifted by +360). Otherwise 0. + */ + private _lngOffset: number = 0; + /** * triangle vertex indices */ @@ -127,6 +145,10 @@ export class RasterReprojector { const p2 = this._addPoint(0, v1); const p3 = this._addPoint(u1, v1); + // Detect antimeridian crossing: if the longitude range of the 4 corner + // vertices exceeds 180°, the tile crosses the ±180° meridian. + this._detectAntimeridian(); + // add initial two triangles const t0 = this._addTriangle(p3, p0, p2, -1, -1, -1); this._addTriangle(p0, p3, p1, t0, -1, -1); @@ -164,6 +186,39 @@ export class RasterReprojector { this._pendingLen = 0; } + /** + * Detect antimeridian crossing from the initial 4 corner vertices. + * + * If the longitude range exceeds 180°, the tile crosses the antimeridian. + * In that case, shift all negative longitudes by +360 to create a + * continuous range (e.g., [170, 190] instead of [170, -170]). + * + * deck.gl handles longitudes outside [-180, 180] correctly. + */ + private _detectAntimeridian(): void { + // Check longitude range of the 4 initial vertices + let minLng = Infinity; + let maxLng = -Infinity; + for (let i = 0; i < 4; i++) { + const lng = this.exactOutputPositions[i * 2]!; + if (lng < minLng) minLng = lng; + if (lng > maxLng) maxLng = lng; + } + + if (maxLng - minLng > 180) { + this.crossesAntimeridian = true; + this._lngOffset = 360; + + // Retroactively fix the initial 4 vertices + for (let i = 0; i < 4; i++) { + const lng = this.exactOutputPositions[i * 2]!; + if (lng < 0) { + this.exactOutputPositions[i * 2] = lng + 360; + } + } + } + } + /** * Conversion of upstream's `_findCandidate` for reprojection error handling. * @@ -248,6 +303,10 @@ export class RasterReprojector { // Reproject these linearly-interpolated coordinates **from target CRS // to input CRS**. This gives us the **exact position in input space** // of the linearly interpolated sample point in output space. + // + // When the tile crosses the antimeridian, the interpolated longitude + // may be >180° due to the longitude offset. proj4 handles extended + // longitudes correctly, so we pass them through directly. const inputCRSSampled = this.reprojectors.inverseReproject( outSampleX, outSampleY, @@ -353,10 +412,14 @@ export class RasterReprojector { inputPosition[0], inputPosition[1], ); - this.exactOutputPositions.push( - exactOutputPosition[0]!, - exactOutputPosition[1]!, - ); + + let lng = exactOutputPosition[0]!; + // Normalize longitude for antimeridian-crossing tiles + if (this._lngOffset !== 0 && lng < 0) { + lng += this._lngOffset; + } + + this.exactOutputPositions.push(lng, exactOutputPosition[1]!); return i; } diff --git a/packages/raster-reproject/tests/antimeridian.test.ts b/packages/raster-reproject/tests/antimeridian.test.ts new file mode 100644 index 00000000..a7a8b307 --- /dev/null +++ b/packages/raster-reproject/tests/antimeridian.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import type { ReprojectionFns } from "../src/delatin"; +import { RasterReprojector } from "../src/delatin"; + +/** Wrap a longitude to [-180, 180] */ +function wrapLng(lng: number): number { + return ((((lng + 180) % 360) + 360) % 360) - 180; +} + +/** + * Create ReprojectionFns that simulate a raster tile in a source CRS where + * the forward transform produces coordinates in source CRS space, and + * forwardReproject converts them to WGS84 with longitude wrapping (as proj4 + * does for geographic output CRS). + */ +function makeReprojectionFns( + originX: number, + originY: number, + pixelSizeX: number, + pixelSizeY: number, + opts?: { wrapLongitude?: boolean }, +): ReprojectionFns { + const wrap = opts?.wrapLongitude ?? false; + return { + forwardTransform(pixelX: number, pixelY: number): [number, number] { + return [ + originX + pixelX * pixelSizeX, + originY + pixelY * pixelSizeY, + ]; + }, + inverseTransform(crsX: number, crsY: number): [number, number] { + return [ + (crsX - originX) / pixelSizeX, + (crsY - originY) / pixelSizeY, + ]; + }, + forwardReproject(x: number, y: number): [number, number] { + // Simulate proj4 behavior: wrap longitude to [-180, 180] + return wrap ? [wrapLng(x), y] : [x, y]; + }, + inverseReproject(x: number, y: number): [number, number] { + // Identity inverse — extended longitudes (>180) pass through + // unchanged, matching how proj4 handles inverse projection + // (proj4 normalizes longitude internally before inverse-projecting) + return [x, y]; + }, + }; +} + +describe("antimeridian detection", () => { + it("does not flag a tile that does not cross the antimeridian", () => { + // A tile centered around longitude 0, latitude 45 + const fns = makeReprojectionFns(-10, 40, 0.1, -0.05, { + wrapLongitude: true, + }); + const reprojector = new RasterReprojector(fns, 200, 200); + expect(reprojector.crossesAntimeridian).toBe(false); + }); + + it("flags a tile that crosses the antimeridian", () => { + // Tile spans from 170° to 190° in source CRS. + // With wrapLongitude=true, forwardReproject wraps 190° → -170°, + // so corner longitudes are [170, -170] — a 340° range that triggers + // antimeridian detection. + const fns = makeReprojectionFns(170, 40, 0.1, -0.05, { + wrapLongitude: true, + }); + const reprojector = new RasterReprojector(fns, 200, 200); + expect(reprojector.crossesAntimeridian).toBe(true); + }); + + it("normalizes longitudes to continuous range when crossing antimeridian", () => { + const fns = makeReprojectionFns(170, 40, 0.1, -0.05, { + wrapLongitude: true, + }); + const reprojector = new RasterReprojector(fns, 200, 200); + reprojector.run(0.5); + + // All longitudes should be in [170, 190] — no jumps to negative values + for (let i = 0; i < reprojector.exactOutputPositions.length; i += 2) { + const lng = reprojector.exactOutputPositions[i]!; + expect(lng).toBeGreaterThanOrEqual(170 - 0.1); + expect(lng).toBeLessThanOrEqual(190 + 0.1); + } + }); + + it("mesh triangles do not span more than 180 degrees of longitude", () => { + const fns = makeReprojectionFns(170, 40, 0.1, -0.05, { + wrapLongitude: true, + }); + const reprojector = new RasterReprojector(fns, 200, 200); + reprojector.run(0.5); + + const { triangles, exactOutputPositions } = reprojector; + for (let t = 0; t < triangles.length; t += 3) { + const a = triangles[t]!; + const b = triangles[t + 1]!; + const c = triangles[t + 2]!; + + const lngA = exactOutputPositions[a * 2]!; + const lngB = exactOutputPositions[b * 2]!; + const lngC = exactOutputPositions[c * 2]!; + + const maxLng = Math.max(lngA, lngB, lngC); + const minLng = Math.min(lngA, lngB, lngC); + expect(maxLng - minLng).toBeLessThan(180); + } + }); + + it("does not affect tiles far from the antimeridian", () => { + // A tile centered at longitude 0, no wrapping needed + const fns = makeReprojectionFns(-10, 40, 0.1, -0.05, { + wrapLongitude: true, + }); + const reprojector = new RasterReprojector(fns, 200, 200); + reprojector.run(0.5); + + expect(reprojector.crossesAntimeridian).toBe(false); + // All longitudes should be in [-10, 10] + for (let i = 0; i < reprojector.exactOutputPositions.length; i += 2) { + const lng = reprojector.exactOutputPositions[i]!; + expect(lng).toBeGreaterThanOrEqual(-10 - 0.1); + expect(lng).toBeLessThanOrEqual(10 + 0.1); + } + }); +}); From 78da2f33c18fb6fe64f658f187f8263f80c3b2de Mon Sep 17 00:00:00 2001 From: Shane Grigsby Date: Wed, 25 Feb 2026 10:17:21 -0800 Subject: [PATCH 4/9] Add polar projection support to RasterReprojector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detect tiles containing or near a geographic pole (|lat| > 75° with antimeridian crossing). Add a maxTriangles safety cap to run() to prevent infinite refinement near poles, where the extreme longitude variation can require many more triangles to converge. The existing antimeridian normalization handles the longitude wrapping for polar tiles — the mesh refines around the remaining discontinuity. deck.gl's GlobeView renders the resulting mesh correctly since projectPosition handles arbitrary (lng, lat) values. --- packages/raster-reproject/src/delatin.ts | 54 +++++- packages/raster-reproject/tests/polar.test.ts | 168 ++++++++++++++++++ 2 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 packages/raster-reproject/tests/polar.test.ts diff --git a/packages/raster-reproject/src/delatin.ts b/packages/raster-reproject/src/delatin.ts index 1df69c65..b8b3ec1a 100644 --- a/packages/raster-reproject/src/delatin.ts +++ b/packages/raster-reproject/src/delatin.ts @@ -84,6 +84,14 @@ export class RasterReprojector { */ crossesAntimeridian: boolean = false; + /** + * Whether this tile contains or is very near a geographic pole. + * When true, the tile's WGS84 output positions have extreme latitude + * values (|lat| > 85°) and the longitude range is very wide, indicating + * the pole-centered geometry that requires more mesh refinement. + */ + containsPole: boolean = false; + /** * Longitude offset applied to normalize antimeridian-crossing tiles. * When `crossesAntimeridian` is true, this is 360 (negative longitudes @@ -149,19 +157,38 @@ export class RasterReprojector { // vertices exceeds 180°, the tile crosses the ±180° meridian. this._detectAntimeridian(); + // Detect polar tiles: vertices near ±90° latitude with wide longitude + // spread indicate a tile at or near a geographic pole. + this._detectPole(); + // add initial two triangles const t0 = this._addTriangle(p3, p0, p2, -1, -1, -1); this._addTriangle(p0, p3, p1, t0, -1, -1); this._flush(); } - // refine the mesh until its maximum error gets below the given one - run(maxError: number = DEFAULT_MAX_ERROR): void { + /** + * Refine the mesh until its maximum error gets below the given one. + * + * @param maxError - Maximum allowed reprojection error in pixels. + * @param opts.maxTriangles - Safety cap on triangle count. Refinement + * stops when this limit is reached even if maxError hasn't been met. + * Useful for polar tiles where convergence is slow due to extreme + * longitude variation near the pole. + */ + run( + maxError: number = DEFAULT_MAX_ERROR, + opts?: { maxTriangles?: number }, + ): void { if (maxError <= 0) { throw new Error("maxError must be positive"); } - while (this.getMaxError() > maxError) { + const maxTriangles = opts?.maxTriangles ?? Infinity; + while ( + this.getMaxError() > maxError && + this.triangles.length / 3 < maxTriangles + ) { this.refine(); } } @@ -219,6 +246,27 @@ export class RasterReprojector { } } + /** + * Detect whether this tile contains or is very near a geographic pole. + * + * A tile is considered polar if it crosses the antimeridian (wide + * longitude spread) and any vertex has |latitude| > 75°. The + * combination of these conditions uniquely identifies tiles in polar + * projections — non-polar antimeridian tiles (e.g., UTM zone 1) have + * much narrower longitude ranges that don't exceed 180°. + */ + private _detectPole(): void { + if (!this.crossesAntimeridian) return; + + for (let i = 0; i < 4; i++) { + const lat = this.exactOutputPositions[i * 2 + 1]!; + if (Math.abs(lat) > 75) { + this.containsPole = true; + return; + } + } + } + /** * Conversion of upstream's `_findCandidate` for reprojection error handling. * diff --git a/packages/raster-reproject/tests/polar.test.ts b/packages/raster-reproject/tests/polar.test.ts new file mode 100644 index 00000000..226d42d7 --- /dev/null +++ b/packages/raster-reproject/tests/polar.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vitest"; +import type { ReprojectionFns } from "../src/delatin"; +import { RasterReprojector } from "../src/delatin"; + +const R = 6378137; // WGS84 semi-major axis (meters) + +/** + * Simplified south polar stereographic projection for testing. + * + * Maps a rectangular tile in a planar CRS centered on the South Pole + * to WGS84 (lon, lat). The projection is a true polar stereographic + * with the origin at the South Pole (0, 0) in projected coordinates. + * + * Convention: + * - x axis → 90°E direction + * - y axis → 0° (prime meridian) direction + * - South Pole is at (0, 0) in projected coords = (*, -90°) in WGS84 + */ +function makePolarReprojectionFns( + originX: number, + originY: number, + pixelSizeX: number, + pixelSizeY: number, +): ReprojectionFns { + return { + forwardTransform(pixelX: number, pixelY: number): [number, number] { + return [ + originX + pixelX * pixelSizeX, + originY + pixelY * pixelSizeY, + ]; + }, + inverseTransform(crsX: number, crsY: number): [number, number] { + return [ + (crsX - originX) / pixelSizeX, + (crsY - originY) / pixelSizeY, + ]; + }, + forwardReproject(x: number, y: number): [number, number] { + // Polar stereographic → WGS84 + const rho = Math.sqrt(x * x + y * y); + if (rho < 0.01) { + // At the pole — longitude is undefined, return 0 by convention + return [0, -90]; + } + const c = 2 * Math.atan2(rho, 2 * R); + const lat = ((c - Math.PI / 2) * 180) / Math.PI; + const lon = (Math.atan2(x, y) * 180) / Math.PI; + return [lon, lat]; + }, + inverseReproject(lon: number, lat: number): [number, number] { + // WGS84 → polar stereographic + const latRad = (lat * Math.PI) / 180; + const lonRad = (lon * Math.PI) / 180; + const rho = 2 * R * Math.tan(Math.PI / 4 + latRad / 2); + const x = rho * Math.sin(lonRad); + const y = rho * Math.cos(lonRad); + return [x, y]; + }, + }; +} + +describe("polar projection support", () => { + it("detects a tile containing the south pole", () => { + // Tile centered on the south pole: spans ±500km around the pole + // Origin is top-left corner in GeoTIFF convention + const fns = makePolarReprojectionFns(-500000, 500000, 5000, -5000); + const reprojector = new RasterReprojector(fns, 200, 200); + + expect(reprojector.crossesAntimeridian).toBe(true); + expect(reprojector.containsPole).toBe(true); + }); + + it("does not flag a tile far from the pole", () => { + // Tile at ~1500-2000km from pole (roughly -75° to -70° latitude) + // At this distance, longitude spread is limited + const fns = makePolarReprojectionFns(1500000, 2500000, 5000, -5000); + const reprojector = new RasterReprojector(fns, 200, 200); + + expect(reprojector.containsPole).toBe(false); + }); + + it("generates a mesh for a polar tile with maxTriangles cap", () => { + // Tile containing the south pole + const fns = makePolarReprojectionFns(-500000, 500000, 5000, -5000); + const reprojector = new RasterReprojector(fns, 200, 200); + + // Run with a safety cap — polar tiles may need many triangles + reprojector.run(2.0, { maxTriangles: 5000 }); + + const numTriangles = reprojector.triangles.length / 3; + expect(numTriangles).toBeGreaterThan(2); + // Each refine() step adds 2+ triangles atomically, so the final count + // may slightly exceed the cap + expect(numTriangles).toBeLessThanOrEqual(5010); + + // Verify mesh has vertices + expect(reprojector.uvs.length).toBeGreaterThan(8); // more than initial 4 vertices + }); + + it("generates a valid mesh for a near-pole tile", () => { + // Tile near the pole but not containing it + // Offset so the pole is outside the tile extent + const fns = makePolarReprojectionFns(200000, 700000, 5000, -5000); + const reprojector = new RasterReprojector(fns, 200, 200); + + // Should still converge with a reasonable triangle count + reprojector.run(2.0, { maxTriangles: 5000 }); + + const numTriangles = reprojector.triangles.length / 3; + expect(numTriangles).toBeGreaterThan(2); + }); + + it("mesh covers the expected latitude range", () => { + // Tile containing the pole, spanning ~±4.5° from the pole + const fns = makePolarReprojectionFns(-500000, 500000, 5000, -5000); + const reprojector = new RasterReprojector(fns, 200, 200); + reprojector.run(2.0, { maxTriangles: 5000 }); + + // Check that output positions include latitudes near -90° + let minLat = Infinity; + let maxLat = -Infinity; + for (let i = 0; i < reprojector.exactOutputPositions.length; i += 2) { + const lat = reprojector.exactOutputPositions[i + 1]!; + if (lat < minLat) minLat = lat; + if (lat > maxLat) maxLat = lat; + } + + // The tile should reach close to the pole (with capped triangle count, + // the mesh may not have a vertex at exactly -90°) + expect(minLat).toBeLessThan(-86); + // And extend away from the pole + expect(maxLat).toBeGreaterThan(-86); + }); + + it("maxTriangles stops refinement early", () => { + const fns = makePolarReprojectionFns(-500000, 500000, 5000, -5000); + const reprojector = new RasterReprojector(fns, 200, 200); + + // Use a very tight error threshold and low maxTriangles + reprojector.run(0.01, { maxTriangles: 100 }); + + const numTriangles = reprojector.triangles.length / 3; + // Each refine() step adds 2+ triangles atomically, so the final count + // may slightly exceed the cap + expect(numTriangles).toBeLessThanOrEqual(110); + // Error should still be above threshold since we stopped early + expect(reprojector.getMaxError()).toBeGreaterThan(0.01); + }); + + it("round-trips through forward and inverse reprojection", () => { + // Verify our test projection is self-consistent + const fns = makePolarReprojectionFns(-500000, 500000, 5000, -5000); + + // Test a few points + const testPoints: [number, number][] = [ + [100000, 200000], + [-300000, 100000], + [0, -400000], + ]; + + for (const [x, y] of testPoints) { + const [lon, lat] = fns.forwardReproject(x, y); + const [x2, y2] = fns.inverseReproject(lon, lat); + expect(x2).toBeCloseTo(x, 0); + expect(y2).toBeCloseTo(y, 0); + } + }); +}); From 6b18b45a70aac95120c3ea2dce775b2d4752dcd7 Mon Sep 17 00:00:00 2001 From: Shane Grigsby Date: Wed, 25 Feb 2026 10:48:46 -0800 Subject: [PATCH 5/9] 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 86f79b263c1ed79b4c3741e84228e64342377b2a Mon Sep 17 00:00:00 2001 From: Shane Grigsby Date: Wed, 25 Feb 2026 11:22:16 -0800 Subject: [PATCH 6/9] addressing test/lint failures, lockfile update --- examples/globe-view/src/App.tsx | 6 +- .../raster-tileset/raster-tile-traversal.ts | 3 +- .../tests/raster-tile-traversal.test.ts | 43 +++++++-- .../tests/antimeridian.test.ts | 10 +-- packages/raster-reproject/tests/polar.test.ts | 10 +-- pnpm-lock.yaml | 87 +++++++++++++++++++ 6 files changed, 129 insertions(+), 30 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/packages/raster-reproject/tests/antimeridian.test.ts b/packages/raster-reproject/tests/antimeridian.test.ts index a7a8b307..8a69711d 100644 --- a/packages/raster-reproject/tests/antimeridian.test.ts +++ b/packages/raster-reproject/tests/antimeridian.test.ts @@ -23,16 +23,10 @@ function makeReprojectionFns( const wrap = opts?.wrapLongitude ?? false; return { forwardTransform(pixelX: number, pixelY: number): [number, number] { - return [ - originX + pixelX * pixelSizeX, - originY + pixelY * pixelSizeY, - ]; + return [originX + pixelX * pixelSizeX, originY + pixelY * pixelSizeY]; }, inverseTransform(crsX: number, crsY: number): [number, number] { - return [ - (crsX - originX) / pixelSizeX, - (crsY - originY) / pixelSizeY, - ]; + return [(crsX - originX) / pixelSizeX, (crsY - originY) / pixelSizeY]; }, forwardReproject(x: number, y: number): [number, number] { // Simulate proj4 behavior: wrap longitude to [-180, 180] diff --git a/packages/raster-reproject/tests/polar.test.ts b/packages/raster-reproject/tests/polar.test.ts index 226d42d7..70cbf702 100644 --- a/packages/raster-reproject/tests/polar.test.ts +++ b/packages/raster-reproject/tests/polar.test.ts @@ -24,16 +24,10 @@ function makePolarReprojectionFns( ): ReprojectionFns { return { forwardTransform(pixelX: number, pixelY: number): [number, number] { - return [ - originX + pixelX * pixelSizeX, - originY + pixelY * pixelSizeY, - ]; + return [originX + pixelX * pixelSizeX, originY + pixelY * pixelSizeY]; }, inverseTransform(crsX: number, crsY: number): [number, number] { - return [ - (crsX - originX) / pixelSizeX, - (crsY - originY) / pixelSizeY, - ]; + return [(crsX - originX) / pixelSizeX, (crsY - originY) / pixelSizeY]; }, forwardReproject(x: number, y: number): [number, number] { // Polar stereographic → WGS84 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 295835871c036b69b4bf170dca519cc9587448de Mon Sep 17 00:00:00 2001 From: Shane Grigsby Date: Wed, 25 Feb 2026 12:05:47 -0800 Subject: [PATCH 7/9] 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 8e3fdbfc40133de0028a172062000cad1e8309ca Mon Sep 17 00:00:00 2001 From: Shane Grigsby Date: Wed, 25 Feb 2026 12:20:08 -0800 Subject: [PATCH 8/9] 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 4bbfbf0a6400483f8e169e64486040737021fa82 Mon Sep 17 00:00:00 2001 From: Shane Grigsby Date: Wed, 25 Feb 2026 12:40:54 -0800 Subject: [PATCH 9/9] fix: update pnpm-lock.yaml for globe-view @luma.gl/webgl dep --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ded96ee..231685c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,6 +134,9 @@ importers: '@luma.gl/shadertools': specifier: ^9.2.6 version: 9.2.6(@luma.gl/core@9.2.6) + '@luma.gl/webgl': + specifier: ^9.2.6 + version: 9.2.6(@luma.gl/core@9.2.6) proj4: specifier: ^2.20.2 version: 2.20.2