Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions packages/deck.gl-geotiff/src/cog-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,11 @@ import type { TextureDataT } from "./geotiff/render-pipeline.js";
import { inferRenderPipeline } from "./geotiff/render-pipeline.js";
import { fromAffine } from "./geotiff-reprojection.js";
import type { EpsgResolver } from "./proj.js";
import { epsgResolver, makeClampedForwardTo3857 } from "./proj.js";
import {
epsgResolver,
makeClampedForwardTo3857,
wrapAntimeridianProjections,
} from "./proj.js";

/** Size of deck.gl's common coordinate space in world units.
*
Expand Down Expand Up @@ -463,11 +467,29 @@ export class COGLayer<
};
deckProjectionProps = {};
} else {
// Wrap projection fns for antimeridian-crossing tiles so the 3857
// x-coordinates are continuous and RasterReprojector can converge (#366).
const corners = [
[0, 0],
[width, 0],
[width, height],
[0, height],
] as const;
const cornerXs = corners.map(([cx, cy]) => {
const [sx, sy] = forwardTransform(cx, cy);
return forwardTo3857(sx, sy)[0];
});
const wrapped = wrapAntimeridianProjections(
cornerXs,
forwardTo3857,
inverseFrom3857,
);
Comment on lines +482 to +486
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wrapAntimeridianProjections creates new closure functions for forwardReproject/inverseReproject on every _renderSubLayers call when wrapping is needed. RasterLayer.updateState treats changes in these function identities as a reason to regenerate the adaptive mesh, so antimeridian tiles can end up re-meshing every render. Consider memoizing/caching the wrapped projection functions (e.g., once per COGLayer instance or via a WeakMap keyed by the original fns) so their identities remain stable across renders for the same source projection.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This does seem like a valid critique. I'm not sure at a glance how it could be fixed.


reprojectionFns = {
forwardTransform,
inverseTransform,
forwardReproject: forwardTo3857,
inverseReproject: inverseFrom3857,
forwardReproject: wrapped.forwardTo3857,
inverseReproject: wrapped.inverseFrom3857,
};
// Scale 3857 meters → deck.gl world units (512×512).
//
Expand Down
36 changes: 36 additions & 0 deletions packages/deck.gl-geotiff/src/proj.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ const WGS84_ELLIPSOID_A = 6378137;
// Beyond this, the Mercator projection is undefined.
const MAX_WEB_MERCATOR_LAT = 85.05112877980659;

const WEB_MERCATOR_METER_CIRCUMFERENCE = 2 * Math.PI * WGS84_ELLIPSOID_A;
const HALF_CIRCUMFERENCE = WEB_MERCATOR_METER_CIRCUMFERENCE / 2;

type ProjectionFn = (x: number, y: number) => [number, number];
Copy link
Copy Markdown
Member

@kylebarron kylebarron Apr 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Edit you can use the copy in transform-bounds.ts


/**
* Convert a WGS84 longitude/latitude to EPSG:3857 meters analytically.
* Valid for latitudes in [-MAX_WEB_MERCATOR_LAT, MAX_WEB_MERCATOR_LAT].
Expand All @@ -19,6 +24,37 @@ function wgs84To3857(lon: number, lat: number): [number, number] {
return [x, y];
}

/**
* If a tile's EPSG:3857 corner x-values span more than half the globe, wrap
* `forwardTo3857` / `inverseFrom3857` so the coordinate space is continuous.
*
* Returns the original functions unchanged when no wrapping is needed.
*/
Comment on lines +27 to +32
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this heuristic trigger on global rasters?

Since we have the corners of the raster in WGS84, can't we instead just check to see whether the left longitude is greater than the right longitude?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @gadomski, this is a good point.

If we have an image that's 512px wide, 256px tall that covers the entire EPSG:4326 coordinate space, then it would trigger this function, even though it doesn't wrap the antimeridian.

