diff --git a/docs/functions/reproj.md b/docs/functions/reproj.md index 41411c5..42677be 100644 --- a/docs/functions/reproj.md +++ b/docs/functions/reproj.md @@ -3,11 +3,20 @@ The `reproj` function is arguably the most important function in GeoExtent and i This function essentially calls [reproject-bbox](https://github.com/DanielJDufour/reproject-bbox) on the extent and returns a new GeoExtent. It's first argument `to` is the new `srs` or what we are reprojecting to. It can be a number, a string like `EPSG:4326`, [well-known text](https://en.wikipedia.org/wiki/Well-known_text_representation_of_coordinate_reference_systems), or a [proj4js](http://proj4js.org/) string. -# errors -If the reprojection failed for some reason, such as the extent is outside the bounds of the [srs](https://en.wikipedia.org/wiki/Spatial_reference_system), then this function will throw an error. For example, +## fallback strategy +If direct reprojection fails, reproj will attempt to reproject through an intermediary projection. It'll attempt +to reproject to 4326 (aka Latitude/Longitude) and then reproject to the desired projection. + +## errors +If the reprojection fails even after the fallback is tried, such as the extent is outside the bounds of the [srs](https://en.wikipedia.org/wiki/Spatial_reference_system), then this function will throw an error. For example, if you try to reproject the north pole to web mercator then you will receive an error because web mercator doesn't not work at that high of a latitude. -# quiet mode +## infinity +Some projections are bound to certain regions, so reprojection can sometimes lead to infinity values. By default, +reproj will throw an error. However, if you are okay with infinity values, you can pass in an optional parameter +`allow_infinity: true`. + +## quiet mode If you would prefer that reproj return `undefined` instead of throwing an error, you can turn on quiet mode. ```js const northPole = new GeoExtent([-180, 85, 180, 90], { srs: 4326 }); @@ -19,7 +28,7 @@ northPole.reproj(3857, { quiet: true }); // undefined ``` -# density +## density You can control the accuracy of the reprojection by passing in a point density parameter. Sometimes a bounding box will bend when reprojected and the most extreme points won't necessarily be at the corners. If you care for speed more than accuracy, you should choose a lower value. By default, reproj uses a "high" density adding a 100 points to each side before reprojecting. diff --git a/package.json b/package.json index 688d1a2..b7c4e76 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "build": "cp ./src/geo-extent.js ./dist/geo-extent.mjs && npx babel ./src/geo-extent.js --out-file ./dist/geo-extent.cjs --plugins=@babel/plugin-transform-modules-commonjs", "browserify": "npx browserify ./dist/geo-extent.cjs > ./dist/geo-extent.browserify.js", "f": "npm run format", - "format": "npx prettier --arrow-parens=avoid --print-width=120 --trailing-comma=none --write src/*.js test/*.js test/*.mjs", + "format": "npx prettier --arrow-parens=avoid --print-width=120 --trailing-comma=none --write *.ts src/*.js test/*.js test/*.mjs", "prepublishOnly": "npm run format && npm run build && npm run browserify && npm run test", "test": "for f in ./test/*; do echo \"node $f\" && node $f && echo \"npx ts-node $f\" && npx ts-node $f; done" }, @@ -49,21 +49,21 @@ }, "homepage": "https://github.com/DanielJDufour/geo-extent#readme", "devDependencies": { - "@babel/cli": "^7.21.0", - "@babel/core": "^7.21.0", - "@babel/helper-compilation-targets": "^7.20.7", - "@babel/helper-validator-option": "^7.21.0", - "@babel/plugin-transform-modules-commonjs": "^7.21.2", - "flug": "^2.5.0", - "global-jsdom": "^8.7.0", - "jsdom": "^21.1.0", - "leaflet": "^1.9.3" + "@babel/cli": "^7.22.5", + "@babel/core": "^7.22.5", + "@babel/helper-compilation-targets": "^7.22.5", + "@babel/helper-validator-option": "^7.22.5", + "@babel/plugin-transform-modules-commonjs": "^7.22.5", + "flug": "^2.6.0", + "global-jsdom": "^9.0.1", + "jsdom": "^22.1.0", + "leaflet": "^1.9.4" }, "dependencies": { - "bbox-fns": "^0.3.0", + "bbox-fns": "^0.11.0", "get-epsg-code": "^0.1.0", "preciso": "^0.12.0", - "reproject-bbox": "^0.6.0", + "reproject-bbox": "^0.10.0", "reproject-geojson": "^0.3.0" } } diff --git a/src/geo-extent.js b/src/geo-extent.js index 04f5b0b..b370979 100644 --- a/src/geo-extent.js +++ b/src/geo-extent.js @@ -10,6 +10,7 @@ import multiply from "preciso/multiply.js"; import subtract from "preciso/subtract.js"; import bboxArray from "bbox-fns/bbox-array.js"; +import booleanContains from "bbox-fns/boolean-contains.js"; import booleanIntersects from "bbox-fns/boolean-intersects.js"; import densePolygon from "bbox-fns/dense-polygon.js"; @@ -247,13 +248,45 @@ export class GeoExtent { return new this.constructor(this); } - contains(other) { - const [_this, _other] = this._pre(this, other); + _contains(other, { quiet = false } = { quiet: false }) { + try { + const [_this, _other] = this._pre(this, other); + + return booleanContains(_this.bbox, _other.bbox); + } catch (error) { + if (!quiet) throw error; + } + } - const xContains = _other.xmin >= _this.xmin && _other.xmax <= _this.xmax; - const yContains = _other.ymin >= _this.ymin && _other.ymax <= _this.ymax; + contains(other, { debug_level = 0, quiet = true } = { debug_level: 0, quiet: true }) { + const result = this._contains(other, { quiet: true }); + if (typeof result === "boolean") return result; + + if (isDef(this.srs) && isDef(other.srs)) { + try { + // try reprojecting to projection of second bbox + const this2 = this.reproj(other.srs); + const result2 = this2._contains(other, { quiet: true }); + if (typeof result2 === "boolean") return result2; + } catch (error) { + if (debug_level >= 1) console.error(error); + } + + try { + // previous attempt was inconclusive, so try again by converting everything to 4326 + const this4326 = this.reproj(4326); + const other4326 = other.reproj(4326); + const result4326 = this4326._contains(other4326, { quiet: true }); + if (typeof result4326 === "boolean") return result4326; + } catch (error) { + if (debug_level >= 1) console.error(error); + } + } - return xContains && yContains; + if (!quiet) + throw new Error( + `[geo-extent] failed to determine if ${this.bbox} in srs ${this.srs} contains ${other.bbox} in srs ${other.srs}` + ); } // should return null if no overlap @@ -390,7 +423,15 @@ export class GeoExtent { return false; } - reproj(to, { density = "high", quiet = false } = { density: "high", quiet: false }) { + reproj( + to, + { allow_infinity = false, debug_level = 0, density = "high", quiet = false } = { + allow_infinity: false, + debug_level: 0, + density: "high", + quiet: false + } + ) { to = normalize(to); // normalize srs // don't need to reproject, so just return a clone @@ -430,15 +471,47 @@ export class GeoExtent { to }); } catch (error) { - if (quiet) return; - throw new Error(`[geo-extent] failed to reproject ${this.bbox} from ${this.srs} to ${to}`); + if (debug_level) console.error(error); } - if (reprojected.some(isNaN)) { - if (quiet) return; + if (reprojected?.every(isFinite)) { + return new GeoExtent(reprojected, { srs: to }); + } + // as a fallback, try reprojecting to EPSG:4326 then to the desired srs + if (to !== 4326) { + let bbox_4326; + try { + bbox_4326 = reprojectBoundingBox({ + bbox: this.bbox, + density, + from: this.srs, + to: 4326 + }); + } catch (error) { + if (debug_level) console.error("failed to create intermediary bbox in EPSG:4326"); + } + + if (bbox_4326) { + try { + reprojected = reprojectBoundingBox({ + bbox: bbox_4326, + density, + from: 4326, + to + }); + } catch (err) { + if (debug_level) console.error(`failed to reproject from intermediary bbox ${bbox_4326} in 4326 to ${to}`); + } + } + } + + if (allow_infinity || reprojected?.every(isFinite)) { + return new GeoExtent(reprojected, { srs: to }); + } else if (quiet) { + return; + } else { throw new Error(`[geo-extent] failed to reproject ${this.bbox} from ${this.srs} to ${to}`); } - return new GeoExtent(reprojected, { srs: to }); } unwrap() { diff --git a/test/test.contains.js b/test/test.contains.js index ff7c94f..08b2d19 100644 --- a/test/test.contains.js +++ b/test/test.contains.js @@ -14,3 +14,19 @@ test("DC in Continental USA", ({ eq }) => { eq(northernHemisphere.contains(usa), true); eq(usa.contains(northernHemisphere), false); }); + +test("check if UTM contains Web Mercator tile", ({ eq }) => { + // [geo-extent] failed to reproject -20037508.342789244,-7.081154551613622e-10,0,20037508.342789244 from EPSG:3857 to EPSG:32615 + // equivalent to [-96.0417..., 29.5116..., -95.7808..., 29.6233...] in 4326 + const utm = new GeoExtent([205437, 3268524, 230448, 3280290], { srs: "EPSG:32615" }); + + // top left quarter of the world + const tile = new GeoExtent([-20037508.342789244, -7.081154551613622e-10, 0, 20037508.342789244], { + srs: "EPSG:3857" + }); + + // aoi completely falls within tile + eq(utm.contains(tile), false); + + eq(tile.contains(utm), true); +}); diff --git a/test/test.reproj.js b/test/test.reproj.js index 6c13b48..95b37ea 100644 --- a/test/test.reproj.js +++ b/test/test.reproj.js @@ -109,3 +109,26 @@ test("reproject extent that bends out", ({ eq }) => { 57.535885041786784 ]); }); + +test("reproj to inf", ({ eq }) => { + const bbox = [-10018754.171394622, -7.081154551613622e-10, 0, 10018754.171394624]; + const srs = "EPSG:3857"; + + let msg; + try { + new GeoExtent(bbox, { srs }).reproj(26916, { debug: false, density: 100 }); + } catch (error) { + msg = error.message; + } + eq( + msg, + "[geo-extent] failed to reproject -10018754.171394622,-7.081154551613622e-10,0,10018754.171394624 from EPSG:3857 to EPSG:26916" + ); + + eq(new GeoExtent(bbox, { srs }).reproj(26916, { allow_infinity: true }).bbox, [ + 166021.4430805326, + -187729840.1254552, + Infinity, + Infinity + ]); +}); diff --git a/types.d.ts b/types.d.ts index 71078f2..09e00ba 100644 --- a/types.d.ts +++ b/types.d.ts @@ -21,24 +21,42 @@ export class GeoExtent { area_str: string; perimeter: number; perimeter_str: string; - center: { x: number, y: number }; - center_str: { x: string, y: string }; - bottomLeft: { x: number, y: number }; - bottomRight: { x: number, y: number }; - topLeft: { x: number, y: number }; - topRight: { x: number, y: number }; + center: { x: number; y: number }; + center_str: { x: string; y: string }; + bottomLeft: { x: number; y: number }; + bottomRight: { x: number; y: number }; + topLeft: { x: number; y: number }; + topRight: { x: number; y: number }; str: string; leafletBounds: [[number, number], [number, number]]; // functions - asEsriJSON(): { xmin: number, ymin: number, xmax: number, ymax: number, spatialReference: { wkid: string }}; - asGeoJSON(): { type: "Feature", geometry: { type: "Polygon", coordinates: [number[]]}}; + asEsriJSON(): { xmin: number; ymin: number; xmax: number; ymax: number; spatialReference: { wkid: string } }; + asGeoJSON(): { type: "Feature"; geometry: { type: "Polygon"; coordinates: [number[]] } }; clone(): GeoExtent; combine(other: GeoExtent): GeoExtent; contains(other: GeoExtent): GeoExtent; crop(other: GeoExtent): GeoExtent; equals: (other: GeoExtent, options?: { digits?: number }) => boolean; overlaps(other: GeoExtent): boolean; - reproj: ((srs: number, options?: { density?: 'lowest' | 'low' | 'medium' | 'high' | 'higher' | 'highest' | number | undefined, quiet: false }) => GeoExtent) | ((srs: number, options: { density?: 'lowest' | 'low' | 'medium' | 'high' | 'higher' | 'highest' | number | undefined, quiet: true }) => (GeoExtent | undefined)); + reproj: + | (( + srs: number, + options?: { + allow_infinity?: boolean | undefined; + density?: "lowest" | "low" | "medium" | "high" | "higher" | "highest" | number | undefined; + debug_level?: number | undefined; + quiet: false; + } + ) => GeoExtent) + | (( + srs: number, + options: { + allow_infinity?: boolean; + debug_level?: number | undefined; + density?: "lowest" | "low" | "medium" | "high" | "higher" | "highest" | number | undefined; + quiet: true; + } + ) => GeoExtent | undefined); unwrap(): GeoExtent[]; }