diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index e04eb9b..b2abd14 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -62,5 +62,6 @@ }, "synchRecordsInterval": "SYNCH_RECORDS_INTERVAL", "productMetadataFields": "productType,updateDate,resolutionMeter,absoluteAccuracyLEP90", - "maximumTilesPerRequest": "MAXIMUM_TILES_PER_REQUEST" + "maximumTilesPerRequest": "MAXIMUM_TILES_PER_REQUEST", + "samplingConcurrency": "SAMPLING_CONCURRENCY" } diff --git a/config/default.json b/config/default.json index 508fecb..29203df 100644 --- a/config/default.json +++ b/config/default.json @@ -50,5 +50,6 @@ }, "synchRecordsInterval": 600000, "productMetadataFields": "productType,updateDate,resolutionMeter,absoluteAccuracyLEP90", - "maximumTilesPerRequest": 150 + "maximumTilesPerRequest": 150, + "samplingConcurrency": 16 } diff --git a/package-lock.json b/package-lock.json index fcbf511..4ddcaa1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,12 +31,12 @@ "@turf/turf": "^6.5.0", "@types/geojson": "^7946.0.10", "axios": "^0.21.1", - "cesium": "^1.114.0", "compression": "^1.7.4", "config": "^3.3.12", "cross-fetch": "^3.1.5", "express": "^4.18.1", "express-openapi-validator": "^4.13.8", + "geotiff": "^3.0.5", "http-status-codes": "^2.2.0", "lodash": "^4.17.21", "node-fetch": "^3.3.1", @@ -53,7 +53,6 @@ "@map-colonies/eslint-config": "^3.1.1", "@map-colonies/prettier-config": "0.0.1", "@redocly/openapi-cli": "^1.0.0-beta.94", - "@types/cesium": "^1.70.0", "@types/compression": "^1.7.2", "@types/config": "^3.3.0", "@types/express": "^4.17.13", @@ -2037,90 +2036,6 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, - "node_modules/@cesium/engine": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/@cesium/engine/-/engine-26.2.0.tgz", - "integrity": "sha512-BsfHZpg6LvlUSaTfvzfEzo9JQd9+PtMbzoylwls+aRj6bnqGNTF5F2sMC1eja5L3wg725NyiWQ8s84sIM9Dr6g==", - "license": "Apache-2.0", - "dependencies": { - "@cesium/wasm-splats": "^0.1.0-alpha.2", - "@spz-loader/core": "0.3.1", - "@tweenjs/tween.js": "^25.0.0", - "@zip.js/zip.js": "^2.8.1", - "autolinker": "^4.0.0", - "bitmap-sdf": "^1.0.3", - "dompurify": "^3.3.0", - "draco3d": "^1.5.1", - "earcut": "3.0.2", - "grapheme-splitter": "^1.0.4", - "jsep": "^1.3.8", - "kdbush": "^4.0.1", - "ktx-parse": "^1.0.0", - "lerc": "^2.0.0", - "mersenne-twister": "^1.1.0", - "meshoptimizer": "^1.0.1", - "pako": "^3.0.0", - "protobufjs": "^8.6.5", - "rbush": "^4.0.1", - "topojson-client": "^3.1.0", - "urijs": "^1.19.7" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/@cesium/engine/node_modules/earcut": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", - "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", - "license": "ISC" - }, - "node_modules/@cesium/engine/node_modules/protobufjs": { - "version": "8.7.2", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.7.2.tgz", - "integrity": "sha512-oTVHV+oelUBtiu5iTuTNNZ0eLYsXSMxry4cgr30mayNkgIZL6qZ0IOQVPuSWGcyAaXKl/XgqwWHIC3a0khYVBA==", - "license": "BSD-3-Clause", - "dependencies": { - "long": "^5.3.2" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/@cesium/engine/node_modules/quickselect": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", - "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", - "license": "ISC" - }, - "node_modules/@cesium/engine/node_modules/rbush": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", - "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", - "license": "MIT", - "dependencies": { - "quickselect": "^3.0.0" - } - }, - "node_modules/@cesium/wasm-splats": { - "version": "0.1.0-alpha.2", - "resolved": "https://registry.npmjs.org/@cesium/wasm-splats/-/wasm-splats-0.1.0-alpha.2.tgz", - "integrity": "sha512-t9pMkknv31hhIbLpMa8yPvmqfpvs5UkUjgqlQv9SeO8VerCXOYnyP8/486BDaFrztM0A7FMbRjsXtNeKvqQghA==", - "license": "Apache-2.0" - }, - "node_modules/@cesium/widgets": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/@cesium/widgets/-/widgets-16.1.1.tgz", - "integrity": "sha512-um2JznRouJ2pfC0FNnEgJJU7WrCDrYFeyqCdiSsdbZgdW4q/R9ysDpj0VO/H2U4r4t6HDn9pA3HgAYpuIsdUWg==", - "license": "Apache-2.0", - "dependencies": { - "@cesium/engine": "^26.2.0", - "nosleep.js": "^0.12.0" - }, - "engines": { - "node": ">=22.0.0" - } - }, "node_modules/@commitlint/cli": { "version": "17.0.1", "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-17.0.1.tgz", @@ -5899,6 +5814,12 @@ "node": ">=14" } }, + "node_modules/@petamoriken/float16": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", + "integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==", + "license": "MIT" + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -6132,16 +6053,6 @@ "@sinonjs/commons": "^1.7.0" } }, - "node_modules/@spz-loader/core": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@spz-loader/core/-/core-0.3.1.tgz", - "integrity": "sha512-8qJ1WIBXaJu8HjnJAjYniE0kYcr0kCe5Hp7kDzYiGVvvd7zyrOBwbF5imoW5mvwx1Qba0hxGEK5R9jEoaHKJFA==", - "license": "Apache-2.0", - "engines": { - "node": ">=16", - "pnpm": ">=8" - } - }, "node_modules/@supercharge/promise-pool": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@supercharge/promise-pool/-/promise-pool-3.0.0.tgz", @@ -7764,12 +7675,6 @@ "url": "https://opencollective.com/turf" } }, - "node_modules/@tweenjs/tween.js": { - "version": "25.0.0", - "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", - "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", - "license": "MIT" - }, "node_modules/@types/accepts": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.5.tgz", @@ -7847,16 +7752,6 @@ "integrity": "sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==", "dev": true }, - "node_modules/@types/cesium": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/@types/cesium/-/cesium-1.70.0.tgz", - "integrity": "sha512-awCNt8Exv6oT+GALMgorfLYw43H5/99ZdKpG4Ovtft7dKpaAPkLuqpD1AobSjjtJ7xaOKKTj5fzYj+JQnPuzAQ==", - "deprecated": "This is a stub types definition. cesium provides its own type definitions, so you do not need this installed.", - "dev": true, - "dependencies": { - "cesium": "*" - } - }, "node_modules/@types/compression": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.7.2.tgz", @@ -8281,13 +8176,6 @@ "integrity": "sha512-RBz2uRZVCXuMg93WD//aTS5B120QlT4lR/gL+935QtGsKHLS6sCtZBaKfWjIfk7ZXv/r8mtGbwjVIee6/3XTow==", "dev": true }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, "node_modules/@types/uuid": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", @@ -8615,17 +8503,6 @@ "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==" }, - "node_modules/@zip.js/zip.js": { - "version": "2.8.36", - "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.36.tgz", - "integrity": "sha512-3CT63BXqYh781nZhNVOktg71oM1496BZhBHDjP5aOZTDmsL2nLkldbLDJFatFJt00j94TFnsZu2pWNijL4Eorw==", - "license": "BSD-3-Clause", - "engines": { - "bun": ">=0.7.0", - "deno": ">=1.0.0", - "node": ">=18.0.0" - } - }, "node_modules/abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -8986,24 +8863,6 @@ "node": ">=8.0.0" } }, - "node_modules/autolinker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/autolinker/-/autolinker-4.1.5.tgz", - "integrity": "sha512-vEfYZPmvVOIuE567XBVCsx8SBgOYtjB2+S1iAaJ+HgH+DNjAcrHem2hmAeC9yaNGWayicv4yR+9UaJlkF3pvtw==", - "license": "MIT", - "dependencies": { - "tslib": "^2.8.1" - }, - "engines": { - "pnpm": ">=10.10.0" - } - }, - "node_modules/autolinker/node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, "node_modules/axe-core": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.4.2.tgz", @@ -9345,12 +9204,6 @@ "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==" }, - "node_modules/bitmap-sdf": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz", - "integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg==", - "license": "MIT" - }, "node_modules/body-parser": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz", @@ -9593,37 +9446,6 @@ } ] }, - "node_modules/cesium": { - "version": "1.144.0", - "resolved": "https://registry.npmjs.org/cesium/-/cesium-1.144.0.tgz", - "integrity": "sha512-g1TX13KvTUhX9zjlJNn3De5BwuHyzuQzp1+1ITpGJJmIORCGnxVKAiu9/OkQByTWNYKgNtoEuPLrnWFhl0ao5g==", - "license": "Apache-2.0", - "workspaces": [ - "packages/engine", - "packages/widgets", - "packages/sandcastle" - ], - "dependencies": { - "@cesium/engine": "^26.2.0", - "@cesium/widgets": "^16.1.1", - "protobufjs": "^8.6.5" - }, - "engines": { - "node": ">=22.0.0" - } - }, - "node_modules/cesium/node_modules/protobufjs": { - "version": "8.7.2", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.7.2.tgz", - "integrity": "sha512-oTVHV+oelUBtiu5iTuTNNZ0eLYsXSMxry4cgr30mayNkgIZL6qZ0IOQVPuSWGcyAaXKl/XgqwWHIC3a0khYVBA==", - "license": "BSD-3-Clause", - "dependencies": { - "long": "^5.3.2" - }, - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -10474,15 +10296,6 @@ "node": ">=0.10.0" } }, - "node_modules/dompurify": { - "version": "3.4.13", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", - "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -10495,12 +10308,6 @@ "node": ">=8" } }, - "node_modules/draco3d": { - "version": "1.5.7", - "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", - "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", - "license": "Apache-2.0" - }, "node_modules/earcut": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", @@ -12180,6 +11987,59 @@ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.8.tgz", "integrity": "sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA==" }, + "node_modules/geotiff": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz", + "integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==", + "license": "MIT", + "dependencies": { + "@petamoriken/float16": "^3.9.3", + "lerc": "^3.0.0", + "pako": "^2.0.4", + "parse-headers": "^2.0.2", + "quick-lru": "^6.1.1", + "web-worker": "^1.5.0", + "xml-utils": "^1.10.2", + "zstddec": "^0.2.0" + }, + "engines": { + "node": ">=10.19" + } + }, + "node_modules/geotiff/node_modules/lerc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", + "integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==", + "license": "Apache-2.0" + }, + "node_modules/geotiff/node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "(MIT AND Zlib)" + }, + "node_modules/geotiff/node_modules/quick-lru": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", + "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -12362,7 +12222,8 @@ "node_modules/grapheme-splitter": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", - "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==" + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", + "dev": true }, "node_modules/handlebars": { "version": "4.7.7", @@ -15016,15 +14877,6 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/jsep": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", - "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", - "license": "MIT", - "engines": { - "node": ">= 10.16.0" - } - }, "node_modules/jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -15148,12 +15000,6 @@ "node": ">=4.0" } }, - "node_modules/kdbush": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", - "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", - "license": "ISC" - }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -15180,12 +15026,6 @@ "node": ">=6" } }, - "node_modules/ktx-parse": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-1.1.0.tgz", - "integrity": "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ==", - "license": "MIT" - }, "node_modules/language-subtag-registry": { "version": "0.3.21", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz", @@ -15201,12 +15041,6 @@ "language-subtag-registry": "~0.3.2" } }, - "node_modules/lerc": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lerc/-/lerc-2.0.0.tgz", - "integrity": "sha512-7qo1Mq8ZNmaR4USHHm615nEW2lPeeWJ3bTyoqFbd35DLx0LUH7C6ptt5FDCTAlbIzs3+WKrk5SkJvw8AFDE2hg==", - "license": "Apache-2.0" - }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -15435,18 +15269,6 @@ "node": ">= 8" } }, - "node_modules/mersenne-twister": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/mersenne-twister/-/mersenne-twister-1.1.0.tgz", - "integrity": "sha512-mUYWsMKNrm4lfygPkL3OfGzOPTR2DBlTkBNHM//F6hGp8cLThY897crAlk3/Jo17LEOOjQUrNAx6DvgO77QJkA==", - "license": "MIT" - }, - "node_modules/meshoptimizer": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.2.0.tgz", - "integrity": "sha512-davRZeIJbxJrE24cwQle7ZDsxjdk/OphNOV83oX+efQinyoHY9Jcyz3MHbaoG0qySZajldGztNZ1RN/T19PZsg==", - "license": "MIT" - }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -15802,12 +15624,6 @@ "node": ">=0.10.0" } }, - "node_modules/nosleep.js": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/nosleep.js/-/nosleep.js-0.12.0.tgz", - "integrity": "sha512-9d1HbpKLh3sdWlhXMhU6MMH+wQzKkrgfRkYV0EBdvt99YJfj0ilCJrWRDYG2130Tm4GXbEoTCx5b34JSaP+HhA==", - "license": "MIT" - }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -16198,22 +16014,6 @@ "node": ">=6" } }, - "node_modules/pako": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/pako/-/pako-3.0.1.tgz", - "integrity": "sha512-GupotUUI0mlhugKjUs4bjOwLt3nrehy9Ys2dxC0GtgVef5cnKggkDMmf2bq2poCCuVXopWPmqsc9VDT2iJUy+w==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], - "license": "(MIT AND Zlib)" - }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -16226,6 +16026,12 @@ "node": ">=6" } }, + "node_modules/parse-headers": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz", + "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", + "license": "MIT" + }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -18811,12 +18617,6 @@ "node": ">=6" } }, - "node_modules/urijs": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", - "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", - "license": "MIT" - }, "node_modules/url-value-parser": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/url-value-parser/-/url-value-parser-2.2.0.tgz", @@ -18937,6 +18737,12 @@ "node": ">= 8" } }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -19087,6 +18893,12 @@ } } }, + "node_modules/xml-utils": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", + "integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==", + "license": "CC0-1.0" + }, "node_modules/xml2js": { "version": "0.4.23", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", @@ -19223,6 +19035,12 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zstddec": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz", + "integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==", + "license": "MIT AND BSD-3-Clause" } }, "dependencies": { @@ -20592,76 +20410,6 @@ "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", "dev": true }, - "@cesium/engine": { - "version": "26.2.0", - "resolved": "https://registry.npmjs.org/@cesium/engine/-/engine-26.2.0.tgz", - "integrity": "sha512-BsfHZpg6LvlUSaTfvzfEzo9JQd9+PtMbzoylwls+aRj6bnqGNTF5F2sMC1eja5L3wg725NyiWQ8s84sIM9Dr6g==", - "requires": { - "@cesium/wasm-splats": "^0.1.0-alpha.2", - "@spz-loader/core": "0.3.1", - "@tweenjs/tween.js": "^25.0.0", - "@zip.js/zip.js": "^2.8.1", - "autolinker": "^4.0.0", - "bitmap-sdf": "^1.0.3", - "dompurify": "^3.3.0", - "draco3d": "^1.5.1", - "earcut": "3.0.2", - "grapheme-splitter": "^1.0.4", - "jsep": "^1.3.8", - "kdbush": "^4.0.1", - "ktx-parse": "^1.0.0", - "lerc": "^2.0.0", - "mersenne-twister": "^1.1.0", - "meshoptimizer": "^1.0.1", - "pako": "^3.0.0", - "protobufjs": "^8.6.5", - "rbush": "^4.0.1", - "topojson-client": "^3.1.0", - "urijs": "^1.19.7" - }, - "dependencies": { - "earcut": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", - "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==" - }, - "protobufjs": { - "version": "8.7.2", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.7.2.tgz", - "integrity": "sha512-oTVHV+oelUBtiu5iTuTNNZ0eLYsXSMxry4cgr30mayNkgIZL6qZ0IOQVPuSWGcyAaXKl/XgqwWHIC3a0khYVBA==", - "requires": { - "long": "^5.3.2" - } - }, - "quickselect": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", - "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==" - }, - "rbush": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/rbush/-/rbush-4.0.1.tgz", - "integrity": "sha512-IP0UpfeWQujYC8Jg162rMNc01Rf0gWMMAb2Uxus/Q0qOFw4lCcq6ZnQEZwUoJqWyUGJ9th7JjwI4yIWo+uvoAQ==", - "requires": { - "quickselect": "^3.0.0" - } - } - } - }, - "@cesium/wasm-splats": { - "version": "0.1.0-alpha.2", - "resolved": "https://registry.npmjs.org/@cesium/wasm-splats/-/wasm-splats-0.1.0-alpha.2.tgz", - "integrity": "sha512-t9pMkknv31hhIbLpMa8yPvmqfpvs5UkUjgqlQv9SeO8VerCXOYnyP8/486BDaFrztM0A7FMbRjsXtNeKvqQghA==" - }, - "@cesium/widgets": { - "version": "16.1.1", - "resolved": "https://registry.npmjs.org/@cesium/widgets/-/widgets-16.1.1.tgz", - "integrity": "sha512-um2JznRouJ2pfC0FNnEgJJU7WrCDrYFeyqCdiSsdbZgdW4q/R9ysDpj0VO/H2U4r4t6HDn9pA3HgAYpuIsdUWg==", - "requires": { - "@cesium/engine": "^26.2.0", - "nosleep.js": "^0.12.0" - } - }, "@commitlint/cli": { "version": "17.0.1", "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-17.0.1.tgz", @@ -23358,6 +23106,11 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.16.0.tgz", "integrity": "sha512-jpbpOY0ahD/uTF16h4/NkFatwlulCpkvecK8zVJgHRfjK/ymJAw1Cg45KXiVSJML8uiMmRBciea/+gebrD398g==" }, + "@petamoriken/float16": { + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", + "integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==" + }, "@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -23569,11 +23322,6 @@ "@sinonjs/commons": "^1.7.0" } }, - "@spz-loader/core": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@spz-loader/core/-/core-0.3.1.tgz", - "integrity": "sha512-8qJ1WIBXaJu8HjnJAjYniE0kYcr0kCe5Hp7kDzYiGVvvd7zyrOBwbF5imoW5mvwx1Qba0hxGEK5R9jEoaHKJFA==" - }, "@supercharge/promise-pool": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@supercharge/promise-pool/-/promise-pool-3.0.0.tgz", @@ -24876,11 +24624,6 @@ "d3-voronoi": "1.1.2" } }, - "@tweenjs/tween.js": { - "version": "25.0.0", - "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", - "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==" - }, "@types/accepts": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/accepts/-/accepts-1.3.5.tgz", @@ -24958,15 +24701,6 @@ "integrity": "sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w==", "dev": true }, - "@types/cesium": { - "version": "1.70.0", - "resolved": "https://registry.npmjs.org/@types/cesium/-/cesium-1.70.0.tgz", - "integrity": "sha512-awCNt8Exv6oT+GALMgorfLYw43H5/99ZdKpG4Ovtft7dKpaAPkLuqpD1AobSjjtJ7xaOKKTj5fzYj+JQnPuzAQ==", - "dev": true, - "requires": { - "cesium": "*" - } - }, "@types/compression": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.7.2.tgz", @@ -25389,12 +25123,6 @@ "integrity": "sha512-RBz2uRZVCXuMg93WD//aTS5B120QlT4lR/gL+935QtGsKHLS6sCtZBaKfWjIfk7ZXv/r8mtGbwjVIee6/3XTow==", "dev": true }, - "@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "optional": true - }, "@types/uuid": { "version": "8.3.4", "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-8.3.4.tgz", @@ -25582,11 +25310,6 @@ "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==" }, - "@zip.js/zip.js": { - "version": "2.8.36", - "resolved": "https://registry.npmjs.org/@zip.js/zip.js/-/zip.js-2.8.36.tgz", - "integrity": "sha512-3CT63BXqYh781nZhNVOktg71oM1496BZhBHDjP5aOZTDmsL2nLkldbLDJFatFJt00j94TFnsZu2pWNijL4Eorw==" - }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -25858,21 +25581,6 @@ "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==" }, - "autolinker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/autolinker/-/autolinker-4.1.5.tgz", - "integrity": "sha512-vEfYZPmvVOIuE567XBVCsx8SBgOYtjB2+S1iAaJ+HgH+DNjAcrHem2hmAeC9yaNGWayicv4yR+9UaJlkF3pvtw==", - "requires": { - "tslib": "^2.8.1" - }, - "dependencies": { - "tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" - } - } - }, "axe-core": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.4.2.tgz", @@ -26137,11 +25845,6 @@ "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==" }, - "bitmap-sdf": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/bitmap-sdf/-/bitmap-sdf-1.0.4.tgz", - "integrity": "sha512-1G3U4n5JE6RAiALMxu0p1XmeZkTeCwGKykzsLTCqVzfSDaN6S7fKnkIkfejogz+iwqBWc0UYAIKnKHNN7pSfDg==" - }, "body-parser": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz", @@ -26309,26 +26012,6 @@ "integrity": "sha512-0ZFjnlCaXNOAYcV7i+TtdKBp0L/3XEU2MF/x6Du1lrh+SRX4IfzIVL4HNJg5pB2PmFb8rszIGyOvsZnqqRoc2g==", "dev": true }, - "cesium": { - "version": "1.144.0", - "resolved": "https://registry.npmjs.org/cesium/-/cesium-1.144.0.tgz", - "integrity": "sha512-g1TX13KvTUhX9zjlJNn3De5BwuHyzuQzp1+1ITpGJJmIORCGnxVKAiu9/OkQByTWNYKgNtoEuPLrnWFhl0ao5g==", - "requires": { - "@cesium/engine": "^26.2.0", - "@cesium/widgets": "^16.1.1", - "protobufjs": "^8.6.5" - }, - "dependencies": { - "protobufjs": { - "version": "8.7.2", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.7.2.tgz", - "integrity": "sha512-oTVHV+oelUBtiu5iTuTNNZ0eLYsXSMxry4cgr30mayNkgIZL6qZ0IOQVPuSWGcyAaXKl/XgqwWHIC3a0khYVBA==", - "requires": { - "long": "^5.3.2" - } - } - } - }, "chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -27013,14 +26696,6 @@ "esutils": "^2.0.2" } }, - "dompurify": { - "version": "3.4.13", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", - "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", - "requires": { - "@types/trusted-types": "^2.0.7" - } - }, "dot-prop": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-5.3.0.tgz", @@ -27030,11 +26705,6 @@ "is-obj": "^2.0.0" } }, - "draco3d": { - "version": "1.5.7", - "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", - "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==" - }, "earcut": { "version": "2.2.4", "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", @@ -28296,6 +27966,38 @@ } } }, + "geotiff": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-3.0.5.tgz", + "integrity": "sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==", + "requires": { + "@petamoriken/float16": "^3.9.3", + "lerc": "^3.0.0", + "pako": "^2.0.4", + "parse-headers": "^2.0.2", + "quick-lru": "^6.1.1", + "web-worker": "^1.5.0", + "xml-utils": "^1.10.2", + "zstddec": "^0.2.0" + }, + "dependencies": { + "lerc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lerc/-/lerc-3.0.0.tgz", + "integrity": "sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==" + }, + "pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==" + }, + "quick-lru": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-6.1.2.tgz", + "integrity": "sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==" + } + } + }, "get-caller-file": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", @@ -28426,7 +28128,8 @@ "grapheme-splitter": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/grapheme-splitter/-/grapheme-splitter-1.0.4.tgz", - "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==" + "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", + "dev": true }, "handlebars": { "version": "4.7.7", @@ -30366,11 +30069,6 @@ "argparse": "^2.0.1" } }, - "jsep": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", - "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==" - }, "jsesc": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", @@ -30460,11 +30158,6 @@ "object.assign": "^4.1.2" } }, - "kdbush": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", - "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==" - }, "kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -30485,11 +30178,6 @@ "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", "dev": true }, - "ktx-parse": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-1.1.0.tgz", - "integrity": "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ==" - }, "language-subtag-registry": { "version": "0.3.21", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.21.tgz", @@ -30505,11 +30193,6 @@ "language-subtag-registry": "~0.3.2" } }, - "lerc": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/lerc/-/lerc-2.0.0.tgz", - "integrity": "sha512-7qo1Mq8ZNmaR4USHHm615nEW2lPeeWJ3bTyoqFbd35DLx0LUH7C6ptt5FDCTAlbIzs3+WKrk5SkJvw8AFDE2hg==" - }, "levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", @@ -30696,16 +30379,6 @@ "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", "dev": true }, - "mersenne-twister": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/mersenne-twister/-/mersenne-twister-1.1.0.tgz", - "integrity": "sha512-mUYWsMKNrm4lfygPkL3OfGzOPTR2DBlTkBNHM//F6hGp8cLThY897crAlk3/Jo17LEOOjQUrNAx6DvgO77QJkA==" - }, - "meshoptimizer": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.2.0.tgz", - "integrity": "sha512-davRZeIJbxJrE24cwQle7ZDsxjdk/OphNOV83oX+efQinyoHY9Jcyz3MHbaoG0qySZajldGztNZ1RN/T19PZsg==" - }, "methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -30974,11 +30647,6 @@ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true }, - "nosleep.js": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/nosleep.js/-/nosleep.js-0.12.0.tgz", - "integrity": "sha512-9d1HbpKLh3sdWlhXMhU6MMH+wQzKkrgfRkYV0EBdvt99YJfj0ilCJrWRDYG2130Tm4GXbEoTCx5b34JSaP+HhA==" - }, "npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -31282,11 +30950,6 @@ "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==" }, - "pako": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/pako/-/pako-3.0.1.tgz", - "integrity": "sha512-GupotUUI0mlhugKjUs4bjOwLt3nrehy9Ys2dxC0GtgVef5cnKggkDMmf2bq2poCCuVXopWPmqsc9VDT2iJUy+w==" - }, "parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -31296,6 +30959,11 @@ "callsites": "^3.0.0" } }, + "parse-headers": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz", + "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==" + }, "parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -33234,11 +32902,6 @@ } } }, - "urijs": { - "version": "1.19.11", - "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", - "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==" - }, "url-value-parser": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/url-value-parser/-/url-value-parser-2.2.0.tgz", @@ -33340,6 +33003,11 @@ "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.2.1.tgz", "integrity": "sha512-e0MO3wdXWKrLbL0DgGnUV7WHVuw9OUvL4hjgnPkIeEvESk74gAITi5G606JtZPp39cd8HA9VQzCIvA49LpPN5Q==" }, + "web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==" + }, "webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", @@ -33448,6 +33116,11 @@ "dev": true, "requires": {} }, + "xml-utils": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", + "integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==" + }, "xml2js": { "version": "0.4.23", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz", @@ -33547,6 +33220,11 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true + }, + "zstddec": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/zstddec/-/zstddec-0.2.0.tgz", + "integrity": "sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==" } } } diff --git a/package.json b/package.json index ab5908c..dc08141 100644 --- a/package.json +++ b/package.json @@ -57,12 +57,12 @@ "@turf/turf": "^6.5.0", "@types/geojson": "^7946.0.10", "axios": "^0.21.1", - "cesium": "^1.114.0", "compression": "^1.7.4", "config": "^3.3.12", "cross-fetch": "^3.1.5", "express": "^4.18.1", "express-openapi-validator": "^4.13.8", + "geotiff": "^3.0.5", "http-status-codes": "^2.2.0", "lodash": "^4.17.21", "node-fetch": "^3.3.1", @@ -79,7 +79,6 @@ "@map-colonies/eslint-config": "^3.1.1", "@map-colonies/prettier-config": "0.0.1", "@redocly/openapi-cli": "^1.0.0-beta.94", - "@types/cesium": "^1.70.0", "@types/compression": "^1.7.2", "@types/config": "^3.3.0", "@types/express": "^4.17.13", diff --git a/src/containerConfig.ts b/src/containerConfig.ts index 563b436..785fb45 100644 --- a/src/containerConfig.ts +++ b/src/containerConfig.ts @@ -51,7 +51,7 @@ export const registerExternalValues = async (options?: RegisterOptions): Promise if (!isSame(dataValue, Object.values(catalogRecordsServiceInstance.getValue()))) { catalogRecordsServiceInstance.setValue(Object.fromEntries(dataValue.map((record) => [record.id as string, record]))); - await demTerrainCacheManager.initTerrainProviders(dataValue); + await demTerrainCacheManager.initProviders(dataValue); logger.info({ msg: `CatalogRecords UPDATED - ${dataValue.length} records fetched`, diff --git a/src/heights/MOCKS/mockData.ts b/src/heights/MOCKS/mockData.ts index 0137ed3..7337aaa 100644 --- a/src/heights/MOCKS/mockData.ts +++ b/src/heights/MOCKS/mockData.ts @@ -305,7 +305,6 @@ export const positionsOutsideOfProviders = { export const emptyPositionsRequest = { positions: [], - radiansToOriginalPositionsMap: new Map(), }; export default { diff --git a/src/heights/controllers/heightsController.ts b/src/heights/controllers/heightsController.ts index 73621c8..b8e283a 100644 --- a/src/heights/controllers/heightsController.ts +++ b/src/heights/controllers/heightsController.ts @@ -1,13 +1,11 @@ import { RequestHandler } from 'express'; -import { Cartographic } from 'cesium'; import { injectable, inject } from 'tsyringe'; import { HeightsManager } from '../models/heightsManager'; -import { PosWithHeight, TerrainTypes } from '../interfaces'; +import { GeoPoint, PosWithHeight, TerrainTypes } from '../interfaces'; import { CommonErrors } from '../../common/commonErrors'; export interface GetHeightsPointsRequest { - positions: Cartographic[]; - radiansToOriginalPositionsMap: Map; + positions: GeoPoint[]; productType?: TerrainTypes; } diff --git a/src/heights/interfaces.ts b/src/heights/interfaces.ts index df163f6..e8aa20a 100644 --- a/src/heights/interfaces.ts +++ b/src/heights/interfaces.ts @@ -1,4 +1,4 @@ -import { Cartographic, CesiumTerrainProvider } from 'cesium'; +import type GeotiffHeightProvider from './models/geotiffHeightProvider'; export enum TerrainTypes { DSM = 'DSM', @@ -6,13 +6,19 @@ export enum TerrainTypes { MIXED = 'MIXED', } -export interface PosWithHeight extends Cartographic { - productId: string; +export interface GeoPoint { + longitude: number; // WGS84 degrees + latitude: number; // WGS84 degrees + height?: number | null; } -export interface PosWithTerrainProvider extends Cartographic { - terrainProvider?: CesiumTerrainProvider; +export interface PosWithHeight extends GeoPoint { + height: number | null; + productId?: string; +} + +export interface PosWithProvider extends GeoPoint { providerKey?: string; } -export type TerrainProviders = Record; +export type HeightProviders = Record; diff --git a/src/heights/middlewares/dataToDegrees.ts b/src/heights/middlewares/dataToDegrees.ts deleted file mode 100644 index 5f8f93d..0000000 --- a/src/heights/middlewares/dataToDegrees.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Logger } from '@map-colonies/js-logger'; -import type { GetHeightsHandler } from '../controllers/heightsController'; -import { PosWithHeight } from '../interfaces'; - -export const positionResAsDegreesMiddleware: (logger: Logger) => GetHeightsHandler = (logger) => { - return (req, res, next) => { - const startTime = performance.now(); - const posArray = res.locals.positions as PosWithHeight[]; - const radiansToOriginalPositionsMap = req.body.radiansToOriginalPositionsMap; - - const posInDegrees = posArray.map(({ latitude, longitude, ...other }) => { - // Using the original position requested. - const originalPositionKey = `${longitude};${latitude}`; - const [originalLongitude, originalLatitude] = radiansToOriginalPositionsMap.get(originalPositionKey)?.split(';') ?? []; - - return { - latitude: Number(originalLatitude), - longitude: Number(originalLongitude), - ...other, - }; - }); - - const endTime = performance.now(); - - logger.info({ - convertToDegreesTime: endTime - startTime, - pointsNumber: posArray.length, - location: '[positionResAsDegreesMiddleware]', - ...res.locals.reqCtx, - }); - res.locals.positions = posInDegrees; - - next(); - }; -}; diff --git a/src/heights/middlewares/dataToRadians.ts b/src/heights/middlewares/dataToRadians.ts deleted file mode 100644 index 7761d3c..0000000 --- a/src/heights/middlewares/dataToRadians.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { Cartographic } from 'cesium'; -import { Logger } from '@map-colonies/js-logger'; -import type { GetHeightsHandler } from '../controllers/heightsController'; - -export const convertReqPositionToRadiansMiddleware: (logger: Logger) => GetHeightsHandler = (logger) => { - return (req, res, next) => { - const startTime = performance.now(); - const posArray = req.body.positions; - const radiansToOriginalPositionsMap = new Map(); - - const positions = req.body.positions.map((pos) => { - const radiansPosition = Cartographic.fromDegrees(pos.longitude, pos.latitude); - - // Populating the dictionary between the original requested positions and the radians we work with internally. - // Key is "longitude;latitude" - radiansToOriginalPositionsMap.set(`${radiansPosition.longitude};${radiansPosition.latitude}`, `${pos.longitude};${pos.latitude}`); - - return radiansPosition; - }); - - const endTime = performance.now(); - - logger.info({ - convertToRadiansTime: endTime - startTime, - pointsNumber: posArray.length, - location: '[convertReqPositionToRadiansMiddleware]', - ...res.locals.reqCtx, - }); - - req.body = { ...req.body, radiansToOriginalPositionsMap, positions }; - - next(); - }; -}; diff --git a/src/heights/middlewares/validateRequest.ts b/src/heights/middlewares/validateRequest.ts index 2330ddc..5281f2a 100644 --- a/src/heights/middlewares/validateRequest.ts +++ b/src/heights/middlewares/validateRequest.ts @@ -1,8 +1,8 @@ import { Logger } from '@map-colonies/js-logger'; -import { Cartographic } from 'cesium'; import { CommonErrors } from '../../common/commonErrors'; import { IConfig } from '../../common/interfaces'; import { GetHeightsHandler } from '../controllers/heightsController'; +import { GeoPoint } from '../interfaces'; export const validateRequestMiddleware: (config: IConfig, logger: Logger, commonErrors: CommonErrors) => GetHeightsHandler = ( config, @@ -11,7 +11,7 @@ export const validateRequestMiddleware: (config: IConfig, logger: Logger, common ) => { return (req, res, next) => { const MAXIMUM_TILES_PER_REQUEST = config.has('maximumTilesPerRequest') ? config.get('maximumTilesPerRequest') : undefined; - const points: Cartographic[] = req.body.positions; + const points: GeoPoint[] = req.body.positions; if (points.length === 0) { logger.error({ msg: 'Points array is empty.', pointsNumber: points.length, location: '[validateRequestMiddleware]', ...res.locals.reqCtx }); diff --git a/src/heights/models/DEMTerrainCacheManager.ts b/src/heights/models/DEMTerrainCacheManager.ts index 3927a91..dfb204f 100644 --- a/src/heights/models/DEMTerrainCacheManager.ts +++ b/src/heights/models/DEMTerrainCacheManager.ts @@ -1,61 +1,75 @@ import { inject, injectable } from 'tsyringe'; import { IConfig } from 'config'; -import { CesiumTerrainProvider, Resource } from 'cesium'; +import { Logger } from '@map-colonies/js-logger'; import { PycswDemCatalogRecord } from '@map-colonies/mc-model-types'; -import { TerrainProviders } from '../interfaces'; +import { HeightProviders } from '../interfaces'; import { SERVICES } from '../../common/constants'; +import GeotiffHeightProvider from './geotiffHeightProvider'; -const QMESH_PROTOCOL = 'TERRAIN_QMESH'; -const TERRAINS_FOLDER = 'terrains/'; +const GEOTIFF_PROTOCOL = 'GEOTIFF'; +const COGS_FOLDER = 'cogs/'; @injectable() export default class DEMTerrainCacheManager { - public terrainProviders: TerrainProviders = {}; - public constructor(@inject(SERVICES.CONFIG) private readonly config: IConfig) {} + public heightProviders: HeightProviders = {}; - private transformRouteToService(cswUrl: string) { - const serviceURL = this.config.get('s3Gateway.url'); + public constructor(@inject(SERVICES.CONFIG) private readonly config: IConfig, @inject(SERVICES.LOGGER) private readonly logger: Logger) {} - return `${serviceURL}/${TERRAINS_FOLDER}${cswUrl.split(TERRAINS_FOLDER)[1]}`; - } + public async initProviders(demCatalogRecords: PycswDemCatalogRecord[]): Promise { + const heightProviders: HeightProviders = {}; - private getTokenResource(url: string): Resource { - const tokenProps: Record = { url }; + const geotiffRecords = demCatalogRecords.filter((record) => record.links?.some((link) => link.protocol === GEOTIFF_PROTOCOL)); + const samplingConcurrency = Number(this.config.get('samplingConcurrency')); - const injectionType = this.config.get('accessToken.injectionType'); - const attributeName = this.config.get('accessToken.attributeName'); - const tokenValue = this.config.get('accessToken.tokenValue'); + for (const record of geotiffRecords) { + const link = record.links?.find((currentLink) => currentLink.protocol === GEOTIFF_PROTOCOL); + if (!link) { + continue; + } - if (injectionType && injectionType.toLowerCase() === 'header') { - tokenProps.headers = { - [attributeName]: tokenValue, - } as Record; - } else if (injectionType && injectionType.toLowerCase() === 'queryparam') { - tokenProps.queryParameters = { - [attributeName]: tokenValue, - } as Record; + try { + const objectUrl = this.transformRouteToObjectUrl(link.url as string); + const { url, headers } = this.buildAuthenticatedUrl(objectUrl); + heightProviders[record.id as string] = await GeotiffHeightProvider.fromUrl(url, headers, samplingConcurrency); + } catch (err) { + this.logger.error({ + msg: 'Failed to open geotiff provider; skipping record', + recordId: record.id, + err, + location: '[DEMTerrainCacheManager] [initProviders]', + }); + } } - return new Resource({ ...(tokenProps as unknown as Resource) }); + this.heightProviders = heightProviders; } - public async initTerrainProviders(demCatalogRecords: PycswDemCatalogRecord[]): Promise { - const terrainProviders: TerrainProviders = {}; - - const qmeshRecords = demCatalogRecords.filter((record) => { - return record.links?.some((link) => link.protocol === QMESH_PROTOCOL); - }); + private transformRouteToObjectUrl(linkUrl: string): string { + const serviceURL = this.config.get('s3Gateway.url'); + // Take everything from the first 'cogs/' to the end — split()[1] would drop trailing + // segments if 'cogs/' appears more than once in the URL. + if (!linkUrl.includes(COGS_FOLDER)) { + throw new Error(`GEOTIFF link URL missing '${COGS_FOLDER}' segment: ${linkUrl}`); + } + const objectKey = linkUrl.slice(linkUrl.indexOf(COGS_FOLDER) + COGS_FOLDER.length); + return `${serviceURL}/${COGS_FOLDER}${objectKey}`; + } - for (const record of qmeshRecords) { - const recordProviderLink = record.links?.find((link) => link.protocol === QMESH_PROTOCOL); + private buildAuthenticatedUrl(objectUrl: string): { url: string; headers?: Record } { + const injectionType = this.config.get('accessToken.injectionType'); + const attributeName = this.config.get('accessToken.attributeName'); + const tokenValue = this.config.get('accessToken.tokenValue'); - if (recordProviderLink) { - const provider = await CesiumTerrainProvider.fromUrl(this.getTokenResource(this.transformRouteToService(recordProviderLink.url as string))); + if (injectionType.toLowerCase() === 'header') { + return { url: objectUrl, headers: { [attributeName]: tokenValue } }; + } - terrainProviders[record.id as string] = provider; - } + if (injectionType.toLowerCase() === 'queryparam') { + const url = new URL(objectUrl); + url.searchParams.set(attributeName, tokenValue); + return { url: url.toString() }; } - this.terrainProviders = terrainProviders; + return { url: objectUrl }; } } diff --git a/src/heights/models/geotiffHeightProvider.ts b/src/heights/models/geotiffHeightProvider.ts new file mode 100644 index 0000000..697195b --- /dev/null +++ b/src/heights/models/geotiffHeightProvider.ts @@ -0,0 +1,90 @@ +import { fromUrl } from 'geotiff'; +import type { GeoTIFFImage } from 'geotiff'; +import PromisePool from '@supercharge/promise-pool/dist'; +import { GeoPoint } from '../interfaces'; + +const DEFAULT_NODATA = -32768; +const DEFAULT_SAMPLING_CONCURRENCY = 16; +const BILINEAR_WINDOW_SIZE = 2; + +export default class GeotiffHeightProvider { + private constructor( + private readonly image: GeoTIFFImage, + private readonly originX: number, + private readonly originY: number, + private readonly pixelWidth: number, // degrees/pixel, positive + private readonly pixelHeight: number, // degrees/pixel, negative + private readonly rasterWidth: number, + private readonly rasterHeight: number, + private readonly noData: number, + private readonly samplingConcurrency: number + ) {} + + public static async fromUrl( + url: string, + headers?: Record, + samplingConcurrency: number = DEFAULT_SAMPLING_CONCURRENCY + ): Promise { + const tiff = await fromUrl(url, headers ? { headers } : {}); + const image = await tiff.getImage(0); + const [originX, originY] = image.getOrigin(); + const [pixelWidth, pixelHeight] = image.getResolution(); + const noData = image.getGDALNoData() ?? DEFAULT_NODATA; + + return new GeotiffHeightProvider( + image, + originX, + originY, + pixelWidth, + pixelHeight, + image.getWidth(), + image.getHeight(), + noData, + samplingConcurrency + ); + } + + public async sample(points: GeoPoint[]): Promise<(number | null)[]> { + // Bound concurrent range reads so a large batch doesn't open N simultaneous connections to the gateway. + const heights = new Array(points.length); + await PromisePool.for(points.map((point, index) => ({ point, index }))) + .withConcurrency(this.samplingConcurrency) + .handleError((error) => { + throw error; + }) + .process(async ({ point, index }) => { + heights[index] = await this.sampleOne(point); + }); + return heights; + } + + private async sampleOne(point: GeoPoint): Promise { + const fx = (point.longitude - this.originX) / this.pixelWidth; + const fy = (point.latitude - this.originY) / this.pixelHeight; + const px = Math.floor(fx); + const py = Math.floor(fy); + + // Need a full 2x2 neighborhood for bilinear interpolation. + if (px < 0 || py < 0 || px >= this.rasterWidth - 1 || py >= this.rasterHeight - 1) { + return null; + } + + const dx = fx - px; + const dy = fy - py; + + const raster = (await this.image.readRasters({ + window: [px, py, px + BILINEAR_WINDOW_SIZE, py + BILINEAR_WINDOW_SIZE], + })) as unknown as number[][]; + const band = raster[0]; + const [tl, tr, bl, br] = [band[0], band[1], band[2], band[3]]; + + if ([tl, tr, bl, br].some((value) => value === this.noData || Number.isNaN(value))) { + return null; + } + + const top = tl + (tr - tl) * dx; + const bottom = bl + (br - bl) * dx; + + return top + (bottom - top) * dy; + } +} diff --git a/src/heights/models/heightsManager.ts b/src/heights/models/heightsManager.ts index c262871..abe2075 100644 --- a/src/heights/models/heightsManager.ts +++ b/src/heights/models/heightsManager.ts @@ -1,17 +1,15 @@ -import { Cartographic, Math as CesiumMath, RequestScheduler, sampleTerrainMostDetailed } from 'cesium'; import { Polygon } from 'geojson'; import client from 'prom-client'; import { container, inject, injectable } from 'tsyringe'; import { Logger } from '@map-colonies/js-logger'; +import { PycswDemCatalogRecord } from '@map-colonies/mc-model-types'; import PromisePool from '@supercharge/promise-pool/dist'; import booleanPointInPolygon from '@turf/boolean-point-in-polygon'; import { Feature } from '@turf/turf'; -import { CommonErrors } from '../../common/commonErrors'; import { SERVICES } from '../../common/constants'; import { IConfig } from '../../common/interfaces'; import { CATALOG_RECORDS_MAP, DEM_TERRAIN_CACHE_MANAGER } from '../../containerConfig'; -import { PosWithHeight, PosWithTerrainProvider, TerrainTypes } from '../interfaces'; -import { cartographicArrayClusteringForHeightRequests } from '../utilities'; +import { GeoPoint, HeightProviders, PosWithHeight, PosWithProvider, TerrainTypes } from '../interfaces'; import DEMTerrainCacheManager from './DEMTerrainCacheManager'; import { CatalogRecords } from './catalogRecords'; @@ -32,20 +30,19 @@ export class HeightsManager { return container.resolve(CATALOG_RECORDS_MAP).getValue(); } - private get terrainProviders() { - return container.resolve(DEM_TERRAIN_CACHE_MANAGER).terrainProviders; + private get heightProviders() { + return container.resolve(DEM_TERRAIN_CACHE_MANAGER).heightProviders; } private readonly elevationsRequestsCounter?: client.Counter<'points_number'>; public constructor( @inject(SERVICES.LOGGER) private readonly logger: Logger, - @inject(CommonErrors) private readonly commonErrors: CommonErrors, @inject(SERVICES.CONFIG) private readonly config: IConfig, @inject(SERVICES.METRICS_REGISTRY) registry?: client.Registry ) { if (registry !== undefined) { - // eslint-disable-next-line @typescript-eslint/no-this-alias + // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; new client.Gauge({ name: 'elevations_current_requests_count', @@ -65,353 +62,155 @@ export class HeightsManager { } } - public async getPoints(points: Cartographic[], requestedProductType: TerrainTypes, reqCtx?: Record): Promise { - this.logger.info({ - pointsNumber: points.length, - location: '[HeightsManager] [getPoints]', - ...reqCtx, - }); + public async getPoints(points: GeoPoint[], requestedProductType: TerrainTypes, reqCtx?: Record): Promise { + this.logger.info({ pointsNumber: points.length, location: '[HeightsManager] [getPoints]', ...reqCtx }); - this.runningRequests++; // eslint-disable-next-line @typescript-eslint/naming-convention this.elevationsRequestsCounter?.inc({ points_number: points.length }); if (points.length === 0) { - this.runningRequests--; return []; } - const result = await this.samplePositionsHeights(points, requestedProductType, reqCtx); - - this.logger.info({ - totalRequests: result.totalRequests, - pointsNumber: points.length, - location: '[HeightsManager] [getPoints]', - ...reqCtx, - }); - - this.runningRequests--; - return result.positions; + this.runningRequests++; + try { + const result = await this.samplePositionsHeights(points, requestedProductType, reqCtx); + this.logger.info({ + totalRequests: result.totalRequests, + pointsNumber: points.length, + location: '[HeightsManager] [getPoints]', + ...reqCtx, + }); + return result.positions; + } finally { + this.runningRequests--; + } } private async samplePositionsHeights( - positionsArr: Cartographic[], + positionsArr: GeoPoint[], requestedProductType: TerrainTypes, reqCtx?: Record ): Promise<{ positions: PosWithHeight[]; totalRequests: number }> { - const MAX_REQ_PER_BATCH = 150; - const MAXIMUM_TILES_PER_REQUEST = this.config.has('maximumTilesPerRequest') ? this.config.get('maximumTilesPerRequest') : undefined; + // Snapshot the provider map and catalog once per request. Both are rebuilt wholesale on a + // background timer; resolving them fresh per access could pair a providerKey chosen against + // one snapshot with a provider/catalog map from a later rebuild (provider becomes undefined, + // sample() throws). + const heightProviders = this.heightProviders; + const catalogRecordsMap = this.catalogRecordsMap; - // Attach providers const attachProviderStart = performance.now(); - - const positionsWithProviders = this.attachTerrainProviderToPositions(positionsArr, requestedProductType); - - const attachProviderEnd = performance.now(); - + const positionsWithProviders = this.attachProviderToPositions(positionsArr, requestedProductType, heightProviders, catalogRecordsMap); this.logger.info({ - attachProviderTime: attachProviderEnd - attachProviderStart, + attachProviderTime: performance.now() - attachProviderStart, pointsNumber: positionsArr.length, location: '[HeightsManager] [samplePositionsHeights]', ...reqCtx, }); - // Positions Clustering - const clusteringStart = performance.now(); - - const { optimizedCluster: sampleTerrainClusteredPositions, totalRequests } = cartographicArrayClusteringForHeightRequests( - positionsWithProviders, - MAX_REQ_PER_BATCH - ); - - const clusteringEnd = performance.now(); - - this.logger.info({ - clusteringTime: clusteringEnd - clusteringStart, - pointsNumber: positionsArr.length, - location: '[HeightsManager] [samplePositionsHeights]', - ...reqCtx, + // Group points by the provider chosen for them (null = no provider), keeping each point's + // original index so results can be scattered back in input order. + const groups = new Map(); + positionsWithProviders.forEach((position, index) => { + const key = position.providerKey ?? null; + const bucket = groups.get(key) ?? []; + bucket.push({ point: { longitude: position.longitude, latitude: position.latitude }, index }); + groups.set(key, bucket); }); - if (typeof MAXIMUM_TILES_PER_REQUEST !== 'undefined' && totalRequests > MAXIMUM_TILES_PER_REQUEST) { - this.logger.error({ - msg: 'Points density is too low to compute.', - totalRequests: totalRequests, - location: '[HeightsManager] [samplePositionsHeights]', - ...reqCtx, - }); - this.runningRequests--; - throw this.commonErrors.POINTS_DENSITY_TOO_LOW_ERROR; - } - - const finalPositionsWithHeights: PosWithHeight[] = []; - - // Terrain sampling batches - const { results } = await PromisePool.for(sampleTerrainClusteredPositions) - .withConcurrency(sampleTerrainClusteredPositions.length) - .useCorrespondingResults() - .process(async (batch) => { - const samplingStart = performance.now(); - - if (batch.providerKey === null) { - this.logger.info({ - msg: `No terrain to sample these positions`, - positionsOutsideOfProviders: JSON.stringify(batch.positions), - positionsOutsideOfProvidersCount: batch.positions.length, - pointsNumber: positionsArr.length, - location: '[HeightsManager] [samplePositionsHeights]', - ...reqCtx, + const groupEntries = [...groups.entries()]; + const finalPositionsWithHeights = new Array(positionsWithProviders.length); + + await PromisePool.for(groupEntries) + .withConcurrency(Math.max(1, groupEntries.length)) + .handleError((error) => { + // Without this, promise-pool silently collects sample() failures and resolves normally, + // leaving height slots undefined. Rethrow so the request fails loudly instead. + throw error; + }) + .process(async ([providerKey, entries]) => { + if (providerKey === null) { + entries.forEach(({ point, index }) => { + finalPositionsWithHeights[index] = { ...point, height: null } as PosWithHeight; }); - - return batch.positions; + return; } - const provider = this.terrainProviders[batch.providerKey]; - const qmeshRecord = this.catalogRecordsMap[batch.providerKey]; - - const positionsWithHeights = await sampleTerrainMostDetailed(provider, batch.positions); - - // Attach additional info on top of each position returned via the catalog record (productId added as ref to products dictionary metadata). - positionsWithHeights.forEach((pos) => { - (pos as unknown as Record)['productId'] = qmeshRecord['productId']; - }); - - const samplingEnd = performance.now(); + const samplingStart = performance.now(); + const provider = heightProviders[providerKey]; + const record = catalogRecordsMap[providerKey]; + const heights = await provider.sample(entries.map(({ point }) => point)); this.logger.info({ - terrainSamplingTime: samplingEnd - samplingStart, - providerId: batch.providerKey, + terrainSamplingTime: performance.now() - samplingStart, + providerId: providerKey, pointsNumber: positionsArr.length, location: '[HeightsManager] [samplePositionsHeights]', ...reqCtx, }); - return positionsWithHeights as PosWithHeight[]; + entries.forEach(({ point, index }, i) => { + const height = heights[i]; + finalPositionsWithHeights[index] = { + ...point, + height, + ...(height !== null && record.productId !== undefined ? { productId: record.productId } : {}), + } as PosWithHeight; + }); }); - finalPositionsWithHeights.push(...(results as PosWithHeight[][]).flat()); - - // CESIUM without involving a VIEWER (visualization) behaves differently and doesn't manage a REQUESTS cleanup - // Full EXPLANATOIN is here: https://github.com/CesiumGS/cesium/issues/7670 - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - RequestScheduler.update(); - - return { positions: finalPositionsWithHeights, totalRequests }; + return { positions: finalPositionsWithHeights, totalRequests: groupEntries.length }; } - private attachTerrainProviderToPositions(positions: Cartographic[], requestedProductType: TerrainTypes): PosWithTerrainProvider[] { + private attachProviderToPositions( + positions: GeoPoint[], + requestedProductType: TerrainTypes, + heightProviders: HeightProviders, + catalogRecordsMap: Record + ): PosWithProvider[] { /* - * Filter terrain providers by requested product type - * Filter terrain providers by footprint point intersection - * Sort by highest resolution (Lower is better) - * Attach to the point the (first) provider with highest resolution + * Filter providers by requested product type (unless MIXED) + * Filter providers by footprint point intersection + * Sort by highest resolution (lower resolutionMeter is better), tie-break on newest updateDate + * Attach the best provider key to the point */ + // Provider list and product-type filter are the same for every point — compute once. + const providerEntries = Object.entries(heightProviders); + const productTypeFiltered = + requestedProductType !== TerrainTypes.MIXED + ? providerEntries.filter(([key]) => catalogRecordsMap[key].productType?.includes(requestedProductType)) + : providerEntries; return positions.map((position) => { - const terrainProvidersEntries = Object.entries(this.terrainProviders); + const footprintFiltered = productTypeFiltered.filter(([key]) => + booleanPointInPolygon([position.longitude, position.latitude], catalogRecordsMap[key].footprint as Feature) + ); - // Filter terrain providers by requested product type if not MIXED - const productTypeFilteredTerrains = - requestedProductType !== TerrainTypes.MIXED - ? terrainProvidersEntries.filter(([terrainKey]) => { - const qmeshRecord = this.catalogRecordsMap[terrainKey]; - return qmeshRecord.productType?.includes(requestedProductType); - }) - : terrainProvidersEntries; - - // Filter terrain providers by footprint point intersection - const terrainsFilterByFootprint = productTypeFilteredTerrains.filter(([terrainKey]) => { - const qmeshRecord = this.catalogRecordsMap[terrainKey]; - const isPointInFootprint = booleanPointInPolygon( - [CesiumMath.toDegrees(position.longitude), CesiumMath.toDegrees(position.latitude)], - qmeshRecord.footprint as Feature - ); - return isPointInFootprint; - }); - - // Sort by highest resolution (Lower is better) - const sortedTerrainsByResolution = terrainsFilterByFootprint.sort(([terrainAKey], [terrainBKey]) => { + const sorted = footprintFiltered.sort(([aKey], [bKey]) => { const A_BEFORE_B = -1; const B_BEFORE_A = 1; - - const qmeshRecordA = this.catalogRecordsMap[terrainAKey]; - const qmeshRecordB = this.catalogRecordsMap[terrainBKey]; - - let dateB = null, - dateA = null; + const recordA = catalogRecordsMap[aKey]; + const recordB = catalogRecordsMap[bKey]; switch (true) { - case (qmeshRecordA.resolutionMeter as number) < (qmeshRecordB.resolutionMeter as number): + case (recordA.resolutionMeter as number) < (recordB.resolutionMeter as number): return A_BEFORE_B; - case (qmeshRecordA.resolutionMeter as number) > (qmeshRecordB.resolutionMeter as number): + case (recordA.resolutionMeter as number) > (recordB.resolutionMeter as number): return B_BEFORE_A; - default: - // Equal resolutions, compare update date - dateB = new Date(qmeshRecordB.updateDate as string | number | Date); - dateA = new Date(qmeshRecordA.updateDate as string | number | Date); + default: { + const dateB = new Date(recordB.updateDate as string | number | Date); + const dateA = new Date(recordA.updateDate as string | number | Date); return dateB.getTime() - dateA.getTime(); + } } }); - if (sortedTerrainsByResolution.length === 0) { - return { - ...position, - } as PosWithTerrainProvider; + if (sorted.length === 0) { + return { ...position } as PosWithProvider; } - // Attach to the point the (first) provider with highest resolution - const [terrainKey, provider] = sortedTerrainsByResolution[0]; - - return { - ...position, - providerKey: terrainKey, - terrainProvider: provider, - } as PosWithTerrainProvider; + const [providerKey] = sorted[0]; + return { ...position, providerKey } as PosWithProvider; }); } - - // public async getPath(path: GeoJSON): Promise { - // this.logger.info({ msg: 'Getting path heights' }); - // const start = new Date(); - // const result = await this.sample(path, { level: 11 }); - // const end = new Date(); - // console.log(result); - // console.log(`${end.getTime() - start.getTime()} ms`); - // return result; - // } - - // public async getPolygon(polygon: GeoJSON): Promise { - // this.logger.info({ msg: 'Getting polygon heights' }); - - // const polygonBbox = bbox(polygon); - // const cellSide = 600.0; // distance between points (in units) - // const options = { - // units: 'meters' as Units, // used in calculating cellSide, can be: degrees, radians, miles, or kilometers (default) - // mask: (polygon as FeatureCollection).features[0] as Feature // if passed a Polygon or MultiPolygon, the grid Points will be created only inside it - // }; - - // // Creates a Point grid from a bounding box, FeatureCollection or Feature. - // const polygonPointGrid = pointGrid(polygonBbox, cellSide, options); // grid of points inside the given polygon - - // // let bbox = turf.bbox(polygon); - // // let cellWidth = 0.05; - // // let cellHeight = 0.05; - - // // let bufferedBbox = turf.bbox(turf.buffer(polygon, cellWidth, {units: 'kilometers'})); - // // let options = { units: "kilometers", mask: polygon}; - // // let squareGrid = turf.squareGrid( - // // bufferedBbox, - // // // bbox, - // // cellWidth, - // // options - // // ); - - // // turf.featureEach(squareGrid, function (currentFeature, featureIndex) { - // // let intersected = turf.intersect(polygon.features[0], currentFeature); - // // }); - - // const start = new Date(); - // const result = await this.sample(polygonPointGrid, { level: 11 }); - // const end = new Date(); - // console.log(result); - // console.log(`${end.getTime() - start.getTime()} ms`); - // return result; - // } - - // public async getHeights(geojson: GeoJSON): Promise { - // this.logger.info({ msg: 'Getting heights' }); - // const start = new Date(); - // const result = await this.sample(geojson, { level: 11 }); - // const end = new Date(); - // console.log(result); - // console.log(`${end.getTime() - start.getTime()} ms`); - // return result; - // } - - // public async getHeight(coordinates: ICoordinates): Promise { - // this.logger.info({ msg: 'Getting height' }); - // const point: GeoJSON = { - // "type": "Feature", - // "properties": {}, - // "geometry": { - // "type": "Point", - // "coordinates": [ - // ...Object.values(coordinates).map(value => +value) - // ] - // } - // }; - // const start = new Date(); - // const result = await this.sample(point, { level: 11 }); - // const end = new Date(); - // console.log(result); - // console.log(`${end.getTime() - start.getTime()} ms`); - // return { dem: ((result as Feature).geometry as Point).coordinates[2] }; - // } - - // private async sample(data: GeoJSON, options?: { level: number }): Promise { - // return new Promise((resolve, reject) => { - // const positions: Cartographic[] = []; - // const dPositions: {longitude: number, latitude: number}[] = []; - // const paths: string[][] = []; - - // traverse(data).forEach(function() { - // // @ts-ignore - // if (this.isLeaf && Number.isFinite(this.node) && Array.isArray(this.parent?.node) && this.parent?.node.length > 1 && this.key == '0') { - // positions.push(Cartographic.fromDegrees(this.parent?.node[0], this.parent?.node[1])); - // dPositions.push({ longitude: th // Here we should attach additional info on top of each position returned via the catalog record.is.parent?.node[0], latitude: this.parent?.node[1] }); - // paths.push(this.parent?.path as string[]); - // } - // }); - - // if (positions.length && dPositions.length) { - - // const CesiumProvider = new CesiumTerrainProvider({ - // url: config.get('terrainProviderUrl') - // }); - - // const MIN_ZOOM_LEVEL = 0; - // const MAX_ZOOM_LEVEL = 11; - - // let sampleTerrainPromise; - // if (options?.level) { - // if ((options.level < MIN_ZOOM_LEVEL) || (options.level > MAX_ZOOM_LEVEL)) { - // return reject(new Error(`Level must between ${MIN_ZOOM_LEVEL} and ${MAX_ZOOM_LEVEL}`)); - // } - // sampleTerrainPromise = sampleTerrain(CesiumProvider, options.level, positions); - // } else { - // sampleTerrainPromise = sampleTerrainMostDetailed(CesiumProvider, positions); - // // sampleTerrainPromise = fetch('https://dem-int-proxy-production-nginx-s3-gateway-route-integration.apps.j1lk3njp.eastus.aroapp.io/terrains/srtm100/11/2446/1394.terrain?token=eyJhbGciOiJSUzI1NiIsImtpZCI6Im1hcC1jb2xvbmllcy1pbnQifQ.eyJkIjpbInJhc3RlciIsInJhc3RlcldtcyIsInJhc3RlckV4cG9ydCIsImRlbSIsInZlY3RvciIsIjNkIl0sImlhdCI6MTY3NDYzMjM0Niwic3ViIjoibWFwY29sb25pZXMtYXBwIiwiaXNzIjoibWFwY29sb25pZXMtdG9rZW4tY2xpIn0.D1u28gFlxf_Z1bzIiRHZonUgrdWwhZy8DtmQj15cIzaABRUrGV2n_OJlgWTuNfrao0SbUZb_s0_qUUW6Gz_zO3ET2bVx5xQjBu0CaIWdmUPDjEYr6tw-eZx8EjFFIyq3rs-Fo0daVY9cX1B2aGW_GeJir1oMnJUURhABYRoh60azzl_utee9UdhDpnr_QElNtzJZIKogngsxCWp7tI7wkTuNCBaQM7aLEcymk0ktxlWEAt1E0nGt1R-bx-HnPeeQyZlxx4UQ1nuYTijpz7N8poaCCExOFeafj9T7megv2BzTrKWgfM1eai8srSgNa3I5wKuW0EyYnGZxdbJe8aseZg&kuku='+dPositions[i].longitude.toFixed(8)); - // } - // sampleTerrainPromise.then(() => { - // paths.forEach((path, i) => { - // if (path.length) { - // traverse(data).set(path, [dPositions[i].longitude, dPositions[i].latitude, positions[i].height ? Number(positions[i].height.toFixed(2)) : undefined]); - // } else { - // return reject (new Error('Invalid GeoJSON input file')); - // } - // }); - // return resolve(data); - // }).catch(function (e) { - // return reject(e); - // }); - // // sampleTerrainPromise.then(() => { - // // return resolve({ - // // "type": "Feature", - // // "properties": { - // // "al": "kuku" - // // }, - // // "geometry": { - // // "coordinates": [ - - // // ], - // // "type": "Polygon" - // // }}) - // // }); - - // } else { - // return reject(new Error('No coordinates found in input file')); - // } - // }); - // } } diff --git a/src/heights/routes/heightsRouter.ts b/src/heights/routes/heightsRouter.ts index e1cfd9f..622a172 100644 --- a/src/heights/routes/heightsRouter.ts +++ b/src/heights/routes/heightsRouter.ts @@ -7,8 +7,6 @@ import { IConfig } from '../../common/interfaces'; import { PRODUCT_METADATA_FIELDS } from '../../containerConfig'; import { HeightsController } from '../controllers/heightsController'; import { createReqCtxMiddleware } from '../middlewares/createReqCtx'; -import { positionResAsDegreesMiddleware } from '../middlewares/dataToDegrees'; -import { convertReqPositionToRadiansMiddleware } from '../middlewares/dataToRadians'; import { sendResponseMiddleware } from '../middlewares/sendResponse'; import { validateRequestMiddleware } from '../middlewares/validateRequest'; import { addProductsDictionaryMiddleware } from '../middlewares/addProductsDictionary'; @@ -25,9 +23,7 @@ const heightsRouterFactory: FactoryFunction = (dependencyContainer) => { '/points', createReqCtxMiddleware(logger), validateRequestMiddleware(config, logger, commonErrors), - convertReqPositionToRadiansMiddleware(logger), controller.getPoints, - positionResAsDegreesMiddleware(logger), addProductsDictionaryMiddleware(logger, productMetadataFields), sendResponseMiddleware(logger) ); diff --git a/src/heights/utilities.ts b/src/heights/utilities.ts index 565379c..cfeae87 100644 --- a/src/heights/utilities.ts +++ b/src/heights/utilities.ts @@ -1,94 +1,13 @@ -/* eslint-disable import/exports-last */ - import crypto from 'crypto'; -import { Cartographic } from 'cesium'; -import { PosWithTerrainProvider } from './interfaces'; - -export interface PositionsWithProviderKey { - positions: Cartographic[]; - providerKey: string | null; -} - -const NO_PROVIDER_KEY = 'NO-PROVIDER'; - -// eslint-disable-next-line @typescript-eslint/naming-convention -const createClustersByTerrainProvider = (data: PositionsWithProviderKey[], maxRequestsPerBatch: number): PositionsWithProviderKey[] => { - const clusters: (PositionsWithProviderKey & { count: number })[] = []; - - // Group positions by providerKey and split into subArrays - for (let i = 0; i < data.length; i++) { - const item = data[i]; - const positions = item.positions; - const batchedPos = clusters.find((value) => value.providerKey === item.providerKey && value.count < maxRequestsPerBatch); - if (batchedPos) { - batchedPos.positions.push(...positions); - batchedPos.count++; - } else { - clusters.push({ providerKey: item.providerKey === NO_PROVIDER_KEY ? null : item.providerKey, positions, count: 1 }); - } - } - - // @ts-ignore - return clusters; -}; - -export const cartographicArrayClusteringForHeightRequests = ( - positions: PosWithTerrainProvider[], - maxRequestsPerBatch = 1 -): { optimizedCluster: PositionsWithProviderKey[]; totalRequests: number } => { - const positionsClustersByTile = new Map(); - - positions.forEach((pos) => { - // Check if position doesn't have attached terrain provider - if (typeof pos.terrainProvider === 'undefined' || typeof pos.providerKey === 'undefined') { - const positionTilePath = NO_PROVIDER_KEY; - - const currentPosInTile = positionsClustersByTile.get(positionTilePath) ?? []; - - positionsClustersByTile.set(positionTilePath, [ - ...currentPosInTile, - { latitude: pos.latitude, longitude: pos.longitude, height: null } as unknown as Cartographic, - ]); - - return; - } - - // Get max level for position. - const maxLevelAtPos = pos.terrainProvider.availability!.computeMaximumLevelAtPosition(pos); - - // Get correspond tile. - const posTile = pos.terrainProvider.tilingScheme.positionToTileXY(pos, maxLevelAtPos); - - // Create unique key per tile matched. - const positionTilePath = `${pos.providerKey}_${maxLevelAtPos}_${posTile.x}_${posTile.y}`; - - // Add position to pos array in dictionary - const currentPosInTile = positionsClustersByTile.get(positionTilePath) ?? []; - - positionsClustersByTile.set(positionTilePath, [...currentPosInTile, { latitude: pos.latitude, longitude: pos.longitude } as Cartographic]); - }); - - // Create batches of length up to max requests, by provider. - - const clusteredPositionsWithProviderKey: PositionsWithProviderKey[] = Array.from(positionsClustersByTile).map(([key, val]) => { - const clusterProviderKey = key.split('_')[0]; - return { providerKey: clusterProviderKey, positions: val }; - }); - - const newOptimizedCluster = createClustersByTerrainProvider(clusteredPositionsWithProviderKey, maxRequestsPerBatch); - - const totalRequests = positionsClustersByTile.size - +positionsClustersByTile.has(NO_PROVIDER_KEY); - - return { optimizedCluster: newOptimizedCluster, totalRequests: totalRequests }; -}; -export const generateChecksum = (str: string, algorithm?: string, encoding?: crypto.BinaryToTextEncoding) => { +export const generateChecksum = (str: string, algorithm?: string, encoding?: crypto.BinaryToTextEncoding): string => { return crypto - .createHash(algorithm || 'md5') + .createHash(algorithm ?? 'md5') .update(str, 'utf8') - .digest(encoding || 'hex'); + .digest(encoding ?? 'hex'); }; -export const isSame = (src1: any, src2: any) => { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const isSame = (src1: any, src2: any): boolean => { return generateChecksum(JSON.stringify(src1)) === generateChecksum(JSON.stringify(src2)); }; diff --git a/src/workerCatalogRecords.ts b/src/workerCatalogRecords.ts index d91ff69..a33c2e2 100644 --- a/src/workerCatalogRecords.ts +++ b/src/workerCatalogRecords.ts @@ -19,15 +19,15 @@ const END_RECORD = 1000; const getCatalogRecords = async (): Promise => { const res = await cswClient.getRecords(START_RECORD, END_RECORD, { filter: [ - // ******* DEM profile has special BOOLEAN field 'mc:hasTerrain' which holds an indication of TERRAIN_PROVIDER - // ******* Probably there is a bug when filtering by BOOLEAN field. Instead used LIKE filter that looks in LINKS field + // ******* DEM profile links carry the object protocol. We match records exposing a GEOTIFF link + // ******* (COG served from the S3 gateway) via a LIKE filter on the LINKS field. // { // field: 'mc:hasTerrain', // eq: 'True', // }, { field: 'mc:links', - like: 'TERRAIN_QMESH', + like: 'GEOTIFF', }, { field: 'mc:productStatus', diff --git a/tests/configurations/integration/jest.config.js b/tests/configurations/integration/jest.config.js index efa2634..7556c70 100644 --- a/tests/configurations/integration/jest.config.js +++ b/tests/configurations/integration/jest.config.js @@ -9,7 +9,15 @@ module.exports = { }, coverageReporters: ['text', 'html'], collectCoverage: true, - collectCoverageFrom: ['/src/**/*.ts', '!*/node_modules/', '!/vendor/**', '!*/common/**', '!**/models/**', '!/src/*'], + collectCoverageFrom: [ + '/src/**/*.ts', + '!*/node_modules/', + '!/vendor/**', + '!*/common/**', + '!**/models/**', + '!/src/*', + '!/src/heights/utilities.ts', // helper reached only via CSW worker (inert in integration); unit-covered instead + ], coverageDirectory: '/coverage', rootDir: '../../../.', testMatch: ['/tests/integration/**/*.spec.ts'], diff --git a/tests/configurations/jest.setup.ts b/tests/configurations/jest.setup.ts index 38f8828..b6b10a2 100644 --- a/tests/configurations/jest.setup.ts +++ b/tests/configurations/jest.setup.ts @@ -1,20 +1,5 @@ -/* eslint-disable @typescript-eslint/no-unsafe-return */ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ - -import Cesium, { Cartographic, CesiumTerrainProvider } from 'cesium'; import 'reflect-metadata'; -jest.mock('cesium', () => { - const originalCesium = jest.requireActual('cesium'); - - return { - // eslint-disable-next-line @typescript-eslint/naming-convention - __esModule: true, - ...originalCesium, - sampleTerrainMostDetailed: jest - .fn() - .mockImplementation(async (provider: CesiumTerrainProvider, positions: Cesium.Cartographic[]): Promise => { - return Promise.resolve(positions.map((pos) => ({ ...pos, height: 66 } as Cartographic))); - }), - }; -}); +// geotiff ships a nested ESM-only quick-lru that Jest's CJS runtime cannot parse. +// Mock the module so the real graph never loads; tests stub provider behavior directly. +jest.mock('geotiff', () => ({ fromUrl: jest.fn() })); diff --git a/tests/configurations/testContainerConfig.ts b/tests/configurations/testContainerConfig.ts index ec1f0d9..d1af628 100644 --- a/tests/configurations/testContainerConfig.ts +++ b/tests/configurations/testContainerConfig.ts @@ -6,8 +6,11 @@ import { PycswDemCatalogRecord } from '@map-colonies/mc-model-types'; import { SERVICES } from '../../src/common/constants'; import { CATALOG_RECORDS_MAP, DEM_TERRAIN_CACHE_MANAGER, PRODUCT_METADATA_FIELDS } from '../../src/containerConfig'; import DEMTerrainCacheManager from '../../src/heights/models/DEMTerrainCacheManager'; +import GeotiffHeightProvider from '../../src/heights/models/geotiffHeightProvider'; import { CatalogRecords } from '../../src/heights/models/catalogRecords'; +const MOCK_SAMPLE_HEIGHT = 100; + async function registerTestValues(shouldInitTerrainProviders = true): Promise { /* eslint-disable */ const demTestCatalogRecords = [ @@ -74,8 +77,8 @@ async function registerTestValues(shouldInitTerrainProviders = true): Promise Promise.resolve(points.map(() => MOCK_SAMPLE_HEIGHT)), + } as unknown as GeotiffHeightProvider); + await (async (): Promise => { const catalogTestRecordsServiceInstance = container.resolve(CATALOG_RECORDS_MAP); const demTestTerrainCacheManager = container.resolve(DEM_TERRAIN_CACHE_MANAGER); @@ -157,7 +164,7 @@ async function registerTestValues(shouldInitTerrainProviders = true): Promise('productMetadataFields').split(','); - cesiumTerrainProviderFromUrlSpy = jest.spyOn(CesiumTerrainProvider, 'fromUrl'); - - cesiumTerrainProviderFromUrlSpy.mockReturnValue({ - availability: { - available: true, - computeMaximumLevelAtPosition: () => { - return 13; - }, - }, - tilingScheme: { - positionToTileXY: (position: Cartographic) => { - // Making sure there are no overlapping tiles for any of the given positions, - // so that each position is a "request" - // (Assuming unique positions) - return new Cartesian2(position.latitude, position.longitude); - }, - }, - }); - const app = await getApp({ override: [{ token: SERVICES.LOGGER, provider: { useValue: jsLogger({ enabled: false }) } }], }); @@ -136,7 +121,7 @@ describe('heights', function () { for (const position of (response.body as GetHeightsPointsResponse).data) { expect(position['latitude'] && position['longitude']).toBeDefined(); - const isNullHeight = (position.height as number | null) === null; + const isNullHeight = position.height === null; expect(typeof position['productId'] === 'undefined').toEqual(isNullHeight); } @@ -163,4 +148,61 @@ describe('heights', function () { }); }); }); + + describe('Given seeded geotiff providers', function () { + beforeAll(async function () { + jest.spyOn(GeotiffHeightProvider, 'fromUrl').mockResolvedValue({ + sample: async (points: { longitude: number; latitude: number }[]) => Promise.resolve(points.map(() => 123)), + } as unknown as GeotiffHeightProvider); + + const records = [ + { + id: 'rec1', + productId: 'test_prod', + productType: 'DTM', + resolutionMeter: 30, + updateDate: '2023-05-08T17:44:01.000Z', + absoluteAccuracyLEP90: 9e-7, + productStatus: 'PUBLISHED', + footprint: { + type: 'Polygon', + coordinates: [ + [ + [34, 32], + [34, 33], + [36, 33], + [36, 32], + [34, 32], + ], + ], + }, + links: [{ protocol: 'GEOTIFF', url: 'https://tiles-dev.mapcolonies.net/api/dem/v1/cogs/a.tif' }], + }, + ]; + + container + .resolve(CATALOG_RECORDS_MAP) + .setValue(Object.fromEntries(records.map((r) => [r.id, r])) as unknown as Record); + await container.resolve(DEM_TERRAIN_CACHE_MANAGER).initProviders(records as unknown as PycswDemCatalogRecord[]); + }); + + afterAll(function () { + container.resolve(CATALOG_RECORDS_MAP).setValue({}); + jest.restoreAllMocks(); + }); + + it('returns real heights, productId, and products metadata for a point inside a footprint', async function () { + const response = await requestSender.getPoints({ positions: [{ longitude: 35.0, latitude: 32.5 }] } as GetHeightsPointsRequest); + + expect(response.status).toBe(httpStatusCodes.OK); + const body = response.body as GetHeightsPointsResponse; + expect(body.data).toHaveLength(1); + expect(body.data[0].height).toBe(123); + expect(body.data[0].productId).toBe('test_prod'); + expect(body.products['test_prod']).toBeDefined(); + ['productType', 'updateDate', 'resolutionMeter', 'absoluteAccuracyLEP90'].forEach((field) => { + expect((body.products['test_prod'] as Record)[field]).toBeDefined(); + }); + }); + }); }); diff --git a/tests/unit/heights/middlewares/heightsMiddlewares.spec.ts b/tests/unit/heights/middlewares/heightsMiddlewares.spec.ts index a93eea5..8fc1b26 100644 --- a/tests/unit/heights/middlewares/heightsMiddlewares.spec.ts +++ b/tests/unit/heights/middlewares/heightsMiddlewares.spec.ts @@ -1,6 +1,5 @@ import { Logger } from '@map-colonies/js-logger'; import { PycswDemCatalogRecord } from '@map-colonies/mc-model-types'; -import { Cartographic } from 'cesium'; import { NextFunction, Request, Response } from 'express'; import { container } from 'tsyringe'; import { isUuid } from 'uuidv4'; @@ -8,12 +7,9 @@ import { CommonErrors } from '../../../../src/common/commonErrors'; import { SERVICES } from '../../../../src/common/constants'; import { IConfig } from '../../../../src/common/interfaces'; import { CATALOG_RECORDS_MAP, PRODUCT_METADATA_FIELDS } from '../../../../src/containerConfig'; -import { GetHeightsHandler, GetHeightsPointsRequest } from '../../../../src/heights/controllers/heightsController'; -import { PosWithHeight } from '../../../../src/heights/interfaces'; +import { GetHeightsHandler } from '../../../../src/heights/controllers/heightsController'; import { addProductsDictionaryMiddleware } from '../../../../src/heights/middlewares/addProductsDictionary'; import { createReqCtxMiddleware } from '../../../../src/heights/middlewares/createReqCtx'; -import { positionResAsDegreesMiddleware } from '../../../../src/heights/middlewares/dataToDegrees'; -import { convertReqPositionToRadiansMiddleware } from '../../../../src/heights/middlewares/dataToRadians'; import { validateRequestMiddleware } from '../../../../src/heights/middlewares/validateRequest'; import { CatalogRecords } from '../../../../src/heights/models/catalogRecords'; import { registerTestValues } from '../../../configurations/testContainerConfig'; @@ -26,8 +22,6 @@ describe('Get heights middlewares', function () { let config: IConfig; let commonErrors: CommonErrors; let reqCtxMiddleware: GetHeightsHandler; - let dataToRadiansMiddleware: GetHeightsHandler; - let dataToDegreesMiddleware: GetHeightsHandler; let reqValidateMiddleware: GetHeightsHandler; let addProdDictionaryMiddleware: GetHeightsHandler; let productMetadataFields: string[]; @@ -41,8 +35,6 @@ describe('Get heights middlewares', function () { productMetadataFields = container.resolve(PRODUCT_METADATA_FIELDS); reqCtxMiddleware = createReqCtxMiddleware(logger); - dataToRadiansMiddleware = convertReqPositionToRadiansMiddleware(logger); - dataToDegreesMiddleware = positionResAsDegreesMiddleware(logger); reqValidateMiddleware = validateRequestMiddleware(config, logger, commonErrors); addProdDictionaryMiddleware = addProductsDictionaryMiddleware(logger, productMetadataFields); }); @@ -54,9 +46,7 @@ describe('Get heights middlewares', function () { } as Response; mockRequest = { - body: { - radiansToOriginalPositionsMap: new Map(), - }, + body: {}, } as Request; mockNext = jest.fn(); @@ -71,74 +61,6 @@ describe('Get heights middlewares', function () { }); }); - describe('Data to radians middleware', function () { - const position = { longitude: 86.82918540404042, latitude: 27.888257 }; - const expectedResponseInRadians = { - longitude: 1.5154551721251082, - latitude: 0.4867419072923562, - height: 0, - } as Cartographic; - - beforeEach(function () { - mockResponse = { - locals: {}, - } as Response; - - mockRequest = { - body: { - positions: [position], - radiansToOriginalPositionsMap: new Map([ - [`${expectedResponseInRadians.longitude};${expectedResponseInRadians.latitude}`, `${position.longitude};${position.latitude}`], - ]), - }, - } as Request; - - mockNext = jest.fn(); - }); - - it('Should receive positions array via req.body.positions and attach back positions in radians', function () { - // @ts-ignore - dataToRadiansMiddleware(mockRequest, mockResponse, mockNext); - expect((mockRequest.body as GetHeightsPointsRequest).positions[0]).toEqual(expectedResponseInRadians); - }); - }); - - describe('Data to degrees middleware', function () { - const positionInRadians = { longitude: 1.5154551721251082, latitude: 0.4867419072923562 }; - const expectedResponseInDegrees = { - longitude: 86.82918540404042, - latitude: 27.888257, - } as Cartographic; - - beforeEach(function () { - mockResponse = { - locals: { - positions: [positionInRadians], - }, - } as unknown as Response; - - mockRequest = { - body: { - radiansToOriginalPositionsMap: new Map([ - [ - `${positionInRadians.longitude};${positionInRadians.latitude}`, - `${expectedResponseInDegrees.longitude};${expectedResponseInDegrees.latitude}`, - ], - ]), - }, - } as Request; - - mockNext = jest.fn(); - }); - - it('Should receive positions array via res.locals.positions and attach back positions in degrees', function () { - // @ts-ignore - dataToDegreesMiddleware(mockRequest, mockResponse, mockNext); - - expect((mockResponse.locals.positions as PosWithHeight[])[0]).toEqual(expectedResponseInDegrees); - }); - }); - describe('Add products dictionary', function () { beforeEach(function () { mockResponse = { diff --git a/tests/unit/heights/models/DEMTerrainCacheManager.spec.ts b/tests/unit/heights/models/DEMTerrainCacheManager.spec.ts new file mode 100644 index 0000000..4a2aa6a --- /dev/null +++ b/tests/unit/heights/models/DEMTerrainCacheManager.spec.ts @@ -0,0 +1,31 @@ +import config from 'config'; +import jsLogger from '@map-colonies/js-logger'; +import { PycswDemCatalogRecord } from '@map-colonies/mc-model-types'; +import DEMTerrainCacheManager from '../../../../src/heights/models/DEMTerrainCacheManager'; +import GeotiffHeightProvider from '../../../../src/heights/models/geotiffHeightProvider'; + +describe('DEMTerrainCacheManager', () => { + afterEach(() => jest.restoreAllMocks()); + + it('skips a record whose provider fails to open and still registers the rest', async () => { + const records = [ + { id: 'bad', links: [{ protocol: 'GEOTIFF', url: 'https://gw/cogs/bad.tif' }] }, + { id: 'good', links: [{ protocol: 'GEOTIFF', url: 'https://gw/cogs/good.tif' }] }, + ] as unknown as PycswDemCatalogRecord[]; + + const fromUrlSpy = jest + .spyOn(GeotiffHeightProvider, 'fromUrl') + .mockRejectedValueOnce(new Error('open failed')) + .mockResolvedValueOnce({} as GeotiffHeightProvider); + + const logger = jsLogger({ enabled: false }); + const errorSpy = jest.spyOn(logger, 'error'); + + const manager = new DEMTerrainCacheManager(config, logger); + await manager.initProviders(records); + + expect(fromUrlSpy).toHaveBeenCalledTimes(2); + expect(Object.keys(manager.heightProviders)).toEqual(['good']); + expect(errorSpy).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/heights/models/geotiffHeightProvider.spec.ts b/tests/unit/heights/models/geotiffHeightProvider.spec.ts new file mode 100644 index 0000000..9b5976e --- /dev/null +++ b/tests/unit/heights/models/geotiffHeightProvider.spec.ts @@ -0,0 +1,63 @@ +import GeotiffHeightProvider from '../../../../src/heights/models/geotiffHeightProvider'; + +jest.mock('geotiff', () => ({ + fromUrl: jest.fn(), +})); + +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { fromUrl } = require('geotiff') as { fromUrl: jest.Mock }; + +// origin [34,33], resolution [0.001,-0.001], 100x100, nodata -32768 +const makeImage = (band: number[], nodata: number | null = -32768) => ({ + getOrigin: () => [34.0, 33.0, 0], + getResolution: () => [0.001, -0.001], + getWidth: () => 100, + getHeight: () => 100, + getGDALNoData: () => nodata, + readRasters: jest.fn().mockResolvedValue([Int16Array.from(band)]), +}); + +const mockTiff = (image: unknown): void => { + fromUrl.mockResolvedValue({ getImage: jest.fn().mockResolvedValue(image) }); +}; + +describe('GeotiffHeightProvider', () => { + afterEach(() => jest.clearAllMocks()); + + it('bilinearly interpolates the height at a point', async () => { + // point at pixel (0,0)+0.5 in both axes → center of the 2x2 window + mockTiff(makeImage([200, 202, 204, 206])); + const provider = await GeotiffHeightProvider.fromUrl('http://gw/cogs/x.tif'); + + const [height] = await provider.sample([{ longitude: 34.0005, latitude: 32.9995 }]); + + expect(height).toBeCloseTo(203, 5); + }); + + it('returns null when any neighbor is nodata', async () => { + mockTiff(makeImage([200, -32768, 204, 206])); + const provider = await GeotiffHeightProvider.fromUrl('http://gw/cogs/x.tif'); + + const [height] = await provider.sample([{ longitude: 34.0005, latitude: 32.9995 }]); + + expect(height).toBeNull(); + }); + + it('returns null for a point outside the raster', async () => { + mockTiff(makeImage([200, 202, 204, 206])); + const provider = await GeotiffHeightProvider.fromUrl('http://gw/cogs/x.tif'); + + const [height] = await provider.sample([{ longitude: 10.0, latitude: 10.0 }]); + + expect(height).toBeNull(); + }); + + it('passes token headers through to fromUrl', async () => { + mockTiff(makeImage([1, 1, 1, 1])); + // eslint-disable-next-line @typescript-eslint/naming-convention + await GeotiffHeightProvider.fromUrl('http://gw/cogs/x.tif', { 'x-api-key': 'T' }); + + // eslint-disable-next-line @typescript-eslint/naming-convention + expect(fromUrl).toHaveBeenCalledWith('http://gw/cogs/x.tif', { headers: { 'x-api-key': 'T' } }); + }); +}); diff --git a/tests/unit/heights/models/heightModel.spec.ts b/tests/unit/heights/models/heightModel.spec.ts index 8c402d0..56d4bc7 100644 --- a/tests/unit/heights/models/heightModel.spec.ts +++ b/tests/unit/heights/models/heightModel.spec.ts @@ -1,71 +1,20 @@ import { container } from 'tsyringe'; -import { Cartesian2, Cartographic, CesiumTerrainProvider } from 'cesium'; import { HeightsManager } from '../../../../src/heights/models/heightsManager'; -import mockJsonPoints, { - moreThen150RequestsPositions, - positionsOutsideOfProviders, - emptyPositionsRequest, -} from '../../../../src/heights/MOCKS/mockData'; +import mockJsonPoints, { positionsOutsideOfProviders, emptyPositionsRequest } from '../../../../src/heights/MOCKS/mockData'; import { GetHeightsPointsRequest } from '../../../../src/heights/controllers/heightsController'; -import { PosWithHeight, TerrainTypes } from '../../../../src/heights/interfaces'; +import { TerrainTypes } from '../../../../src/heights/interfaces'; +import { DEM_TERRAIN_CACHE_MANAGER } from '../../../../src/containerConfig'; +import DEMTerrainCacheManager from '../../../../src/heights/models/DEMTerrainCacheManager'; import { registerTestValues } from '../../../configurations/testContainerConfig'; -import { CommonErrorCodes } from '../../../../src/common/commonErrors'; describe('Get Heights model', function () { const mockJsonData = mockJsonPoints as GetHeightsPointsRequest; - const mockJsonDataLowDensity = moreThen150RequestsPositions as GetHeightsPointsRequest; const mockJsonDataOutOfBounds = positionsOutsideOfProviders as GetHeightsPointsRequest; let heightsManager: HeightsManager; - let cesiumTerrainProviderFromUrlSpy: jest.SpyInstance; - - const basicPositionResponse: PosWithHeight = { - latitude: 0, - longitude: 0, - height: 0, - productId: 'dummy_product_id', - } as PosWithHeight; - - beforeAll(function () { - // convert mock json data to degrees. - - mockJsonData.positions = mockJsonData.positions.map((pos) => { - return Cartographic.fromDegrees(pos.longitude, pos.latitude); - }); - - mockJsonDataLowDensity.positions = mockJsonDataLowDensity.positions.map((pos) => { - return Cartographic.fromDegrees(pos.longitude, pos.latitude); - }); - - mockJsonDataOutOfBounds.positions = mockJsonDataOutOfBounds.positions.map((pos) => { - return Cartographic.fromDegrees(pos.longitude, pos.latitude); - }); - - cesiumTerrainProviderFromUrlSpy = jest.spyOn(CesiumTerrainProvider, 'fromUrl'); - - cesiumTerrainProviderFromUrlSpy.mockReturnValue({ - availability: { - available: true, - computeMaximumLevelAtPosition: () => { - return 13; - }, - }, - tilingScheme: { - positionToTileXY: () => { - // Always a maximum of 3 "Tile requests" - - const xys = [new Cartesian2(1, 2), new Cartesian2(3, 4), new Cartesian2(5, 6)]; - const randomXy = Math.floor(Math.random() * xys.length); - - return xys[randomXy]; - }, - }, - }); - }); beforeEach(async function () { await registerTestValues(); - heightsManager = container.resolve(HeightsManager); }); @@ -76,86 +25,71 @@ describe('Get Heights model', function () { }); describe('Given valid parameters', function () { - it('Should return positions with height and extra metadata fields', async function () { + it('Should return positions with height and productId', async function () { const result = await heightsManager.getPoints(mockJsonData.positions, TerrainTypes.MIXED); expect(result).toHaveLength(mockJsonData.positions.length); - - const getHeightsResProperties = Object.keys(basicPositionResponse); - for (const position of result) { - for (const key of getHeightsResProperties) { - expect(position[key as keyof PosWithHeight]).toBeTruthy(); - } + expect(position.height).toBe(100); + expect(position.productId).toBeDefined(); } }); - it('Should return positions with height and productId', async function () { - const result = await heightsManager.getPoints(mockJsonData.positions, TerrainTypes.MIXED); + it('Should return null heights and no productId when no provider matches the product type', async function () { + const result = await heightsManager.getPoints(mockJsonData.positions, TerrainTypes.DSM); expect(result).toHaveLength(mockJsonData.positions.length); - for (const position of result) { - expect(position['productId']).toBeDefined(); + expect(position.height).toBeNull(); + expect(position.productId).toBeUndefined(); } }); - it('Should return the positions with null heights and no fields if no provider match for the request', async function () { - const nonExistingTerrainType = TerrainTypes.DSM; - const result = await heightsManager.getPoints(mockJsonData.positions, nonExistingTerrainType); + it('Should preserve input order when points span providers and out-of-coverage gaps', async function () { + const positions = [ + { longitude: 35.2, latitude: 32.0 }, // inside footprint + { longitude: 0, latitude: 0 }, // outside every footprint + { longitude: 35.5, latitude: 32.5 }, // inside footprint + ]; - expect(result).toHaveLength(mockJsonData.positions.length); + const result = await heightsManager.getPoints(positions, TerrainTypes.MIXED); - for (const position of result) { - expect(position.height).toBeNull(); - expect(position['productId']).toBeUndefined(); - } + expect(result).toHaveLength(3); + expect(result[0]).toMatchObject({ longitude: 35.2, latitude: 32.0, height: 100 }); + expect(result[1]).toMatchObject({ longitude: 0, latitude: 0, height: null }); + expect(result[2]).toMatchObject({ longitude: 35.5, latitude: 32.5, height: 100 }); }); - it('Should be able to return height and data for only a part of the positions', async function () { + it('Should return height only for the positions inside a provider footprint', async function () { const result = await heightsManager.getPoints(mockJsonDataOutOfBounds.positions, TerrainTypes.MIXED); expect(result).toHaveLength(mockJsonDataOutOfBounds.positions.length); - for (const position of result) { - expect(position['latitude'] && position['longitude']).toBeDefined(); - - const isNullHeight = (position.height as number | null) === null; + expect(position.longitude).toBeDefined(); + expect(position.latitude).toBeDefined(); - expect(typeof position['productId'] === 'undefined').toEqual(isNullHeight); + const isNullHeight = position.height === null; + expect(typeof position.productId === 'undefined').toEqual(isNullHeight); } }); }); - describe('Given invalid params', function () { - beforeAll(function () { - cesiumTerrainProviderFromUrlSpy = jest.spyOn(CesiumTerrainProvider, 'fromUrl'); - - cesiumTerrainProviderFromUrlSpy.mockReturnValue({ - availability: { - available: true, - computeMaximumLevelAtPosition: () => { - return 13; - }, - }, - tilingScheme: { - positionToTileXY: (position: Cartographic) => { - // Making sure there is no tiles overlapping for any position. so that each position is a "request". (Assuming unique positions) - return new Cartesian2(position.latitude, position.longitude); - }, - }, - }); - }); + describe('Given a provider failure', function () { + it('Should reject instead of silently returning positions with undefined heights when a provider sample fails', async function () { + const cacheManager = container.resolve(DEM_TERRAIN_CACHE_MANAGER); + for (const provider of Object.values(cacheManager.heightProviders)) { + jest.spyOn(provider, 'sample').mockRejectedValue(new Error('range read failed')); + } - it.skip('Should throw dansity too low error for 150+ points (As configured)', async function () { - await expect(heightsManager.getPoints(mockJsonDataLowDensity.positions, TerrainTypes.MIXED)).rejects.toHaveProperty( - 'errorCode', - CommonErrorCodes.POINTS_DENSITY_TOO_LOW_ERROR - ); + await expect(heightsManager.getPoints(mockJsonData.positions, TerrainTypes.MIXED)).rejects.toThrow('range read failed'); }); + }); - it('Should return empty array', async function () { - await expect(heightsManager.getPoints(emptyPositionsRequest.positions, TerrainTypes.MIXED)).resolves.toEqual([]); + describe('Given invalid params', function () { + it('Should return empty array for empty positions', async function () { + await expect( + heightsManager.getPoints((emptyPositionsRequest as unknown as GetHeightsPointsRequest).positions, TerrainTypes.MIXED) + ).resolves.toEqual([]); }); }); }); diff --git a/tests/unit/heights/utilities.spec.ts b/tests/unit/heights/utilities.spec.ts new file mode 100644 index 0000000..020dcd1 --- /dev/null +++ b/tests/unit/heights/utilities.spec.ts @@ -0,0 +1,21 @@ +import { generateChecksum, isSame } from '../../../src/heights/utilities'; + +describe('utilities', () => { + describe('generateChecksum', () => { + it('is deterministic for the same input', () => { + expect(generateChecksum('abc')).toEqual(generateChecksum('abc')); + }); + it('differs for different input', () => { + expect(generateChecksum('abc')).not.toEqual(generateChecksum('abd')); + }); + }); + + describe('isSame', () => { + it('returns true for deeply equal objects', () => { + expect(isSame({ a: 1, b: [2, 3] }, { a: 1, b: [2, 3] })).toBe(true); + }); + it('returns false for different objects', () => { + expect(isSame({ a: 1 }, { a: 2 })).toBe(false); + }); + }); +});