(We should add unit test cases that such an input image doesn't trigger antimeridian handling)

export function wrapAntimeridianProjections(
cornerXs3857: number[],
forwardTo3857: ProjectionFn,
inverseFrom3857: ProjectionFn,
): { forwardTo3857: ProjectionFn; inverseFrom3857: ProjectionFn } {
const xMin = Math.min(...cornerXs3857);
const xMax = Math.max(...cornerXs3857);

if (xMax - xMin <= HALF_CIRCUMFERENCE) {
return { forwardTo3857, inverseFrom3857 };
}

return {
forwardTo3857: (x: number, y: number): [number, number] => {
const [px, py] = forwardTo3857(x, y);
return [px < 0 ? px + WEB_MERCATOR_METER_CIRCUMFERENCE : px, py];
},
inverseFrom3857: (x: number, y: number): [number, number] => {
const unwrapped =
x > HALF_CIRCUMFERENCE ? x - WEB_MERCATOR_METER_CIRCUMFERENCE : x;
return inverseFrom3857(unwrapped, y);
},
};
}

/**
* Wrap a proj4 forward projection to EPSG:3857 so that it never returns NaN.
*
Expand Down
71 changes: 70 additions & 1 deletion packages/deck.gl-geotiff/tests/proj.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import proj4 from "proj4";
import { describe, expect, it } from "vitest";
import { makeClampedForwardTo3857 } from "../src/proj.js";
import {
makeClampedForwardTo3857,
wrapAntimeridianProjections,
} from "../src/proj.js";

const WGS84_ELLIPSOID_A = 6378137;
const EPSG_3857_HALF_CIRCUMFERENCE = Math.PI * WGS84_ELLIPSOID_A;
Expand Down Expand Up @@ -44,3 +47,69 @@ describe("makeClampedForwardTo3857", () => {
expect(y).toBeCloseTo(EPSG_3857_HALF_CIRCUMFERENCE, 0);
});
});

describe("wrapAntimeridianProjections", () => {
const converter3857 = proj4("EPSG:4326", "EPSG:3857");

const forwardTo3857 = (x: number, y: number): [number, number] =>
converter3857.forward([x, y], false);
const inverseFrom3857 = (x: number, y: number): [number, number] =>
converter3857.inverse([x, y], false);

it("returns original functions when tile does not cross antimeridian", () => {
// Tile from lon 10° to 20° — well within one hemisphere
const cornerXs = [10, 20].map((lon) => forwardTo3857(lon, 0)[0]);
const result = wrapAntimeridianProjections(
cornerXs,
forwardTo3857,
inverseFrom3857,
);
expect(result.forwardTo3857).toBe(forwardTo3857);
expect(result.inverseFrom3857).toBe(inverseFrom3857);
});

it("wraps functions when tile crosses the antimeridian", () => {
// Tile corners at lon +170° and -170° (crosses ±180°)
const cornerXs = [170, -170].map((lon) => forwardTo3857(lon, 0)[0]);
const result = wrapAntimeridianProjections(
cornerXs,
forwardTo3857,
inverseFrom3857,
);
// Should return new (wrapped) functions
expect(result.forwardTo3857).not.toBe(forwardTo3857);
expect(result.inverseFrom3857).not.toBe(inverseFrom3857);
});

it("produces continuous x-values for antimeridian-crossing tiles", () => {
const cornerXs = [170, -170].map((lon) => forwardTo3857(lon, 0)[0]);
const { forwardTo3857: wrapped } = wrapAntimeridianProjections(
cornerXs,
forwardTo3857,
inverseFrom3857,
);

const x170 = wrapped(170, 0)[0];
const xNeg170 = wrapped(-170, 0)[0];

// Both should now be positive and close together (~20° apart in meters)
expect(x170).toBeGreaterThan(0);
expect(xNeg170).toBeGreaterThan(0);
expect(Math.abs(xNeg170 - x170)).toBeLessThan(5_000_000);
});

it("round-trips through wrapped forward and inverse", () => {
const cornerXs = [170, -170].map((lon) => forwardTo3857(lon, 0)[0]);
const result = wrapAntimeridianProjections(
cornerXs,
forwardTo3857,
inverseFrom3857,
);

// Forward then inverse should recover the original lon/lat
const [mx, my] = result.forwardTo3857(-175, 45);
const [lon, lat] = result.inverseFrom3857(mx, my);
expect(lon).toBeCloseTo(-175, 4);
expect(lat).toBeCloseTo(45, 4);
});
});